diff --git a/CHANGELOG.md b/CHANGELOG.md index 05962b4e..495d9f1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,77 @@ # Changelog +## [3.2.0] - 2026-01-22 + +### Added + +- **Home Feed / Explore Feature**: Personalized home feed sections on the home screen + - Works with any extension that has `homeFeed` capability + - Displays sections like "Trending Songs", "Quick Picks", "New Releases", etc. + - Each item shows thumbnail, name, artist, and supports navigation to album/artist/playlist + - Prefers spotify-web if available, otherwise uses first available homeFeed extension + +- **Extension Capabilities System**: Extensions can now declare capabilities in manifest + - New `capabilities` field in extension manifest (e.g., `{ "homeFeed": true, "browseCategories": true }`) + - `Extension` model now has `hasHomeFeed` and `hasBrowseCategories` getters + - Capabilities are parsed from Go backend and exposed to Flutter + +- **YT Music Quick Picks UI**: Special vertical swipeable format for YT Music track sections + - Detects YT Music track-only sections and renders them differently + - PageView with 5 tracks per page, swipeable left/right + - Animated page indicator dots (active = primary color, inactive = gray) + - Each item shows: 48x48 thumbnail, track name, artist, 3-dot menu button + +- **Pull-to-Refresh on Home Feed**: Swipe down to refresh explore sections + - Replaced refresh button next to greeting with pull-to-refresh gesture + - Only active when explore sections are displayed + - Cleaner UI with greeting text only (no refresh icon) + +- **`gobackend.getLocalTime()` API for Extensions**: New utility function to get accurate device local time + - Returns: `{ year, month, day, hour, minute, second, weekday, offsetMinutes, timezone, timestamp }` + - Uses Go's `time.Now()` for accurate device timezone detection + - Solves Goja JS engine's `getTimezoneOffset()` returning 0 issue + +### Fixed + +- **YT Music Greeting Time**: Fixed "Good night" showing in the morning + - Root cause: Goja JS engine returns `getTimezoneOffset() = 0` instead of actual offset + - Now uses `gobackend.getLocalTime().hour` for accurate local hour + - Greeting correctly shows "Good morning/afternoon/evening/night" based on device time + +- **Spotify Home Feed Timezone**: Fixed timezone detection for Spotify API calls + - Now uses `gobackend.getLocalTime().timezone` or offset mapping + - Ensures personalized content is based on correct user timezone + +### Extensions + +- **spotify-web Extension**: Updated to v1.8.0 + - Added `capabilities: { homeFeed: true, browseCategories: true }` to manifest + - `fetchHomeFeed()` now uses `gobackend.getLocalTime()` for timezone detection + - Removed reliance on Goja's broken `getTimezoneOffset()` and `Intl.DateTimeFormat()` + +- **ytmusic-spotiflac Extension**: Updated to v1.6.0 + - Added `capabilities: { homeFeed: true }` to manifest + - `getTimeBasedGreeting()` now uses `gobackend.getLocalTime().hour` directly + - Simplified greeting logic - no more manual UTC offset calculations + +### Technical + +- **Go Backend**: Added `getLocalTime` function to `RegisterGoBackendAPIs()` + - File: `go_backend/extension_runtime_utils.go` + - Returns device local time with timezone info via `time.Now()` + - Offset follows JS convention (negative for east of UTC) + +- **Flutter Explore Provider**: Updated extension selection logic + - File: `lib/providers/explore_provider.dart` + - Finds extensions with `hasHomeFeed` capability + - Prefers spotify-web if available, falls back to first available + +- **Flutter Home Tab**: Refactored explore sections rendering + - File: `lib/screens/home_tab.dart` + - Added `RefreshIndicator` wrapper with `notificationPredicate` for conditional refresh + - Added `_buildYTMusicQuickPicksSection()` for special YT Music format + - Added `_QuickPicksPageView` StatefulWidget for swipeable track pages + ## [3.1.3] - 2026-01-19 ### Added diff --git a/README.md b/README.md index b0df7bf7..0e2811d1 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,6 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc ![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white) ![iOS](https://img.shields.io/badge/iOS-14.0%2B-000000?style=for-the-badge&logo=apple&logoColor=white) -

- - Telegram Channel - - - - Telegram Community - -

- ### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases) @@ -64,6 +54,18 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window > **Note:** Currently unavailable because the GitHub account is suspended. Alternatively, use [SpotiFLAC-Next](https://github.com/spotiverse/SpotiFLAC-Next) until the original is restored. +## Telegram + +

+ + Telegram Channel + + + + Telegram Community + +

