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


-
-
-
-
-
-
-
-
-
-
### [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
+
+
+
+
+
+
+
+
+
+
+
## 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