feat: add Home Feed with pull-to-refresh and gobackend.getLocalTime() API

- Add Home Feed/Explore feature with extension capabilities system
- Add pull-to-refresh on home feed (replaces refresh button)
- Add gobackend.getLocalTime() API for accurate device timezone detection
- Add YT Music Quick Picks UI with swipeable vertical format
- Fix greeting time showing wrong time due to Goja getTimezoneOffset() returning 0
- Update spotify-web and ytmusic extensions to use getLocalTime()
- Add Turkish language support
- Update CHANGELOG for v3.2.0
This commit is contained in:
zarzet
2026-01-21 08:30:44 +07:00
parent e725a7be77
commit 79180dd918
16 changed files with 3231 additions and 55 deletions
+72
View File
@@ -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
+15 -10
View File
@@ -11,16 +11,6 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc
![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)
![iOS](https://img.shields.io/badge/iOS-14.0%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
<p align="center">
<a href="https://t.me/spotiflac">
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
</a>
<a href="https://t.me/spotiflacchat">
<img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
</a>
</p>
</div>
### [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
<p align="center">
<a href="https://t.me/spotiflac">
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
</a>
<a href="https://t.me/spotiflacchat">
<img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
</a>
</p>
## 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.
@@ -678,6 +678,21 @@ class MainActivity: FlutterActivity() {
}
result.success(null)
}
// Extension Home Feed (Explore)
"getExtensionHomeFeed" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getExtensionHomeFeedJSON(extensionId)
}
result.success(response)
}
"getExtensionBrowseCategories" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getExtensionBrowseCategoriesJSON(extensionId)
}
result.success(response)
}
else -> result.notImplemented()
}
} catch (e: Exception) {
+82
View File
@@ -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
}
+23 -21
View File
@@ -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,
}
}
+19 -18
View File
@@ -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
+21
View File
@@ -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(),
})
})
}
+15
View File
@@ -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",
+5
View File
@@ -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();
}
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
{
"@@locale": "tr",
"@@last_modified": "2026-01-21",
"appName": "SpotiFLAC",
"@appName": {"description": "App name - DO NOT TRANSLATE"}
}
+220
View File
@@ -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<String, dynamic> 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<ExploreItem> items;
const ExploreSection({
required this.uri,
required this.title,
required this.items,
});
factory ExploreSection.fromJson(Map<String, dynamic> json) {
final itemsList = json['items'] as List<dynamic>? ?? [];
return ExploreSection(
uri: json['uri'] as String? ?? '',
title: json['title'] as String? ?? '',
items: itemsList
.map((item) => ExploreItem.fromJson(item as Map<String, dynamic>))
.toList(),
);
}
}
/// State for explore/home feed
class ExploreState {
final bool isLoading;
final String? error;
final String? greeting;
final List<ExploreSection> 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<ExploreSection>? 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<ExploreState> {
@override
ExploreState build() {
return const ExploreState();
}
/// Fetch home feed from spotify-web extension
Future<void> 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<dynamic>? ?? [];
final sections = sectionsData
.map((s) => ExploreSection.fromJson(s as Map<String, dynamic>))
.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<void> refresh() => fetchHomeFeed(forceRefresh: true);
}
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
return ExploreNotifier();
});
+7
View File
@@ -26,6 +26,7 @@ class Extension {
final URLHandler? urlHandler;
final TrackMatching? trackMatching;
final PostProcessing? postProcessing;
final Map<String, dynamic> 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<String, dynamic> json) {
@@ -84,6 +86,7 @@ class Extension {
postProcessing: json['post_processing'] != null
? PostProcessing.fromJson(json['post_processing'] as Map<String, dynamic>)
: null,
capabilities: (json['capabilities'] as Map<String, dynamic>?) ?? const {},
);
}
@@ -108,6 +111,7 @@ class Extension {
URLHandler? urlHandler,
TrackMatching? trackMatching,
PostProcessing? postProcessing,
Map<String, dynamic>? 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 {
+616 -6
View File
@@ -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<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> with AutomaticKeepAliveClient
),
],
),
), // Close RefreshIndicator
), // Close GestureDetector
);
}
@@ -670,6 +719,382 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
);
}
List<Widget> _buildExploreSections(ExploreState exploreState, ColorScheme colorScheme) {
final slivers = <Widget>[];
// 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<void> _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<void> _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<RecentAccessItem> items,
List<DownloadHistoryItem> historyItems,
@@ -2422,3 +2847,188 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
);
}
}
/// 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),
),
],
),
),
);
}
}
@@ -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),
+28
View File
@@ -794,6 +794,34 @@ class PlatformBridge {
}
}
/// Get extension home feed
static Future<Map<String, dynamic>?> getExtensionHomeFeed(String extensionId) async {
try {
final result = await _channel.invokeMethod('getExtensionHomeFeed', {
'extension_id': extensionId,
});
if (result == null || result == '') return null;
return jsonDecode(result as String) as Map<String, dynamic>;
} catch (e) {
_log.e('getExtensionHomeFeed failed: $e');
return null;
}
}
/// Get extension browse categories
static Future<Map<String, dynamic>?> getExtensionBrowseCategories(String extensionId) async {
try {
final result = await _channel.invokeMethod('getExtensionBrowseCategories', {
'extension_id': extensionId,
});
if (result == null || result == '') return null;
return jsonDecode(result as String) as Map<String, dynamic>;
} catch (e) {
_log.e('getExtensionBrowseCategories failed: $e');
return null;
}
}
static Future<Map<String, dynamic>> runPostProcessing(
String filePath, {