+ ## FAQ **Q: Why is my download failing with "Song not found"?** @@ -81,6 +83,9 @@ A: The app needs permission to save downloaded files to your device. On Android **Q: Is this app safe?** A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README). +**Q: Why is download not working in my country?** +A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region. + ## Disclaimer This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement. diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 625bfd66..85a32ffe 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -678,6 +678,21 @@ class MainActivity: FlutterActivity() { } result.success(null) } + // Extension Home Feed (Explore) + "getExtensionHomeFeed" -> { + val extensionId = call.argument("extension_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getExtensionHomeFeedJSON(extensionId) + } + result.success(response) + } + "getExtensionBrowseCategories" -> { + val extensionId = call.argument("extension_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getExtensionBrowseCategoriesJSON(extensionId) + } + result.success(response) + } else -> result.notImplemented() } } catch (e: Exception) { diff --git a/go_backend/exports.go b/go_backend/exports.go index 17112d93..6c718a52 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -2082,3 +2082,85 @@ func ClearStoreCacheJSON() error { store.ClearCache() return nil } + +// GetExtensionHomeFeedJSON calls getHomeFeed on any extension that supports it +func GetExtensionHomeFeedJSON(extensionID string) (string, error) { + manager := GetExtensionManager() + ext, err := manager.GetExtension(extensionID) + if err != nil { + return "", err + } + + if !ext.Enabled { + return "", fmt.Errorf("extension '%s' is disabled", extensionID) + } + + provider := NewExtensionProviderWrapper(ext) + + script := ` + (function() { + if (typeof extension !== 'undefined' && typeof extension.getHomeFeed === 'function') { + return extension.getHomeFeed(); + } + return null; + })() + ` + + result, err := RunWithTimeoutAndRecover(provider.vm, script, 60*time.Second) + if err != nil { + return "", fmt.Errorf("getHomeFeed failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return "", fmt.Errorf("getHomeFeed returned null") + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return "", fmt.Errorf("failed to marshal result: %w", err) + } + + return string(jsonBytes), nil +} + +// GetExtensionBrowseCategoriesJSON calls getBrowseCategories on any extension that supports it +func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) { + manager := GetExtensionManager() + ext, err := manager.GetExtension(extensionID) + if err != nil { + return "", err + } + + if !ext.Enabled { + return "", fmt.Errorf("extension '%s' is disabled", extensionID) + } + + provider := NewExtensionProviderWrapper(ext) + + script := ` + (function() { + if (typeof extension !== 'undefined' && typeof extension.getBrowseCategories === 'function') { + return extension.getBrowseCategories(); + } + return null; + })() + ` + + result, err := RunWithTimeoutAndRecover(provider.vm, script, 30*time.Second) + if err != nil { + return "", fmt.Errorf("getBrowseCategories failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return "", fmt.Errorf("getBrowseCategories returned null") + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return "", fmt.Errorf("failed to marshal result: %w", err) + } + + return string(jsonBytes), nil +} diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index baa4ba72..706a32b9 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -719,27 +719,28 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) { extensions := m.GetAllExtensions() type ExtensionInfo struct { - ID string `json:"id"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - Version string `json:"version"` - Author string `json:"author"` - Description string `json:"description"` - Homepage string `json:"homepage,omitempty"` - IconPath string `json:"icon_path,omitempty"` - Types []ExtensionType `json:"types"` - Enabled bool `json:"enabled"` - Status string `json:"status"` - Error string `json:"error_message,omitempty"` - Settings []ExtensionSetting `json:"settings,omitempty"` - QualityOptions []QualityOption `json:"quality_options,omitempty"` - Permissions []string `json:"permissions"` - HasMetadataProvider bool `json:"has_metadata_provider"` - HasDownloadProvider bool `json:"has_download_provider"` - SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"` - SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"` - TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"` - PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + Homepage string `json:"homepage,omitempty"` + IconPath string `json:"icon_path,omitempty"` + Types []ExtensionType `json:"types"` + Enabled bool `json:"enabled"` + Status string `json:"status"` + Error string `json:"error_message,omitempty"` + Settings []ExtensionSetting `json:"settings,omitempty"` + QualityOptions []QualityOption `json:"quality_options,omitempty"` + Permissions []string `json:"permissions"` + HasMetadataProvider bool `json:"has_metadata_provider"` + HasDownloadProvider bool `json:"has_download_provider"` + SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"` + SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"` + TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"` + PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"` + Capabilities map[string]interface{} `json:"capabilities,omitempty"` } infos := make([]ExtensionInfo, len(extensions)) @@ -796,6 +797,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) { SearchBehavior: ext.Manifest.SearchBehavior, TrackMatching: ext.Manifest.TrackMatching, PostProcessing: ext.Manifest.PostProcessing, + Capabilities: ext.Manifest.Capabilities, } } diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index 7a850a55..65740067 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -107,24 +107,25 @@ type PostProcessingConfig struct { // ExtensionManifest represents the manifest.json of an extension type ExtensionManifest struct { - Name string `json:"name"` - DisplayName string `json:"displayName"` - Version string `json:"version"` - Author string `json:"author"` - Description string `json:"description"` - Homepage string `json:"homepage,omitempty"` - Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png") - Types []ExtensionType `json:"type"` - Permissions ExtensionPermissions `json:"permissions"` - Settings []ExtensionSetting `json:"settings,omitempty"` - QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers - MinAppVersion string `json:"minAppVersion,omitempty"` - SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify - SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon) - SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior - URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling - TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching - PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks + Name string `json:"name"` + DisplayName string `json:"displayName"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + Homepage string `json:"homepage,omitempty"` + Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png") + Types []ExtensionType `json:"type"` + Permissions ExtensionPermissions `json:"permissions"` + Settings []ExtensionSetting `json:"settings,omitempty"` + QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers + MinAppVersion string `json:"minAppVersion,omitempty"` + SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify + SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon) + SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior + URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling + TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching + PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks + Capabilities map[string]interface{} `json:"capabilities,omitempty"` // Extension capabilities (homeFeed, browseCategories, etc.) } // ManifestValidationError represents a validation error in the manifest diff --git a/go_backend/extension_runtime_utils.go b/go_backend/extension_runtime_utils.go index 37d86920..568b2366 100644 --- a/go_backend/extension_runtime_utils.go +++ b/go_backend/extension_runtime_utils.go @@ -12,6 +12,7 @@ import ( "encoding/json" "fmt" "strings" + "time" "github.com/dop251/goja" ) @@ -371,4 +372,24 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) { return vm.ToValue(buildFilenameFromTemplate(template, metadata)) }) + + // Expose getLocalTime - returns device local time info + obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value { + now := time.Now() + _, offsetSeconds := now.Zone() + offsetMinutes := offsetSeconds / 60 + + return vm.ToValue(map[string]interface{}{ + "year": now.Year(), + "month": int(now.Month()), + "day": now.Day(), + "hour": now.Hour(), + "minute": now.Minute(), + "second": now.Second(), + "weekday": int(now.Weekday()), + "offsetMinutes": -offsetMinutes, // JS convention: negative for east of UTC + "timezone": now.Location().String(), + "timestamp": now.Unix(), + }) + }) } diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c6a373d7..31538db2 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -605,6 +605,21 @@ import Gobackend // Import Go framework if let error = error { throw error } return nil + // Extension Home Feed API + case "getExtensionHomeFeed": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let response = GobackendGetExtensionHomeFeedJSON(extensionId, &error) + if let error = error { throw error } + return response + + case "getExtensionBrowseCategories": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let response = GobackendGetExtensionBrowseCategoriesJSON(extensionId, &error) + if let error = error { throw error } + return response + default: throw NSError( domain: "SpotiFLAC", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f450dcd6..e2c92ab4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -16,6 +16,7 @@ import 'app_localizations_ko.dart'; import 'app_localizations_nl.dart'; import 'app_localizations_pt.dart'; import 'app_localizations_ru.dart'; +import 'app_localizations_tr.dart'; import 'app_localizations_zh.dart'; // ignore_for_file: type=lint @@ -117,6 +118,7 @@ abstract class AppLocalizations { Locale('pt'), Locale('pt', 'PT'), Locale('ru'), + Locale('tr'), Locale('zh'), Locale('zh', 'CN'), Locale('zh', 'TW'), @@ -3811,6 +3813,7 @@ class _AppLocalizationsDelegate 'nl', 'pt', 'ru', + 'tr', 'zh', ].contains(locale.languageCode); @@ -3873,6 +3876,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) { return AppLocalizationsPt(); case 'ru': return AppLocalizationsRu(); + case 'tr': + return AppLocalizationsTr(); case 'zh': return AppLocalizationsZh(); } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart new file mode 100644 index 00000000..e94fd348 --- /dev/null +++ b/lib/l10n/app_localizations_tr.dart @@ -0,0 +1,2085 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Turkish (`tr`). +class AppLocalizationsTr extends AppLocalizations { + AppLocalizationsTr([String locale = 'tr']) : super(locale); + + @override + String get appName => 'SpotiFLAC'; + + @override + String get appDescription => + 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + + @override + String get navHome => 'Home'; + + @override + String get navHistory => 'History'; + + @override + String get navSettings => 'Settings'; + + @override + String get navStore => 'Store'; + + @override + String get homeTitle => 'Home'; + + @override + String get homeSearchHint => 'Paste Spotify URL or search...'; + + @override + String homeSearchHintExtension(String extensionName) { + return 'Search with $extensionName...'; + } + + @override + String get homeSubtitle => 'Paste a Spotify link or search by name'; + + @override + String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; + + @override + String get homeRecent => 'Recent'; + + @override + String get historyTitle => 'History'; + + @override + String historyDownloading(int count) { + return 'Downloading ($count)'; + } + + @override + String get historyDownloaded => 'Downloaded'; + + @override + String get historyFilterAll => 'All'; + + @override + String get historyFilterAlbums => 'Albums'; + + @override + String get historyFilterSingles => 'Singles'; + + @override + String historyTracksCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String historyAlbumsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get historyNoDownloads => 'No download history'; + + @override + String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; + + @override + String get historyNoAlbums => 'No album downloads'; + + @override + String get historyNoAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get historyNoSingles => 'No single downloads'; + + @override + String get historyNoSinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get historySearchHint => 'Search history...'; + + @override + String get settingsTitle => 'Settings'; + + @override + String get settingsDownload => 'Download'; + + @override + String get settingsAppearance => 'Appearance'; + + @override + String get settingsOptions => 'Options'; + + @override + String get settingsExtensions => 'Extensions'; + + @override + String get settingsAbout => 'About'; + + @override + String get downloadTitle => 'Download'; + + @override + String get downloadLocation => 'Download Location'; + + @override + String get downloadLocationSubtitle => 'Choose where to save files'; + + @override + String get downloadLocationDefault => 'Default location'; + + @override + String get downloadDefaultService => 'Default Service'; + + @override + String get downloadDefaultServiceSubtitle => 'Service used for downloads'; + + @override + String get downloadDefaultQuality => 'Default Quality'; + + @override + String get downloadAskQuality => 'Ask Quality Before Download'; + + @override + String get downloadAskQualitySubtitle => + 'Show quality picker for each download'; + + @override + String get downloadFilenameFormat => 'Filename Format'; + + @override + String get downloadFolderOrganization => 'Folder Organization'; + + @override + String get downloadSeparateSingles => 'Separate Singles'; + + @override + String get downloadSeparateSinglesSubtitle => + 'Put single tracks in a separate folder'; + + @override + String get qualityBest => 'Best Available'; + + @override + String get qualityFlac => 'FLAC'; + + @override + String get quality320 => '320 kbps'; + + @override + String get quality128 => '128 kbps'; + + @override + String get appearanceTitle => 'Appearance'; + + @override + String get appearanceTheme => 'Theme'; + + @override + String get appearanceThemeSystem => 'System'; + + @override + String get appearanceThemeLight => 'Light'; + + @override + String get appearanceThemeDark => 'Dark'; + + @override + String get appearanceDynamicColor => 'Dynamic Color'; + + @override + String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; + + @override + String get appearanceAccentColor => 'Accent Color'; + + @override + String get appearanceHistoryView => 'History View'; + + @override + String get appearanceHistoryViewList => 'List'; + + @override + String get appearanceHistoryViewGrid => 'Grid'; + + @override + String get optionsTitle => 'Options'; + + @override + String get optionsSearchSource => 'Search Source'; + + @override + String get optionsPrimaryProvider => 'Primary Provider'; + + @override + String get optionsPrimaryProviderSubtitle => + 'Service used when searching by track name.'; + + @override + String optionsUsingExtension(String extensionName) { + return 'Using extension: $extensionName'; + } + + @override + String get optionsSwitchBack => + 'Tap Deezer or Spotify to switch back from extension'; + + @override + String get optionsAutoFallback => 'Auto Fallback'; + + @override + String get optionsAutoFallbackSubtitle => + 'Try other services if download fails'; + + @override + String get optionsUseExtensionProviders => 'Use Extension Providers'; + + @override + String get optionsUseExtensionProvidersOn => 'Extensions will be tried first'; + + @override + String get optionsUseExtensionProvidersOff => 'Using built-in providers only'; + + @override + String get optionsEmbedLyrics => 'Embed Lyrics'; + + @override + String get optionsEmbedLyricsSubtitle => + 'Embed synced lyrics into FLAC files'; + + @override + String get optionsMaxQualityCover => 'Max Quality Cover'; + + @override + String get optionsMaxQualityCoverSubtitle => + 'Download highest resolution cover art'; + + @override + String get optionsConcurrentDownloads => 'Concurrent Downloads'; + + @override + String get optionsConcurrentSequential => 'Sequential (1 at a time)'; + + @override + String optionsConcurrentParallel(int count) { + return '$count parallel downloads'; + } + + @override + String get optionsConcurrentWarning => + 'Parallel downloads may trigger rate limiting'; + + @override + String get optionsExtensionStore => 'Extension Store'; + + @override + String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation'; + + @override + String get optionsCheckUpdates => 'Check for Updates'; + + @override + String get optionsCheckUpdatesSubtitle => + 'Notify when new version is available'; + + @override + String get optionsUpdateChannel => 'Update Channel'; + + @override + String get optionsUpdateChannelStable => 'Stable releases only'; + + @override + String get optionsUpdateChannelPreview => 'Get preview releases'; + + @override + String get optionsUpdateChannelWarning => + 'Preview may contain bugs or incomplete features'; + + @override + String get optionsClearHistory => 'Clear Download History'; + + @override + String get optionsClearHistorySubtitle => + 'Remove all downloaded tracks from history'; + + @override + String get optionsDetailedLogging => 'Detailed Logging'; + + @override + String get optionsDetailedLoggingOn => 'Detailed logs are being recorded'; + + @override + String get optionsDetailedLoggingOff => 'Enable for bug reports'; + + @override + String get optionsSpotifyCredentials => 'Spotify Credentials'; + + @override + String optionsSpotifyCredentialsConfigured(String clientId) { + return 'Client ID: $clientId...'; + } + + @override + String get optionsSpotifyCredentialsRequired => 'Required - tap to configure'; + + @override + String get optionsSpotifyWarning => + 'Spotify requires your own API credentials. Get them free from developer.spotify.com'; + + @override + String get extensionsTitle => 'Extensions'; + + @override + String get extensionsInstalled => 'Installed Extensions'; + + @override + String get extensionsNone => 'No extensions installed'; + + @override + String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; + + @override + String get extensionsEnabled => 'Enabled'; + + @override + String get extensionsDisabled => 'Disabled'; + + @override + String extensionsVersion(String version) { + return 'Version $version'; + } + + @override + String extensionsAuthor(String author) { + return 'by $author'; + } + + @override + String get extensionsUninstall => 'Uninstall'; + + @override + String get extensionsSetAsSearch => 'Set as Search Provider'; + + @override + String get storeTitle => 'Extension Store'; + + @override + String get storeSearch => 'Search extensions...'; + + @override + String get storeInstall => 'Install'; + + @override + String get storeInstalled => 'Installed'; + + @override + String get storeUpdate => 'Update'; + + @override + String get aboutTitle => 'About'; + + @override + String get aboutContributors => 'Contributors'; + + @override + String get aboutMobileDeveloper => 'Mobile version developer'; + + @override + String get aboutOriginalCreator => 'Creator of the original SpotiFLAC'; + + @override + String get aboutLogoArtist => + 'The talented artist who created our beautiful app logo!'; + + @override + String get aboutTranslators => 'Translators'; + + @override + String get aboutSpecialThanks => 'Special Thanks'; + + @override + String get aboutLinks => 'Links'; + + @override + String get aboutMobileSource => 'Mobile source code'; + + @override + String get aboutPCSource => 'PC source code'; + + @override + String get aboutReportIssue => 'Report an issue'; + + @override + String get aboutReportIssueSubtitle => 'Report any problems you encounter'; + + @override + String get aboutFeatureRequest => 'Feature request'; + + @override + String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; + + @override + String get aboutTelegramChannel => 'Telegram Channel'; + + @override + String get aboutTelegramChannelSubtitle => 'Announcements and updates'; + + @override + String get aboutTelegramChat => 'Telegram Community'; + + @override + String get aboutTelegramChatSubtitle => 'Chat with other users'; + + @override + String get aboutSocial => 'Social'; + + @override + String get aboutSupport => 'Support'; + + @override + String get aboutBuyMeCoffee => 'Buy me a coffee'; + + @override + String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi'; + + @override + String get aboutApp => 'App'; + + @override + String get aboutVersion => 'Version'; + + @override + String get aboutBinimumDesc => + 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!'; + + @override + String get aboutSachinsenalDesc => + 'The original HiFi project creator. The foundation of Tidal integration!'; + + @override + String get aboutDoubleDouble => 'DoubleDouble'; + + @override + String get aboutDoubleDoubleDesc => + 'Amazing API for Amazon Music downloads. Thank you for making it free!'; + + @override + String get aboutDabMusic => 'DAB Music'; + + @override + String get aboutDabMusicDesc => + 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; + + @override + String get aboutAppDescription => + 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + + @override + String get albumTitle => 'Album'; + + @override + String albumTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String get albumDownloadAll => 'Download All'; + + @override + String get albumDownloadRemaining => 'Download Remaining'; + + @override + String get playlistTitle => 'Playlist'; + + @override + String get artistTitle => 'Artist'; + + @override + String get artistAlbums => 'Albums'; + + @override + String get artistSingles => 'Singles & EPs'; + + @override + String get artistCompilations => 'Compilations'; + + @override + String artistReleases(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count releases', + one: '1 release', + ); + return '$_temp0'; + } + + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + + @override + String get trackMetadataTitle => 'Track Info'; + + @override + String get trackMetadataArtist => 'Artist'; + + @override + String get trackMetadataAlbum => 'Album'; + + @override + String get trackMetadataDuration => 'Duration'; + + @override + String get trackMetadataQuality => 'Quality'; + + @override + String get trackMetadataPath => 'File Path'; + + @override + String get trackMetadataDownloadedAt => 'Downloaded'; + + @override + String get trackMetadataService => 'Service'; + + @override + String get trackMetadataPlay => 'Play'; + + @override + String get trackMetadataShare => 'Share'; + + @override + String get trackMetadataDelete => 'Delete'; + + @override + String get trackMetadataRedownload => 'Re-download'; + + @override + String get trackMetadataOpenFolder => 'Open Folder'; + + @override + String get setupTitle => 'Welcome to SpotiFLAC'; + + @override + String get setupSubtitle => 'Let\'s get you started'; + + @override + String get setupStoragePermission => 'Storage Permission'; + + @override + String get setupStoragePermissionSubtitle => + 'Required to save downloaded files'; + + @override + String get setupStoragePermissionGranted => 'Permission granted'; + + @override + String get setupStoragePermissionDenied => 'Permission denied'; + + @override + String get setupGrantPermission => 'Grant Permission'; + + @override + String get setupDownloadLocation => 'Download Location'; + + @override + String get setupChooseFolder => 'Choose Folder'; + + @override + String get setupContinue => 'Continue'; + + @override + String get setupSkip => 'Skip for now'; + + @override + String get setupStorageAccessRequired => 'Storage Access Required'; + + @override + String get setupStorageAccessMessage => + 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; + + @override + String get setupStorageAccessMessageAndroid11 => + 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; + + @override + String get setupOpenSettings => 'Open Settings'; + + @override + String get setupPermissionDeniedMessage => + 'Permission denied. Please grant all permissions to continue.'; + + @override + String setupPermissionRequired(String permissionType) { + return '$permissionType Permission Required'; + } + + @override + String setupPermissionRequiredMessage(String permissionType) { + return '$permissionType permission is required for the best experience. You can change this later in Settings.'; + } + + @override + String get setupSelectDownloadFolder => 'Select Download Folder'; + + @override + String get setupUseDefaultFolder => 'Use Default Folder?'; + + @override + String get setupNoFolderSelected => + 'No folder selected. Would you like to use the default Music folder?'; + + @override + String get setupUseDefault => 'Use Default'; + + @override + String get setupDownloadLocationTitle => 'Download Location'; + + @override + String get setupDownloadLocationIosMessage => + 'On iOS, downloads are saved to the app\'s Documents folder. You can access them via the Files app.'; + + @override + String get setupAppDocumentsFolder => 'App Documents Folder'; + + @override + String get setupAppDocumentsFolderSubtitle => + 'Recommended - accessible via Files app'; + + @override + String get setupChooseFromFiles => 'Choose from Files'; + + @override + String get setupChooseFromFilesSubtitle => 'Select iCloud or other location'; + + @override + String get setupIosEmptyFolderWarning => + 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; + + @override + String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; + + @override + String get setupStepStorage => 'Storage'; + + @override + String get setupStepNotification => 'Notification'; + + @override + String get setupStepFolder => 'Folder'; + + @override + String get setupStepSpotify => 'Spotify'; + + @override + String get setupStepPermission => 'Permission'; + + @override + String get setupStorageGranted => 'Storage Permission Granted!'; + + @override + String get setupStorageRequired => 'Storage Permission Required'; + + @override + String get setupStorageDescription => + 'SpotiFLAC needs storage permission to save your downloaded music files.'; + + @override + String get setupNotificationGranted => 'Notification Permission Granted!'; + + @override + String get setupNotificationEnable => 'Enable Notifications'; + + @override + String get setupNotificationDescription => + 'Get notified when downloads complete or require attention.'; + + @override + String get setupFolderSelected => 'Download Folder Selected!'; + + @override + String get setupFolderChoose => 'Choose Download Folder'; + + @override + String get setupFolderDescription => + 'Select a folder where your downloaded music will be saved.'; + + @override + String get setupChangeFolder => 'Change Folder'; + + @override + String get setupSelectFolder => 'Select Folder'; + + @override + String get setupSpotifyApiOptional => 'Spotify API (Optional)'; + + @override + String get setupSpotifyApiDescription => + 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; + + @override + String get setupUseSpotifyApi => 'Use Spotify API'; + + @override + String get setupEnterCredentialsBelow => 'Enter your credentials below'; + + @override + String get setupUsingDeezer => 'Using Deezer (no account needed)'; + + @override + String get setupEnterClientId => 'Enter Spotify Client ID'; + + @override + String get setupEnterClientSecret => 'Enter Spotify Client Secret'; + + @override + String get setupGetFreeCredentials => + 'Get your free API credentials from the Spotify Developer Dashboard.'; + + @override + String get setupEnableNotifications => 'Enable Notifications'; + + @override + String get setupProceedToNextStep => 'You can now proceed to the next step.'; + + @override + String get setupNotificationProgressDescription => + 'You will receive download progress notifications.'; + + @override + String get setupNotificationBackgroundDescription => + 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; + + @override + String get setupSkipForNow => 'Skip for now'; + + @override + String get setupBack => 'Back'; + + @override + String get setupNext => 'Next'; + + @override + String get setupGetStarted => 'Get Started'; + + @override + String get setupSkipAndStart => 'Skip & Start'; + + @override + String get setupAllowAccessToManageFiles => + 'Please enable \"Allow access to manage all files\" in the next screen.'; + + @override + String get setupGetCredentialsFromSpotify => + 'Get credentials from developer.spotify.com'; + + @override + String get dialogCancel => 'Cancel'; + + @override + String get dialogOk => 'OK'; + + @override + String get dialogSave => 'Save'; + + @override + String get dialogDelete => 'Delete'; + + @override + String get dialogRetry => 'Retry'; + + @override + String get dialogClose => 'Close'; + + @override + String get dialogYes => 'Yes'; + + @override + String get dialogNo => 'No'; + + @override + String get dialogClear => 'Clear'; + + @override + String get dialogConfirm => 'Confirm'; + + @override + String get dialogDone => 'Done'; + + @override + String get dialogImport => 'Import'; + + @override + String get dialogDiscard => 'Discard'; + + @override + String get dialogRemove => 'Remove'; + + @override + String get dialogUninstall => 'Uninstall'; + + @override + String get dialogDiscardChanges => 'Discard Changes?'; + + @override + String get dialogUnsavedChanges => + 'You have unsaved changes. Do you want to discard them?'; + + @override + String get dialogDownloadFailed => 'Download Failed'; + + @override + String get dialogTrackLabel => 'Track:'; + + @override + String get dialogArtistLabel => 'Artist:'; + + @override + String get dialogErrorLabel => 'Error:'; + + @override + String get dialogClearAll => 'Clear All'; + + @override + String get dialogClearAllDownloads => + 'Are you sure you want to clear all downloads?'; + + @override + String get dialogRemoveFromDevice => 'Remove from device?'; + + @override + String get dialogRemoveExtension => 'Remove Extension'; + + @override + String get dialogRemoveExtensionMessage => + 'Are you sure you want to remove this extension? This cannot be undone.'; + + @override + String get dialogUninstallExtension => 'Uninstall Extension?'; + + @override + String dialogUninstallExtensionMessage(String extensionName) { + return 'Are you sure you want to remove $extensionName?'; + } + + @override + String get dialogClearHistoryTitle => 'Clear History'; + + @override + String get dialogClearHistoryMessage => + 'Are you sure you want to clear all download history? This cannot be undone.'; + + @override + String get dialogDeleteSelectedTitle => 'Delete Selected'; + + @override + String dialogDeleteSelectedMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0 from history?\n\nThis will also delete the files from storage.'; + } + + @override + String get dialogImportPlaylistTitle => 'Import Playlist'; + + @override + String dialogImportPlaylistMessage(int count) { + return 'Found $count tracks in CSV. Add them to download queue?'; + } + + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + + @override + String snackbarAddedToQueue(String trackName) { + return 'Added \"$trackName\" to queue'; + } + + @override + String snackbarAddedTracksToQueue(int count) { + return 'Added $count tracks to queue'; + } + + @override + String snackbarAlreadyDownloaded(String trackName) { + return '\"$trackName\" already downloaded'; + } + + @override + String get snackbarHistoryCleared => 'History cleared'; + + @override + String get snackbarCredentialsSaved => 'Credentials saved'; + + @override + String get snackbarCredentialsCleared => 'Credentials cleared'; + + @override + String snackbarDeletedTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Deleted $count $_temp0'; + } + + @override + String snackbarCannotOpenFile(String error) { + return 'Cannot open file: $error'; + } + + @override + String get snackbarFillAllFields => 'Please fill all fields'; + + @override + String get snackbarViewQueue => 'View Queue'; + + @override + String snackbarFailedToLoad(String error) { + return 'Failed to load: $error'; + } + + @override + String snackbarUrlCopied(String platform) { + return '$platform URL copied to clipboard'; + } + + @override + String get snackbarFileNotFound => 'File not found'; + + @override + String get snackbarSelectExtFile => 'Please select a .spotiflac-ext file'; + + @override + String get snackbarProviderPrioritySaved => 'Provider priority saved'; + + @override + String get snackbarMetadataProviderSaved => + 'Metadata provider priority saved'; + + @override + String snackbarExtensionInstalled(String extensionName) { + return '$extensionName installed.'; + } + + @override + String snackbarExtensionUpdated(String extensionName) { + return '$extensionName updated.'; + } + + @override + String get snackbarFailedToInstall => 'Failed to install extension'; + + @override + String get snackbarFailedToUpdate => 'Failed to update extension'; + + @override + String get errorRateLimited => 'Rate Limited'; + + @override + String get errorRateLimitedMessage => + 'Too many requests. Please wait a moment before searching again.'; + + @override + String errorFailedToLoad(String item) { + return 'Failed to load $item'; + } + + @override + String get errorNoTracksFound => 'No tracks found'; + + @override + String errorMissingExtensionSource(String item) { + return 'Cannot load $item: missing extension source'; + } + + @override + String get statusQueued => 'Queued'; + + @override + String get statusDownloading => 'Downloading'; + + @override + String get statusFinalizing => 'Finalizing'; + + @override + String get statusCompleted => 'Completed'; + + @override + String get statusFailed => 'Failed'; + + @override + String get statusSkipped => 'Skipped'; + + @override + String get statusPaused => 'Paused'; + + @override + String get actionPause => 'Pause'; + + @override + String get actionResume => 'Resume'; + + @override + String get actionCancel => 'Cancel'; + + @override + String get actionStop => 'Stop'; + + @override + String get actionSelect => 'Select'; + + @override + String get actionSelectAll => 'Select All'; + + @override + String get actionDeselect => 'Deselect'; + + @override + String get actionPaste => 'Paste'; + + @override + String get actionImportCsv => 'Import CSV'; + + @override + String get actionRemoveCredentials => 'Remove Credentials'; + + @override + String get actionSaveCredentials => 'Save Credentials'; + + @override + String selectionSelected(int count) { + return '$count selected'; + } + + @override + String get selectionAllSelected => 'All tracks selected'; + + @override + String get selectionTapToSelect => 'Tap tracks to select'; + + @override + String selectionDeleteTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0'; + } + + @override + String get selectionSelectToDelete => 'Select tracks to delete'; + + @override + String progressFetchingMetadata(int current, int total) { + return 'Fetching metadata... $current/$total'; + } + + @override + String get progressReadingCsv => 'Reading CSV...'; + + @override + String get searchSongs => 'Songs'; + + @override + String get searchArtists => 'Artists'; + + @override + String get searchAlbums => 'Albums'; + + @override + String get searchPlaylists => 'Playlists'; + + @override + String get tooltipPlay => 'Play'; + + @override + String get tooltipCancel => 'Cancel'; + + @override + String get tooltipStop => 'Stop'; + + @override + String get tooltipRetry => 'Retry'; + + @override + String get tooltipRemove => 'Remove'; + + @override + String get tooltipClear => 'Clear'; + + @override + String get tooltipPaste => 'Paste'; + + @override + String get filenameFormat => 'Filename Format'; + + @override + String filenameFormatPreview(String preview) { + return 'Preview: $preview'; + } + + @override + String get filenameAvailablePlaceholders => 'Available placeholders:'; + + @override + String filenameHint(Object artist, Object title) { + return '$artist - $title'; + } + + @override + String get folderOrganization => 'Folder Organization'; + + @override + String get folderOrganizationNone => 'No organization'; + + @override + String get folderOrganizationByArtist => 'By Artist'; + + @override + String get folderOrganizationByAlbum => 'By Album'; + + @override + String get folderOrganizationByArtistAlbum => 'Artist/Album'; + + @override + String get folderOrganizationDescription => + 'Organize downloaded files into folders'; + + @override + String get folderOrganizationNoneSubtitle => 'All files in download folder'; + + @override + String get folderOrganizationByArtistSubtitle => + 'Separate folder for each artist'; + + @override + String get folderOrganizationByAlbumSubtitle => + 'Separate folder for each album'; + + @override + String get folderOrganizationByArtistAlbumSubtitle => + 'Nested folders for artist and album'; + + @override + String get updateAvailable => 'Update Available'; + + @override + String updateNewVersion(String version) { + return 'Version $version is available'; + } + + @override + String get updateDownload => 'Download'; + + @override + String get updateLater => 'Later'; + + @override + String get updateChangelog => 'Changelog'; + + @override + String get updateStartingDownload => 'Starting download...'; + + @override + String get updateDownloadFailed => 'Download failed'; + + @override + String get updateFailedMessage => 'Failed to download update'; + + @override + String get updateNewVersionReady => 'A new version is ready'; + + @override + String get updateCurrent => 'Current'; + + @override + String get updateNew => 'New'; + + @override + String get updateDownloading => 'Downloading...'; + + @override + String get updateWhatsNew => 'What\'s New'; + + @override + String get updateDownloadInstall => 'Download & Install'; + + @override + String get updateDontRemind => 'Don\'t remind'; + + @override + String get providerPriority => 'Provider Priority'; + + @override + String get providerPrioritySubtitle => 'Drag to reorder download providers'; + + @override + String get providerPriorityTitle => 'Provider Priority'; + + @override + String get providerPriorityDescription => + 'Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.'; + + @override + String get providerPriorityInfo => + 'If a track is not available on the first provider, the app will automatically try the next one.'; + + @override + String get providerBuiltIn => 'Built-in'; + + @override + String get providerExtension => 'Extension'; + + @override + String get metadataProviderPriority => 'Metadata Provider Priority'; + + @override + String get metadataProviderPrioritySubtitle => + 'Order used when fetching track metadata'; + + @override + String get metadataProviderPriorityTitle => 'Metadata Priority'; + + @override + String get metadataProviderPriorityDescription => + 'Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.'; + + @override + String get metadataProviderPriorityInfo => + 'Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.'; + + @override + String get metadataNoRateLimits => 'No rate limits'; + + @override + String get metadataMayRateLimit => 'May rate limit'; + + @override + String get logTitle => 'Logs'; + + @override + String get logCopy => 'Copy Logs'; + + @override + String get logClear => 'Clear Logs'; + + @override + String get logShare => 'Share Logs'; + + @override + String get logEmpty => 'No logs yet'; + + @override + String get logCopied => 'Logs copied to clipboard'; + + @override + String get logSearchHint => 'Search logs...'; + + @override + String get logFilterLevel => 'Level'; + + @override + String get logFilterSection => 'Filter'; + + @override + String get logShareLogs => 'Share logs'; + + @override + String get logClearLogs => 'Clear logs'; + + @override + String get logClearLogsTitle => 'Clear Logs'; + + @override + String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; + + @override + String get logIspBlocking => 'ISP BLOCKING DETECTED'; + + @override + String get logRateLimited => 'RATE LIMITED'; + + @override + String get logNetworkError => 'NETWORK ERROR'; + + @override + String get logTrackNotFound => 'TRACK NOT FOUND'; + + @override + String get logFilterBySeverity => 'Filter logs by severity'; + + @override + String get logNoLogsYet => 'No logs yet'; + + @override + String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; + + @override + String get logIssueSummary => 'Issue Summary'; + + @override + String get logIspBlockingDescription => + 'Your ISP may be blocking access to download services'; + + @override + String get logIspBlockingSuggestion => + 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; + + @override + String get logRateLimitedDescription => 'Too many requests to the service'; + + @override + String get logRateLimitedSuggestion => + 'Wait a few minutes before trying again'; + + @override + String get logNetworkErrorDescription => 'Connection issues detected'; + + @override + String get logNetworkErrorSuggestion => 'Check your internet connection'; + + @override + String get logTrackNotFoundDescription => + 'Some tracks could not be found on download services'; + + @override + String get logTrackNotFoundSuggestion => + 'The track may not be available in lossless quality'; + + @override + String logTotalErrors(int count) { + return 'Total errors: $count'; + } + + @override + String logAffected(String domains) { + return 'Affected: $domains'; + } + + @override + String logEntriesFiltered(int count) { + return 'Entries ($count filtered)'; + } + + @override + String logEntries(int count) { + return 'Entries ($count)'; + } + + @override + String get credentialsTitle => 'Spotify Credentials'; + + @override + String get credentialsDescription => + 'Enter your Client ID and Secret to use your own Spotify application quota.'; + + @override + String get credentialsClientId => 'Client ID'; + + @override + String get credentialsClientIdHint => 'Paste Client ID'; + + @override + String get credentialsClientSecret => 'Client Secret'; + + @override + String get credentialsClientSecretHint => 'Paste Client Secret'; + + @override + String get channelStable => 'Stable'; + + @override + String get channelPreview => 'Preview'; + + @override + String get sectionSearchSource => 'Search Source'; + + @override + String get sectionDownload => 'Download'; + + @override + String get sectionPerformance => 'Performance'; + + @override + String get sectionApp => 'App'; + + @override + String get sectionData => 'Data'; + + @override + String get sectionDebug => 'Debug'; + + @override + String get sectionService => 'Service'; + + @override + String get sectionAudioQuality => 'Audio Quality'; + + @override + String get sectionFileSettings => 'File Settings'; + + @override + String get sectionLyrics => 'Lyrics'; + + @override + String get lyricsMode => 'Lyrics Mode'; + + @override + String get lyricsModeDescription => + 'Choose how lyrics are saved with your downloads'; + + @override + String get lyricsModeEmbed => 'Embed in file'; + + @override + String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; + + @override + String get lyricsModeExternal => 'External .lrc file'; + + @override + String get lyricsModeExternalSubtitle => + 'Separate .lrc file for players like Samsung Music'; + + @override + String get lyricsModeBoth => 'Both'; + + @override + String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; + + @override + String get sectionColor => 'Color'; + + @override + String get sectionTheme => 'Theme'; + + @override + String get sectionLayout => 'Layout'; + + @override + String get sectionLanguage => 'Language'; + + @override + String get appearanceLanguage => 'App Language'; + + @override + String get appearanceLanguageSubtitle => 'Choose your preferred language'; + + @override + String get settingsAppearanceSubtitle => 'Theme, colors, display'; + + @override + String get settingsDownloadSubtitle => 'Service, quality, filename format'; + + @override + String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates'; + + @override + String get settingsExtensionsSubtitle => 'Manage download providers'; + + @override + String get settingsLogsSubtitle => 'View app logs for debugging'; + + @override + String get loadingSharedLink => 'Loading shared link...'; + + @override + String get pressBackAgainToExit => 'Press back again to exit'; + + @override + String get tracksHeader => 'Tracks'; + + @override + String downloadAllCount(int count) { + return 'Download All ($count)'; + } + + @override + String tracksCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String get trackCopyFilePath => 'Copy file path'; + + @override + String get trackRemoveFromDevice => 'Remove from device'; + + @override + String get trackLoadLyrics => 'Load Lyrics'; + + @override + String get trackMetadata => 'Metadata'; + + @override + String get trackFileInfo => 'File Info'; + + @override + String get trackLyrics => 'Lyrics'; + + @override + String get trackFileNotFound => 'File not found'; + + @override + String get trackOpenInDeezer => 'Open in Deezer'; + + @override + String get trackOpenInSpotify => 'Open in Spotify'; + + @override + String get trackTrackName => 'Track name'; + + @override + String get trackArtist => 'Artist'; + + @override + String get trackAlbumArtist => 'Album artist'; + + @override + String get trackAlbum => 'Album'; + + @override + String get trackTrackNumber => 'Track number'; + + @override + String get trackDiscNumber => 'Disc number'; + + @override + String get trackDuration => 'Duration'; + + @override + String get trackAudioQuality => 'Audio quality'; + + @override + String get trackReleaseDate => 'Release date'; + + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + + @override + String get trackDownloaded => 'Downloaded'; + + @override + String get trackCopyLyrics => 'Copy lyrics'; + + @override + String get trackLyricsNotAvailable => 'Lyrics not available for this track'; + + @override + String get trackLyricsTimeout => 'Request timed out. Try again later.'; + + @override + String get trackLyricsLoadFailed => 'Failed to load lyrics'; + + @override + String get trackCopiedToClipboard => 'Copied to clipboard'; + + @override + String get trackDeleteConfirmTitle => 'Remove from device?'; + + @override + String get trackDeleteConfirmMessage => + 'This will permanently delete the downloaded file and remove it from your history.'; + + @override + String trackCannotOpen(String message) { + return 'Cannot open: $message'; + } + + @override + String get dateToday => 'Today'; + + @override + String get dateYesterday => 'Yesterday'; + + @override + String dateDaysAgo(int count) { + return '$count days ago'; + } + + @override + String dateWeeksAgo(int count) { + return '$count weeks ago'; + } + + @override + String dateMonthsAgo(int count) { + return '$count months ago'; + } + + @override + String get concurrentSequential => 'Sequential'; + + @override + String get concurrentParallel2 => '2 Parallel'; + + @override + String get concurrentParallel3 => '3 Parallel'; + + @override + String get tapToSeeError => 'Tap to see error details'; + + @override + String get storeFilterAll => 'All'; + + @override + String get storeFilterMetadata => 'Metadata'; + + @override + String get storeFilterDownload => 'Download'; + + @override + String get storeFilterUtility => 'Utility'; + + @override + String get storeFilterLyrics => 'Lyrics'; + + @override + String get storeFilterIntegration => 'Integration'; + + @override + String get storeClearFilters => 'Clear filters'; + + @override + String get storeNoResults => 'No extensions found'; + + @override + String get extensionProviderPriority => 'Provider Priority'; + + @override + String get extensionInstallButton => 'Install Extension'; + + @override + String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; + + @override + String get extensionDefaultProviderSubtitle => 'Use built-in search'; + + @override + String get extensionAuthor => 'Author'; + + @override + String get extensionId => 'ID'; + + @override + String get extensionError => 'Error'; + + @override + String get extensionCapabilities => 'Capabilities'; + + @override + String get extensionMetadataProvider => 'Metadata Provider'; + + @override + String get extensionDownloadProvider => 'Download Provider'; + + @override + String get extensionLyricsProvider => 'Lyrics Provider'; + + @override + String get extensionUrlHandler => 'URL Handler'; + + @override + String get extensionQualityOptions => 'Quality Options'; + + @override + String get extensionPostProcessingHooks => 'Post-Processing Hooks'; + + @override + String get extensionPermissions => 'Permissions'; + + @override + String get extensionSettings => 'Settings'; + + @override + String get extensionRemoveButton => 'Remove Extension'; + + @override + String get extensionUpdated => 'Updated'; + + @override + String get extensionMinAppVersion => 'Min App Version'; + + @override + String get extensionCustomTrackMatching => 'Custom Track Matching'; + + @override + String get extensionPostProcessing => 'Post-Processing'; + + @override + String extensionHooksAvailable(int count) { + return '$count hook(s) available'; + } + + @override + String extensionPatternsCount(int count) { + return '$count pattern(s)'; + } + + @override + String extensionStrategy(String strategy) { + return 'Strategy: $strategy'; + } + + @override + String get extensionsProviderPrioritySection => 'Provider Priority'; + + @override + String get extensionsInstalledSection => 'Installed Extensions'; + + @override + String get extensionsNoExtensions => 'No extensions installed'; + + @override + String get extensionsNoExtensionsSubtitle => + 'Install .spotiflac-ext files to add new providers'; + + @override + String get extensionsInstallButton => 'Install Extension'; + + @override + String get extensionsInfoTip => + 'Extensions can add new metadata and download providers. Only install extensions from trusted sources.'; + + @override + String get extensionsInstalledSuccess => 'Extension installed successfully'; + + @override + String get extensionsDownloadPriority => 'Download Priority'; + + @override + String get extensionsDownloadPrioritySubtitle => 'Set download service order'; + + @override + String get extensionsNoDownloadProvider => + 'No extensions with download provider'; + + @override + String get extensionsMetadataPriority => 'Metadata Priority'; + + @override + String get extensionsMetadataPrioritySubtitle => + 'Set search & metadata source order'; + + @override + String get extensionsNoMetadataProvider => + 'No extensions with metadata provider'; + + @override + String get extensionsSearchProvider => 'Search Provider'; + + @override + String get extensionsNoCustomSearch => 'No extensions with custom search'; + + @override + String get extensionsSearchProviderDescription => + 'Choose which service to use for searching tracks'; + + @override + String get extensionsCustomSearch => 'Custom search'; + + @override + String get extensionsErrorLoading => 'Error loading extension'; + + @override + String get qualityFlacLossless => 'FLAC Lossless'; + + @override + String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz'; + + @override + String get qualityHiResFlac => 'Hi-Res FLAC'; + + @override + String get qualityHiResFlacSubtitle => '24-bit / up to 96kHz'; + + @override + String get qualityHiResFlacMax => 'Hi-Res FLAC Max'; + + @override + String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + + @override + String get qualityMp3 => 'MP3'; + + @override + String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; + + @override + String get enableMp3Option => 'Enable MP3 Option'; + + @override + String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; + + @override + String get enableMp3OptionSubtitleOff => + 'Downloads FLAC then converts to 320kbps MP3'; + + @override + String get qualityNote => + 'Actual quality depends on track availability from the service'; + + @override + String get downloadAskBeforeDownload => 'Ask Before Download'; + + @override + String get downloadDirectory => 'Download Directory'; + + @override + String get downloadSeparateSinglesFolder => 'Separate Singles Folder'; + + @override + String get downloadAlbumFolderStructure => 'Album Folder Structure'; + + @override + String get downloadSaveFormat => 'Save Format'; + + @override + String get downloadSelectService => 'Select Service'; + + @override + String get downloadSelectQuality => 'Select Quality'; + + @override + String get downloadFrom => 'Download From'; + + @override + String get downloadDefaultQualityLabel => 'Default Quality'; + + @override + String get downloadBestAvailable => 'Best available'; + + @override + String get folderNone => 'None'; + + @override + String get folderNoneSubtitle => 'Save all files directly to download folder'; + + @override + String get folderArtist => 'Artist'; + + @override + String get folderArtistSubtitle => 'Artist Name/filename'; + + @override + String get folderAlbum => 'Album'; + + @override + String get folderAlbumSubtitle => 'Album Name/filename'; + + @override + String get folderArtistAlbum => 'Artist/Album'; + + @override + String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; + + @override + String get serviceTidal => 'Tidal'; + + @override + String get serviceQobuz => 'Qobuz'; + + @override + String get serviceAmazon => 'Amazon'; + + @override + String get serviceDeezer => 'Deezer'; + + @override + String get serviceSpotify => 'Spotify'; + + @override + String get appearanceAmoledDark => 'AMOLED Dark'; + + @override + String get appearanceAmoledDarkSubtitle => 'Pure black background'; + + @override + String get appearanceChooseAccentColor => 'Choose Accent Color'; + + @override + String get appearanceChooseTheme => 'Theme Mode'; + + @override + String get queueTitle => 'Download Queue'; + + @override + String get queueClearAll => 'Clear All'; + + @override + String get queueClearAllMessage => + 'Are you sure you want to clear all downloads?'; + + @override + String get queueEmpty => 'No downloads in queue'; + + @override + String get queueEmptySubtitle => 'Add tracks from the home screen'; + + @override + String get queueClearCompleted => 'Clear completed'; + + @override + String get queueDownloadFailed => 'Download Failed'; + + @override + String get queueTrackLabel => 'Track:'; + + @override + String get queueArtistLabel => 'Artist:'; + + @override + String get queueErrorLabel => 'Error:'; + + @override + String get queueUnknownError => 'Unknown error'; + + @override + String get albumFolderArtistAlbum => 'Artist / Album'; + + @override + String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/'; + + @override + String get albumFolderArtistYearAlbum => 'Artist / [Year] Album'; + + @override + String get albumFolderArtistYearAlbumSubtitle => + 'Albums/Artist Name/[2005] Album Name/'; + + @override + String get albumFolderAlbumOnly => 'Album Only'; + + @override + String get albumFolderAlbumOnlySubtitle => 'Albums/Album Name/'; + + @override + String get albumFolderYearAlbum => '[Year] Album'; + + @override + String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; + + @override + String get downloadedAlbumDeleteSelected => 'Delete Selected'; + + @override + String downloadedAlbumDeleteMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; + } + + @override + String get downloadedAlbumTracksHeader => 'Tracks'; + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } + + @override + String downloadedAlbumSelectedCount(int count) { + return '$count selected'; + } + + @override + String get downloadedAlbumAllSelected => 'All tracks selected'; + + @override + String get downloadedAlbumTapToSelect => 'Tap tracks to select'; + + @override + String downloadedAlbumDeleteCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0'; + } + + @override + String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; + + @override + String downloadedAlbumDiscHeader(int discNumber) { + return 'Disc $discNumber'; + } + + @override + String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } +} diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb new file mode 100644 index 00000000..79d419e9 --- /dev/null +++ b/lib/l10n/arb/app_tr.arb @@ -0,0 +1,7 @@ +{ + "@@locale": "tr", + "@@last_modified": "2026-01-21", + + "appName": "SpotiFLAC", + "@appName": {"description": "App name - DO NOT TRANSLATE"} +} diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart new file mode 100644 index 00000000..14a238cb --- /dev/null +++ b/lib/providers/explore_provider.dart @@ -0,0 +1,220 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; + +final _log = AppLogger('ExploreProvider'); + +/// Represents an item in a Spotify home section +class ExploreItem { + final String id; + final String uri; + final String type; // track, album, playlist, artist, station + final String name; + final String artists; + final String? description; + final String? coverUrl; + final String? providerId; + final String? albumId; + final String? albumName; + + const ExploreItem({ + required this.id, + required this.uri, + required this.type, + required this.name, + required this.artists, + this.description, + this.coverUrl, + this.providerId, + this.albumId, + this.albumName, + }); + + factory ExploreItem.fromJson(Map json) { + return ExploreItem( + id: json['id'] as String? ?? '', + uri: json['uri'] as String? ?? '', + type: json['type'] as String? ?? 'track', + name: json['name'] as String? ?? '', + artists: json['artists'] as String? ?? '', + description: json['description'] as String?, + coverUrl: json['cover_url'] as String?, + providerId: json['provider_id'] as String?, + albumId: json['album_id'] as String?, + albumName: json['album_name'] as String?, + ); + } +} + +/// Represents a section in Spotify home feed +class ExploreSection { + final String uri; + final String title; + final List items; + + const ExploreSection({ + required this.uri, + required this.title, + required this.items, + }); + + factory ExploreSection.fromJson(Map json) { + final itemsList = json['items'] as List? ?? []; + return ExploreSection( + uri: json['uri'] as String? ?? '', + title: json['title'] as String? ?? '', + items: itemsList + .map((item) => ExploreItem.fromJson(item as Map)) + .toList(), + ); + } +} + +/// State for explore/home feed +class ExploreState { + final bool isLoading; + final String? error; + final String? greeting; + final List sections; + final DateTime? lastFetched; + + const ExploreState({ + this.isLoading = false, + this.error, + this.greeting, + this.sections = const [], + this.lastFetched, + }); + + bool get hasContent => sections.isNotEmpty; + + ExploreState copyWith({ + bool? isLoading, + String? error, + String? greeting, + List? sections, + DateTime? lastFetched, + }) { + return ExploreState( + isLoading: isLoading ?? this.isLoading, + error: error, + greeting: greeting ?? this.greeting, + sections: sections ?? this.sections, + lastFetched: lastFetched ?? this.lastFetched, + ); + } +} + +/// Provider for explore/home feed state +class ExploreNotifier extends Notifier { + @override + ExploreState build() { + return const ExploreState(); + } + + /// Fetch home feed from spotify-web extension + Future fetchHomeFeed({bool forceRefresh = false}) async { + _log.i('fetchHomeFeed called, forceRefresh=$forceRefresh'); + + // Don't refetch if we have data and it's less than 5 minutes old + if (!forceRefresh && + state.hasContent && + state.lastFetched != null && + DateTime.now().difference(state.lastFetched!).inMinutes < 5) { + _log.d('Using cached home feed'); + return; + } + + state = state.copyWith(isLoading: true, error: null); + + try { + // Find any extension with homeFeed capability + final extState = ref.read(extensionProvider); + _log.d('Extensions count: ${extState.extensions.length}'); + + // Look for extensions with homeFeed capability (prefer spotify-web, then ytmusic) + final homeFeedExtensions = extState.extensions.where( + (e) => e.enabled && e.hasHomeFeed, + ).toList(); + + if (homeFeedExtensions.isEmpty) { + _log.w('No extension with homeFeed capability found'); + state = state.copyWith( + isLoading: false, + error: 'No extension with home feed support enabled', + ); + return; + } + + // Prefer spotify-web if available, otherwise use first available + var targetExt = homeFeedExtensions.firstWhere( + (e) => e.id == 'spotify-web', + orElse: () => homeFeedExtensions.first, + ); + + _log.i('Fetching home feed from ${targetExt.id}...'); + final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id); + + _log.d('getExtensionHomeFeed result: $result'); + + if (result == null) { + state = state.copyWith( + isLoading: false, + error: 'Failed to fetch home feed', + ); + return; + } + + final success = result['success'] as bool? ?? false; + if (!success) { + final error = result['error'] as String? ?? 'Unknown error'; + state = state.copyWith( + isLoading: false, + error: error, + ); + return; + } + + final greeting = result['greeting'] as String?; + final sectionsData = result['sections'] as List? ?? []; + + final sections = sectionsData + .map((s) => ExploreSection.fromJson(s as Map)) + .toList(); + + _log.i('Fetched ${sections.length} sections'); + + // Debug: log first section items + if (sections.isNotEmpty && sections.first.items.isNotEmpty) { + final firstItem = sections.first.items.first; + _log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}'); + } + + state = ExploreState( + isLoading: false, + greeting: greeting, + sections: sections, + lastFetched: DateTime.now(), + ); + } catch (e, stack) { + _log.e('Error fetching home feed: $e', e, stack); + state = state.copyWith( + isLoading: false, + error: e.toString(), + ); + } + } + + /// Clear cached data + void clear() { + state = const ExploreState(); + } + + /// Refresh home feed + Future refresh() => fetchHomeFeed(forceRefresh: true); +} + +final exploreProvider = NotifierProvider(() { + return ExploreNotifier(); +}); diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 6f74d25b..a4c3d25e 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -26,6 +26,7 @@ class Extension { final URLHandler? urlHandler; final TrackMatching? trackMatching; final PostProcessing? postProcessing; + final Map capabilities; // Extension capabilities (homeFeed, browseCategories, etc.) const Extension({ required this.id, @@ -48,6 +49,7 @@ class Extension { this.urlHandler, this.trackMatching, this.postProcessing, + this.capabilities = const {}, }); factory Extension.fromJson(Map json) { @@ -84,6 +86,7 @@ class Extension { postProcessing: json['post_processing'] != null ? PostProcessing.fromJson(json['post_processing'] as Map) : null, + capabilities: (json['capabilities'] as Map?) ?? const {}, ); } @@ -108,6 +111,7 @@ class Extension { URLHandler? urlHandler, TrackMatching? trackMatching, PostProcessing? postProcessing, + Map? capabilities, }) { return Extension( id: id ?? this.id, @@ -130,6 +134,7 @@ class Extension { urlHandler: urlHandler ?? this.urlHandler, trackMatching: trackMatching ?? this.trackMatching, postProcessing: postProcessing ?? this.postProcessing, + capabilities: capabilities ?? this.capabilities, ); } @@ -137,6 +142,8 @@ class Extension { bool get hasURLHandler => urlHandler?.enabled ?? false; bool get hasCustomMatching => trackMatching?.customMatching ?? false; bool get hasPostProcessing => postProcessing?.enabled ?? false; + bool get hasHomeFeed => capabilities['homeFeed'] == true; + bool get hasBrowseCategories => capabilities['browseCategories'] == true; } class SearchBehavior { diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index af5413a3..d1006315 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -12,6 +12,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/recent_access_provider.dart'; +import 'package:spotiflac_android/providers/explore_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; @@ -59,6 +60,19 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient _searchFocusNode.addListener(_onSearchFocusChanged); } + void _fetchExploreIfNeeded() { + final extState = ref.read(extensionProvider); + final exploreState = ref.read(exploreProvider); + // Check if any extension with homeFeed capability is enabled + final hasHomeFeedExtension = extState.extensions.any( + (e) => e.enabled && e.hasHomeFeed, + ); + // Fetch if any homeFeed extension is enabled and we don't have content yet + if (hasHomeFeedExtension && !exploreState.hasContent && !exploreState.isLoading) { + ref.read(exploreProvider.notifier).fetchHomeFeed(); + } + } + @override void dispose() { _liveSearchDebounce?.cancel(); @@ -420,6 +434,15 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } }); + // Listen for extension state changes to trigger explore fetch + ref.listen(extensionProvider.select((s) => s.isInitialized), (previous, next) { + if (next == true && previous != true) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _fetchExploreIfNeeded(); + }); + } + }); + final tracks = ref.watch(trackProvider.select((s) => s.tracks)); final searchArtists = ref.watch(trackProvider.select((s) => s.searchArtists)); final isLoading = ref.watch(trackProvider.select((s) => s.isLoading)); @@ -429,6 +452,12 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ref.watch(extensionProvider.select((s) => s.isInitialized)); ref.watch(extensionProvider.select((s) => s.extensions)); + // Explore state + final exploreState = ref.watch(exploreProvider); + final hasHomeFeedExtension = ref.watch(extensionProvider.select((s) => + s.extensions.any((e) => e.enabled && e.hasHomeFeed) + )); + final colorScheme = Theme.of(context).colorScheme; final hasActualResults = tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty); final isShowingRecentAccess = ref.watch(trackProvider.select((s) => s.isShowingRecentAccess)); @@ -441,6 +470,9 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final hasRecentItems = recentAccessItems.isNotEmpty || historyItems.isNotEmpty; final showRecentAccess = isShowingRecentAccess && hasRecentItems && !hasActualResults && !isLoading; + // Show explore only when no search results and not showing recent access + final showExplore = !hasActualResults && !isLoading && !showRecentAccess && hasHomeFeedExtension && exploreState.hasContent; + if (hasActualResults && isShowingRecentAccess) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) ref.read(trackProvider.notifier).setShowingRecentAccess(false); @@ -455,9 +487,12 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient }, behavior: HitTestBehavior.translucent, child: Scaffold( - body: CustomScrollView( - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - slivers: [ + body: RefreshIndicator( + onRefresh: () => ref.read(exploreProvider.notifier).refresh(), + notificationPredicate: (notification) => showExplore, + child: CustomScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + slivers: [ SliverAppBar( expandedHeight: 120 + topPadding, collapsedHeight: kToolbarHeight, @@ -492,7 +527,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient child: AnimatedSize( duration: const Duration(milliseconds: 250), curve: Curves.easeOut, - child: hasResults + child: (hasResults || showExplore) ? const SizedBox.shrink() : Column( children: [ @@ -541,7 +576,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.fromLTRB(16, hasResults ? 8 : 32, 16, hasResults ? 8 : 16), + padding: EdgeInsets.fromLTRB(16, (hasResults || showExplore) ? 8 : 32, 16, (hasResults || showExplore) ? 8 : 16), child: _buildSearchBar(colorScheme), ), ), @@ -559,7 +594,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient child: AnimatedSize( duration: const Duration(milliseconds: 250), curve: Curves.easeOut, - child: (hasResults || showRecentAccess) + child: (hasResults || showRecentAccess || showExplore) ? const SizedBox.shrink() : Column( children: [ @@ -584,6 +619,19 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ), + // Explore sections (Spotify Home Feed) + if (showExplore) + ..._buildExploreSections(exploreState, colorScheme), + + // Loading indicator for explore + if (hasHomeFeedExtension && !hasActualResults && !isLoading && exploreState.isLoading) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ), + ), + ..._buildSearchResults( tracks: tracks, searchArtists: searchArtists, @@ -594,6 +642,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ], ), + ), // Close RefreshIndicator ), // Close GestureDetector ); } @@ -670,6 +719,382 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } + List _buildExploreSections(ExploreState exploreState, ColorScheme colorScheme) { + final slivers = []; + + // Greeting (pull-to-refresh handles refresh) + if (exploreState.greeting != null && exploreState.greeting!.isNotEmpty) { + slivers.add( + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + exploreState.greeting!, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ); + } + + // Build each section + for (final section in exploreState.sections) { + slivers.add( + SliverToBoxAdapter( + child: _buildExploreSection(section, colorScheme), + ), + ); + } + + // Add some bottom padding + slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 16))); + + return slivers; + } + + Widget _buildExploreSection(ExploreSection section, ColorScheme colorScheme) { + // Check if this is a YT Music "Quick picks" style section (vertical list) + final isYTMusicQuickPicks = section.items.isNotEmpty && + section.items.first.providerId == 'ytmusic-spotiflac' && + section.items.every((item) => item.type == 'track'); + + if (isYTMusicQuickPicks) { + return _buildYTMusicQuickPicksSection(section, colorScheme); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: Text( + section.title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + SizedBox( + height: 175, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: section.items.length, + itemBuilder: (context, index) { + final item = section.items[index]; + return _buildExploreItem(item, colorScheme); + }, + ), + ), + ], + ); + } + + /// Build YT Music "Quick picks" style swipeable pages section + Widget _buildYTMusicQuickPicksSection(ExploreSection section, ColorScheme colorScheme) { + const itemsPerPage = 5; + final totalPages = (section.items.length / itemsPerPage).ceil(); + + return _QuickPicksPageView( + section: section, + colorScheme: colorScheme, + itemsPerPage: itemsPerPage, + totalPages: totalPages, + onItemTap: _navigateToExploreItem, + onItemMenu: _showTrackBottomSheet, + ); + } + + Widget _buildExploreItem(ExploreItem item, ColorScheme colorScheme) { + final isArtist = item.type == 'artist'; + + return GestureDetector( + onTap: () => _navigateToExploreItem(item), + child: SizedBox( + width: 120, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Column( + crossAxisAlignment: isArtist ? CrossAxisAlignment.center : CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(isArtist ? 60 : 8), + child: item.coverUrl != null && item.coverUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: item.coverUrl!, + width: 120, + height: 120, + fit: BoxFit.cover, + memCacheWidth: 240, + memCacheHeight: 240, + cacheManager: CoverCacheManager.instance, + errorWidget: (context, url, error) => Container( + width: 120, + height: 120, + color: colorScheme.surfaceContainerHighest, + child: Icon( + _getIconForType(item.type), + color: colorScheme.onSurfaceVariant, + size: 36, + ), + ), + ) + : Container( + width: 120, + height: 120, + color: colorScheme.surfaceContainerHighest, + child: Icon( + _getIconForType(item.type), + color: colorScheme.onSurfaceVariant, + size: 36, + ), + ), + ), + const SizedBox(height: 8), + Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: isArtist ? TextAlign.center : TextAlign.start, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + color: colorScheme.onSurface, + ), + ), + if (item.artists.isNotEmpty && !isArtist) + Text( + item.artists, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: 11, + ), + ), + ], + ), + ), + ), + ); + } + + IconData _getIconForType(String type) { + switch (type) { + case 'track': + return Icons.music_note; + case 'album': + return Icons.album; + case 'playlist': + return Icons.playlist_play; + case 'artist': + return Icons.person; + case 'station': + return Icons.radio; + default: + return Icons.music_note; + } + } + + void _navigateToExploreItem(ExploreItem item) async { + final extensionId = item.providerId ?? 'spotify-web'; + + switch (item.type) { + case 'track': + // Show bottom sheet with track info and download option + _showTrackBottomSheet(item); + case 'album': + Navigator.push(context, MaterialPageRoute( + builder: (context) => ExtensionAlbumScreen( + extensionId: extensionId, + albumId: item.id, + albumName: item.name, + coverUrl: item.coverUrl, + ), + )); + case 'playlist': + Navigator.push(context, MaterialPageRoute( + builder: (context) => ExtensionPlaylistScreen( + extensionId: extensionId, + playlistId: item.id, + playlistName: item.name, + coverUrl: item.coverUrl, + ), + )); + case 'artist': + Navigator.push(context, MaterialPageRoute( + builder: (context) => ExtensionArtistScreen( + extensionId: extensionId, + artistId: item.id, + artistName: item.name, + coverUrl: item.coverUrl, + ), + )); + default: + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${item.type}: ${item.name}')), + ); + } + } + + void _showTrackBottomSheet(ExploreItem item) { + final colorScheme = Theme.of(context).colorScheme; + + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + margin: const EdgeInsets.only(top: 12), + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + // Track info + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: item.coverUrl != null && item.coverUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: item.coverUrl!, + width: 64, + height: 64, + fit: BoxFit.cover, + memCacheWidth: 128, + cacheManager: CoverCacheManager.instance, + ) + : Container( + width: 64, + height: 64, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + item.artists, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + const Divider(height: 1), + // Actions + ListTile( + leading: Icon(Icons.download, color: colorScheme.primary), + title: Text(context.l10n.downloadTitle), + onTap: () { + Navigator.pop(context); + _downloadExploreTrack(item); + }, + ), + ListTile( + leading: Icon(Icons.album, color: colorScheme.onSurfaceVariant), + title: const Text('Go to Album'), + onTap: () { + Navigator.pop(context); + // Navigate to album - we'll use the track ID to search + _navigateToTrackAlbum(item); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + Future _downloadExploreTrack(ExploreItem item) async { + final settings = ref.read(settingsProvider); + + // Create a Track object from ExploreItem + // Pass spotify ID as ISRC so enrichment can look it up via SongLink/Deezer + final track = Track( + id: item.id, + name: item.name, + artistName: item.artists, + albumName: item.albumName ?? '', + duration: 0, + trackNumber: 1, + discNumber: 1, + isrc: item.id, // Pass Spotify ID - enrichment will detect and lookup real ISRC + releaseDate: null, + coverUrl: item.coverUrl, + source: item.providerId ?? 'spotify-web', + ); + + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + context, + trackName: track.name, + artistName: track.artistName, + coverUrl: track.coverUrl, + onSelect: (quality, service) { + ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), + ); + }, + ); + } else { + ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), + ); + } + } + + Future _navigateToTrackAlbum(ExploreItem item) async { + if (item.albumId != null && item.albumId!.isNotEmpty) { + Navigator.push(context, MaterialPageRoute( + builder: (context) => ExtensionAlbumScreen( + extensionId: item.providerId ?? 'spotify-web', + albumId: item.albumId!, + albumName: item.albumName ?? 'Album', + coverUrl: item.coverUrl, + ), + )); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Album info not available')), + ); + } + } + Widget _buildRecentAccess( List items, List historyItems, @@ -2422,3 +2847,188 @@ class _ExtensionArtistScreenState extends ConsumerState { ); } } + +/// Swipeable Quick Picks widget with page indicator +class _QuickPicksPageView extends StatefulWidget { + final ExploreSection section; + final ColorScheme colorScheme; + final int itemsPerPage; + final int totalPages; + final void Function(ExploreItem) onItemTap; + final void Function(ExploreItem) onItemMenu; + + const _QuickPicksPageView({ + required this.section, + required this.colorScheme, + required this.itemsPerPage, + required this.totalPages, + required this.onItemTap, + required this.onItemMenu, + }); + + @override + State<_QuickPicksPageView> createState() => _QuickPicksPageViewState(); +} + +class _QuickPicksPageViewState extends State<_QuickPicksPageView> { + int _currentPage = 0; + late PageController _pageController; + + @override + void initState() { + super.initState(); + _pageController = PageController(); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + widget.section.title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + // Swipeable pages of tracks + SizedBox( + height: widget.itemsPerPage * 64.0, + child: PageView.builder( + controller: _pageController, + itemCount: widget.totalPages, + onPageChanged: (page) { + setState(() => _currentPage = page); + }, + itemBuilder: (context, pageIndex) { + final startIndex = pageIndex * widget.itemsPerPage; + final endIndex = (startIndex + widget.itemsPerPage).clamp(0, widget.section.items.length); + final pageItems = widget.section.items.sublist(startIndex, endIndex); + + return Column( + children: pageItems.map((item) => _buildQuickPickItem(item)).toList(), + ); + }, + ), + ), + // Page indicator dots + if (widget.totalPages > 1) + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(widget.totalPages, (index) { + final isActive = index == _currentPage; + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: isActive ? 8 : 6, + height: isActive ? 8 : 6, + margin: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive + ? widget.colorScheme.primary + : widget.colorScheme.onSurfaceVariant.withOpacity(0.3), + ), + ); + }), + ), + ), + ], + ); + } + + Widget _buildQuickPickItem(ExploreItem item) { + return InkWell( + onTap: () => widget.onItemTap(item), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + // Album art thumbnail + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: item.coverUrl != null && item.coverUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: item.coverUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + memCacheWidth: 96, + memCacheHeight: 96, + cacheManager: CoverCacheManager.instance, + errorWidget: (context, url, error) => Container( + width: 48, + height: 48, + color: widget.colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: widget.colorScheme.onSurfaceVariant, + size: 24, + ), + ), + ) + : Container( + width: 48, + height: 48, + color: widget.colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: widget.colorScheme.onSurfaceVariant, + size: 24, + ), + ), + ), + const SizedBox(width: 12), + // Title and artist + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: widget.colorScheme.onSurface, + ), + ), + if (item.artists.isNotEmpty) + Text( + item.artists, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: widget.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + // Menu button + IconButton( + icon: Icon( + Icons.more_vert, + color: widget.colorScheme.onSurfaceVariant, + size: 20, + ), + onPressed: () => widget.onItemMenu(item), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 92ca5ff0..ad8c8029 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -709,6 +709,7 @@ static const _allLanguages = [ ('pt', 'Português', Icons.language), ('pt_PT', 'Português (Brasil)', Icons.language), ('ru', 'Русский', Icons.language), + ('tr', 'Türkçe', Icons.language), ('zh', '简体中文', Icons.language), ('zh_CN', '简体中文 (中国)', Icons.language), ('zh_TW', '繁體中文', Icons.language), diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index f0895bcd..a310b130 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -794,6 +794,34 @@ class PlatformBridge { } } + /// Get extension home feed + static Future?> getExtensionHomeFeed(String extensionId) async { + try { + final result = await _channel.invokeMethod('getExtensionHomeFeed', { + 'extension_id': extensionId, + }); + if (result == null || result == '') return null; + return jsonDecode(result as String) as Map; + } catch (e) { + _log.e('getExtensionHomeFeed failed: $e'); + return null; + } + } + + /// Get extension browse categories + static Future?> getExtensionBrowseCategories(String extensionId) async { + try { + final result = await _channel.invokeMethod('getExtensionBrowseCategories', { + 'extension_id': extensionId, + }); + if (result == null || result == '') return null; + return jsonDecode(result as String) as Map; + } catch (e) { + _log.e('getExtensionBrowseCategories failed: $e'); + return null; + } + } + static Future> runPostProcessing( String filePath, {