mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-16 05:29:15 +02:00
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:
@@ -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
|
||||
|
||||
@@ -11,16 +11,6 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc
|
||||

|
||||

|
||||
|
||||
<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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"@@locale": "tr",
|
||||
"@@last_modified": "2026-01-21",
|
||||
|
||||
"appName": "SpotiFLAC",
|
||||
"@appName": {"description": "App name - DO NOT TRANSLATE"}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user