mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-25 17:17:52 +02:00
feat: add home tab enhancements, download queue improvements, and platform bridge updates
This commit is contained in:
@@ -56,3 +56,6 @@ android/app/libs/gobackend-sources.jar
|
||||
|
||||
# Extension folder
|
||||
extension/
|
||||
AGENTS.md
|
||||
nul
|
||||
/extension
|
||||
|
||||
@@ -1,5 +1,49 @@
|
||||
# Changelog
|
||||
|
||||
## [3.0.1] - 2026-01-21
|
||||
|
||||
### Added
|
||||
|
||||
- **Year in Album Folder Name** ([#50](https://github.com/zarzet/SpotiFLAC-Mobile/issues/50)): New album folder structure options with release year
|
||||
- `Artist / [Year] Album`: Albums/Coldplay/[2005] X&Y/
|
||||
- `[Year] Album Only`: Albums/[2005] X&Y/
|
||||
- Year extracted from release date metadata
|
||||
- Matches desktop SpotiFLAC folder structure
|
||||
|
||||
- **Extension Album/Playlist/Artist Support**: Extensions can now return albums, playlists, and artists in search results
|
||||
- Search results now properly separated into Albums, Playlists, Artists, and Songs sections
|
||||
- Albums, playlists, and artists show chevron icon (navigate to detail) instead of download button
|
||||
- Tap album/playlist to view track list and download
|
||||
- Tap artist to view their albums/discography
|
||||
- New `getAlbum()`, `getPlaylist()`, and `getArtist()` extension functions
|
||||
- New `ExtensionAlbumScreen`, `ExtensionPlaylistScreen`, and `ExtensionArtistScreen` for fetching content from extensions
|
||||
- YouTube Music extension updated with album/playlist/artist support
|
||||
- See [Extension Development Guide](docs/EXTENSION_DEVELOPMENT.md#artist-support) for implementation details
|
||||
|
||||
- **Odesli (song.link) Integration for YouTube Music Extension**
|
||||
- New `enrichTrack()` function to fetch ISRC and external service links
|
||||
- Uses Odesli API to convert YouTube Music tracks to Deezer/Tidal/Qobuz/Spotify
|
||||
- Enables built-in service fallback for high-quality audio downloads
|
||||
- Extension version updated to 1.4.0 with `api.song.link` and `odesli.io` network permissions
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed PageView overscroll at edges (BouncingScrollPhysics → ClampingScrollPhysics)
|
||||
- Fixed settings item highlight on swipe (highlightColor: Colors.transparent)
|
||||
- Fixed extension duplicate load error (skip silently instead of throwing error)
|
||||
- Fixed keyboard appearing when swiping between tabs (unfocus on page change)
|
||||
- Removed "Free"/"API Key" badges from search source selector
|
||||
- **Go Backend: Missing `item_type` and `album_type` fields**
|
||||
- Added `ItemType` and `AlbumType` fields to `ExtTrackMetadata` struct
|
||||
- Fixed `CustomSearchWithExtensionJSON` - now includes `item_type` and `album_type` in response
|
||||
- Fixed `HandleURLWithExtensionJSON` - now includes `item_type` and `album_type` for tracks
|
||||
- Fixed `GetAlbumWithExtensionJSON` - now includes `item_type` and `album_type` for album tracks
|
||||
- Fixed `GetPlaylistWithExtensionJSON` - now includes `item_type` and `album_type` for playlist tracks
|
||||
- **Album/Playlist Track Thumbnails**: Tracks inside albums/playlists now use album/playlist cover as fallback when no individual cover exists
|
||||
- **YouTube Music Extension getArtist**: Fixed `getArtist()` function not being registered in extension, causing artist pages to fail with "returned null" error
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0] - 2026-01-14
|
||||
|
||||
### 🎉 Extension System (Major Feature)
|
||||
@@ -45,6 +89,12 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
||||
- Based on `album_type` from Spotify/Deezer metadata
|
||||
- Toggle in Settings > Download > Separate Singles Folder
|
||||
|
||||
- **Year in Album Folder Name**: New album folder structure options with release year
|
||||
- `Artist / [Year] Album`: Albums/Coldplay/[2005] X&Y/
|
||||
- `[Year] Album Only`: Albums/[2005] X&Y/
|
||||
- Year extracted from release date metadata
|
||||
- Matches desktop SpotiFLAC folder structure
|
||||
|
||||
- **Parallel API Calls**: Download URL fetching now uses parallel requests
|
||||
- Tidal: All 8 APIs requested simultaneously, first success wins
|
||||
- Qobuz: Both APIs requested simultaneously, first success wins
|
||||
|
||||
@@ -572,6 +572,30 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getAlbumWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val albumId = call.argument<String>("album_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getAlbumWithExtensionJSON(extensionId, albumId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getPlaylistWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val playlistId = call.argument<String>("playlist_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getPlaylistWithExtensionJSON(extensionId, playlistId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getArtistWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val artistId = call.argument<String>("artist_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getArtistWithExtensionJSON(extensionId, artistId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Extension Post-Processing API
|
||||
"runPostProcessing" -> {
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ParseSpotifyURL parses and validates a Spotify URL
|
||||
@@ -150,6 +152,10 @@ type DownloadRequest struct {
|
||||
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
||||
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
||||
Source string `json:"source"` // Extension ID that provided this track (prioritize this extension)
|
||||
// Enriched IDs from Odesli/song.link - used to skip search and directly fetch
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
}
|
||||
|
||||
// DownloadResponse represents the result of a download
|
||||
@@ -1516,6 +1522,8 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
|
||||
"disc_number": track.DiscNumber,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType, // track, album, or playlist
|
||||
"album_type": track.AlbumType, // album, single, ep, compilation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1613,6 +1621,8 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
"disc_number": track.DiscNumber,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType,
|
||||
"album_type": track.AlbumType,
|
||||
}
|
||||
}
|
||||
response["tracks"] = tracks
|
||||
@@ -1627,6 +1637,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
"cover_url": result.Album.CoverURL,
|
||||
"release_date": result.Album.ReleaseDate,
|
||||
"total_tracks": result.Album.TotalTracks,
|
||||
"album_type": result.Album.AlbumType,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1681,6 +1692,219 @@ func FindURLHandlerJSON(url string) string {
|
||||
return handler.extension.ID
|
||||
}
|
||||
|
||||
// GetAlbumWithExtensionJSON gets album tracks using an extension
|
||||
func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !ext.Manifest.IsMetadataProvider() {
|
||||
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
|
||||
}
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
album, err := provider.GetAlbum(albumID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if album == nil {
|
||||
return "", fmt.Errorf("album not found")
|
||||
}
|
||||
|
||||
// Convert tracks to map format
|
||||
tracks := make([]map[string]interface{}, len(album.Tracks))
|
||||
for i, track := range album.Tracks {
|
||||
// Use album cover as fallback if track doesn't have its own cover
|
||||
trackCover := track.ResolvedCoverURL()
|
||||
if trackCover == "" {
|
||||
trackCover = album.CoverURL
|
||||
}
|
||||
tracks[i] = map[string]interface{}{
|
||||
"id": track.ID,
|
||||
"name": track.Name,
|
||||
"artists": track.Artists,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"cover_url": trackCover,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"disc_number": track.DiscNumber,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType,
|
||||
"album_type": track.AlbumType,
|
||||
}
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"id": album.ID,
|
||||
"name": album.Name,
|
||||
"artists": album.Artists,
|
||||
"cover_url": album.CoverURL,
|
||||
"release_date": album.ReleaseDate,
|
||||
"total_tracks": album.TotalTracks,
|
||||
"album_type": album.AlbumType,
|
||||
"tracks": tracks,
|
||||
"provider_id": album.ProviderID,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetPlaylistWithExtensionJSON gets playlist tracks using an extension
|
||||
func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !ext.Manifest.IsMetadataProvider() {
|
||||
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
|
||||
}
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
|
||||
// Try getPlaylist first, fall back to getAlbum (some extensions use album for playlists)
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.getPlaylist === 'function') {
|
||||
return extension.getPlaylist(%q);
|
||||
}
|
||||
if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') {
|
||||
return extension.getAlbum(%q);
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
`, playlistID, playlistID)
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(provider.vm, script, DefaultJSTimeout)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getPlaylist failed: %w", err)
|
||||
}
|
||||
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return "", fmt.Errorf("playlist not found")
|
||||
}
|
||||
|
||||
exported := result.Export()
|
||||
jsonBytes, err := json.Marshal(exported)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal result: %w", err)
|
||||
}
|
||||
|
||||
// Parse into album metadata (same structure)
|
||||
var album ExtAlbumMetadata
|
||||
if err := json.Unmarshal(jsonBytes, &album); err != nil {
|
||||
return "", fmt.Errorf("failed to parse playlist: %w", err)
|
||||
}
|
||||
|
||||
// Convert tracks to map format
|
||||
tracks := make([]map[string]interface{}, len(album.Tracks))
|
||||
for i, track := range album.Tracks {
|
||||
// Use playlist cover as fallback if track doesn't have its own cover
|
||||
trackCover := track.ResolvedCoverURL()
|
||||
if trackCover == "" {
|
||||
trackCover = album.CoverURL
|
||||
}
|
||||
tracks[i] = map[string]interface{}{
|
||||
"id": track.ID,
|
||||
"name": track.Name,
|
||||
"artists": track.Artists,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"cover_url": trackCover,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"disc_number": track.DiscNumber,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType,
|
||||
"album_type": track.AlbumType,
|
||||
}
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"id": album.ID,
|
||||
"name": album.Name,
|
||||
"owner": album.Artists,
|
||||
"cover_url": album.CoverURL,
|
||||
"total_tracks": album.TotalTracks,
|
||||
"tracks": tracks,
|
||||
"provider_id": album.ProviderID,
|
||||
}
|
||||
|
||||
jsonBytes, err = json.Marshal(response)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetArtistWithExtensionJSON gets artist info and albums using an extension
|
||||
func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !ext.Manifest.IsMetadataProvider() {
|
||||
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
|
||||
}
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
artist, err := provider.GetArtist(artistID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if artist == nil {
|
||||
return "", fmt.Errorf("artist not found")
|
||||
}
|
||||
|
||||
// Convert albums to map format
|
||||
albums := make([]map[string]interface{}, len(artist.Albums))
|
||||
for i, album := range artist.Albums {
|
||||
albums[i] = map[string]interface{}{
|
||||
"id": album.ID,
|
||||
"name": album.Name,
|
||||
"artists": album.Artists,
|
||||
"cover_url": album.CoverURL,
|
||||
"release_date": album.ReleaseDate,
|
||||
"total_tracks": album.TotalTracks,
|
||||
"album_type": album.AlbumType,
|
||||
"provider_id": album.ProviderID,
|
||||
}
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"id": artist.ID,
|
||||
"name": artist.Name,
|
||||
"cover_url": artist.ImageURL,
|
||||
"albums": albums,
|
||||
"provider_id": artist.ProviderID,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetURLHandlersJSON returns all extensions that handle custom URLs
|
||||
func GetURLHandlersJSON() (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
|
||||
@@ -29,6 +29,14 @@ type ExtTrackMetadata struct {
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
ItemType string `json:"item_type,omitempty"` // track, album, or playlist - for extension search results
|
||||
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||
// Enrichment fields from Odesli/song.link
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
SpotifyID string `json:"spotify_id,omitempty"`
|
||||
ExternalLinks map[string]string `json:"external_links,omitempty"` // service -> URL mapping
|
||||
}
|
||||
|
||||
// ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields
|
||||
@@ -730,6 +738,19 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC)
|
||||
req.ISRC = enrichedTrack.ISRC
|
||||
}
|
||||
// Update service-specific IDs from Odesli enrichment
|
||||
if enrichedTrack.TidalID != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Tidal ID from Odesli: %s\n", enrichedTrack.TidalID)
|
||||
req.TidalID = enrichedTrack.TidalID
|
||||
}
|
||||
if enrichedTrack.QobuzID != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Qobuz ID from Odesli: %s\n", enrichedTrack.QobuzID)
|
||||
req.QobuzID = enrichedTrack.QobuzID
|
||||
}
|
||||
if enrichedTrack.DeezerID != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Deezer ID from Odesli: %s\n", enrichedTrack.DeezerID)
|
||||
req.DeezerID = enrichedTrack.DeezerID
|
||||
}
|
||||
// Can also update other fields if needed
|
||||
if enrichedTrack.Name != "" {
|
||||
req.TrackName = enrichedTrack.Name
|
||||
|
||||
+45
-1
@@ -367,6 +367,35 @@ func NewQobuzDownloader() *QobuzDownloader {
|
||||
return globalQobuzDownloader
|
||||
}
|
||||
|
||||
// GetTrackByID fetches track info directly by Qobuz track ID
|
||||
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
|
||||
// Qobuz API: /track/get?track_id=XXX
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
|
||||
trackURL := fmt.Sprintf("%s%d&app_id=%s", string(apiBase), trackID, q.appID)
|
||||
|
||||
req, err := http.NewRequest("GET", trackURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("get track failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var track QobuzTrack
|
||||
if err := json.NewDecoder(resp.Body).Decode(&track); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &track, nil
|
||||
}
|
||||
|
||||
// GetAvailableAPIs returns list of available Qobuz APIs
|
||||
// Uses same APIs as PC version for compatibility
|
||||
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
||||
@@ -936,8 +965,23 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
var track *QobuzTrack
|
||||
var err error
|
||||
|
||||
// STRATEGY 0: Use pre-fetched Qobuz ID from Odesli enrichment (highest priority)
|
||||
if req.QobuzID != "" {
|
||||
GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID)
|
||||
var trackID int64
|
||||
if _, parseErr := fmt.Sscanf(req.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
track, err = downloader.GetTrackByID(trackID)
|
||||
if err != nil {
|
||||
GoLog("[Qobuz] Failed to get track by Odesli ID %d: %v\n", trackID, err)
|
||||
track = nil
|
||||
} else if track != nil {
|
||||
GoLog("[Qobuz] Successfully found track via Odesli ID: '%s' by '%s'\n", track.Title, track.Performer.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIMIZATION: Check cache first for track ID
|
||||
if req.ISRC != "" {
|
||||
if track == nil && req.ISRC != "" {
|
||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
||||
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
||||
// For Qobuz we need to search again to get full track info, but we can use the ID
|
||||
|
||||
+17
-1
@@ -1457,8 +1457,24 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
var track *TidalTrack
|
||||
var err error
|
||||
|
||||
// STRATEGY 0: Use pre-fetched Tidal ID from Odesli enrichment (highest priority)
|
||||
if req.TidalID != "" {
|
||||
GoLog("[Tidal] Using Tidal ID from Odesli enrichment: %s\n", req.TidalID)
|
||||
// Parse track ID (could be a number or extracted from URL)
|
||||
var trackID int64
|
||||
if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
track, err = downloader.GetTrackInfoByID(trackID)
|
||||
if err != nil {
|
||||
GoLog("[Tidal] Failed to get track by Odesli ID %d: %v\n", trackID, err)
|
||||
track = nil
|
||||
} else if track != nil {
|
||||
GoLog("[Tidal] Successfully found track via Odesli ID: '%s' by '%s'\n", track.Title, track.Artist.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIMIZATION: Check cache first for track ID
|
||||
if req.ISRC != "" {
|
||||
if track == nil && req.ISRC != "" {
|
||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
|
||||
GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
|
||||
track, err = downloader.GetTrackInfoByID(cached.TidalTrackID)
|
||||
|
||||
@@ -503,6 +503,30 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getAlbumWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let albumId = args["album_id"] as! String
|
||||
let response = GobackendGetAlbumWithExtensionJSON(extensionId, albumId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getPlaylistWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let playlistId = args["playlist_id"] as! String
|
||||
let response = GobackendGetPlaylistWithExtensionJSON(extensionId, playlistId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getArtistWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let artistId = args["artist_id"] as! String
|
||||
let response = GobackendGetArtistWithExtensionJSON(extensionId, artistId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Extension Post-Processing API
|
||||
case "runPostProcessing":
|
||||
let args = call.arguments as! [String: Any]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '3.0.0';
|
||||
static const String buildNumber = '57';
|
||||
static const String version = '3.0.1';
|
||||
static const String buildNumber = '58';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ class AppSettings {
|
||||
final bool useExtensionProviders; // Use extension providers for downloads when available
|
||||
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
|
||||
final bool separateSingles; // Separate singles/EPs into their own folder
|
||||
final String albumFolderStructure; // artist_album or album_only
|
||||
final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album
|
||||
final bool showExtensionStore; // Show Extension Store tab in navigation
|
||||
|
||||
const AppSettings({
|
||||
|
||||
@@ -32,7 +32,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
||||
searchProvider: json['searchProvider'] as String?,
|
||||
separateSingles: json['separateSingles'] as bool? ?? false,
|
||||
albumFolderStructure: json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||
albumFolderStructure:
|
||||
json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||
);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ class Track {
|
||||
final ServiceAvailability? availability;
|
||||
final String? source; // Extension ID that provided this track (null for built-in sources)
|
||||
final String? albumType; // album, single, ep, compilation (from metadata API)
|
||||
final String? itemType; // track, album, playlist - for extension search results
|
||||
|
||||
const Track({
|
||||
required this.id,
|
||||
@@ -37,10 +38,23 @@ class Track {
|
||||
this.availability,
|
||||
this.source,
|
||||
this.albumType,
|
||||
this.itemType,
|
||||
});
|
||||
|
||||
/// Check if this track is a single (based on album_type metadata)
|
||||
bool get isSingle => albumType == 'single' || albumType == 'ep';
|
||||
|
||||
/// Check if this is an album item (not a track)
|
||||
bool get isAlbumItem => itemType == 'album';
|
||||
|
||||
/// Check if this is a playlist item (not a track)
|
||||
bool get isPlaylistItem => itemType == 'playlist';
|
||||
|
||||
/// Check if this is an artist item (not a track)
|
||||
bool get isArtistItem => itemType == 'artist';
|
||||
|
||||
/// Check if this is a collection (album, playlist, or artist)
|
||||
bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem;
|
||||
|
||||
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
||||
|
||||
@@ -26,6 +26,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
||||
),
|
||||
source: json['source'] as String?,
|
||||
albumType: json['albumType'] as String?,
|
||||
itemType: json['itemType'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
@@ -44,6 +45,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
'availability': instance.availability,
|
||||
'source': instance.source,
|
||||
'albumType': instance.albumType,
|
||||
'itemType': instance.itemType,
|
||||
};
|
||||
|
||||
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -688,15 +688,28 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
} else {
|
||||
// Albums folder structure based on setting
|
||||
final albumName = _sanitizeFolderName(track.albumName);
|
||||
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
|
||||
final year = _extractYear(track.releaseDate);
|
||||
String albumPath;
|
||||
|
||||
if (albumFolderStructure == 'album_only') {
|
||||
// Albums/Album structure (no artist folder)
|
||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName';
|
||||
} else {
|
||||
// Albums/Artist/Album structure (default)
|
||||
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
|
||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
|
||||
switch (albumFolderStructure) {
|
||||
case 'album_only':
|
||||
// Albums/Album structure (no artist folder)
|
||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName';
|
||||
break;
|
||||
case 'artist_year_album':
|
||||
// Albums/Artist/[Year] Album structure
|
||||
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
|
||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$yearAlbum';
|
||||
break;
|
||||
case 'year_album':
|
||||
// Albums/[Year] Album structure (no artist folder)
|
||||
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
|
||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$yearAlbum';
|
||||
break;
|
||||
default:
|
||||
// Albums/Artist/Album structure (default: artist_album)
|
||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
|
||||
}
|
||||
|
||||
final dir = Directory(albumPath);
|
||||
@@ -751,6 +764,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/// Extract year from release date (format: "2005-06-13" or "2005")
|
||||
String? _extractYear(String? releaseDate) {
|
||||
if (releaseDate == null || releaseDate.isEmpty) return null;
|
||||
// Handle both "2005-06-13" and "2005" formats
|
||||
final match = RegExp(r'^(\d{4})').firstMatch(releaseDate);
|
||||
return match?.group(1);
|
||||
}
|
||||
|
||||
void updateSettings(AppSettings settings) {
|
||||
state = state.copyWith(
|
||||
outputDir: settings.downloadDirectory.isNotEmpty
|
||||
|
||||
@@ -82,6 +82,7 @@ class ArtistAlbum {
|
||||
final String? coverUrl;
|
||||
final String albumType; // album, single, compilation
|
||||
final String artists;
|
||||
final String? providerId; // Extension ID if from extension
|
||||
|
||||
const ArtistAlbum({
|
||||
required this.id,
|
||||
@@ -91,6 +92,7 @@ class ArtistAlbum {
|
||||
this.coverUrl,
|
||||
required this.albumType,
|
||||
required this.artists,
|
||||
this.providerId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -479,6 +481,23 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
void setSearchText(bool hasText) {
|
||||
state = state.copyWith(hasSearchText: hasText);
|
||||
}
|
||||
|
||||
/// Set tracks from a collection (album/playlist) opened from search results
|
||||
void setTracksFromCollection({
|
||||
required List<Track> tracks,
|
||||
String? albumName,
|
||||
String? playlistName,
|
||||
String? coverUrl,
|
||||
}) {
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumName: albumName,
|
||||
playlistName: playlistName,
|
||||
coverUrl: coverUrl,
|
||||
hasSearchText: state.hasSearchText,
|
||||
);
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
return Track(
|
||||
@@ -506,13 +525,16 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
durationMs = durationValue.toInt();
|
||||
}
|
||||
|
||||
// Get item_type - can be 'track', 'album', or 'playlist'
|
||||
final itemType = data['item_type']?.toString();
|
||||
|
||||
return Track(
|
||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||
albumArtist: data['album_artist']?.toString(),
|
||||
coverUrl: data['images']?.toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
@@ -520,6 +542,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(),
|
||||
albumType: data['album_type']?.toString(),
|
||||
itemType: itemType,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen;
|
||||
|
||||
/// Simple in-memory cache for artist discography
|
||||
class _ArtistCache {
|
||||
@@ -346,14 +347,29 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
void _navigateToAlbum(ArtistAlbum album) {
|
||||
// Navigate immediately with data from artist discography, fetch tracks in AlbumScreen
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => AlbumScreen(
|
||||
albumId: album.id,
|
||||
albumName: album.name,
|
||||
coverUrl: album.coverUrl,
|
||||
// tracks: null - will be fetched in AlbumScreen
|
||||
),
|
||||
));
|
||||
|
||||
// Check if this album is from an extension (has providerId)
|
||||
if (album.providerId != null && album.providerId!.isNotEmpty) {
|
||||
// Use ExtensionAlbumScreen for extension albums
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => ExtensionAlbumScreen(
|
||||
extensionId: album.providerId!,
|
||||
albumId: album.id,
|
||||
albumName: album.name,
|
||||
coverUrl: album.coverUrl,
|
||||
),
|
||||
));
|
||||
} else {
|
||||
// Use regular AlbumScreen for Spotify/Deezer albums
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => AlbumScreen(
|
||||
albumId: album.id,
|
||||
albumName: album.name,
|
||||
coverUrl: album.coverUrl,
|
||||
// tracks: null - will be fetched in AlbumScreen
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Build error widget with special handling for rate limit (429)
|
||||
|
||||
@@ -6,6 +6,8 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
class HomeScreen extends ConsumerStatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
@@ -267,6 +269,23 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
|
||||
Widget _buildTrackTile(int index, ColorScheme colorScheme) {
|
||||
final track = ref.watch(trackProvider).tracks[index];
|
||||
final isCollection = track.isCollection;
|
||||
|
||||
// Determine subtitle text based on item type
|
||||
String subtitleText;
|
||||
if (isCollection) {
|
||||
final typeLabel = track.albumType ?? (track.isPlaylistItem ? 'Playlist' : 'Album');
|
||||
final capitalizedType = typeLabel.isNotEmpty
|
||||
? '${typeLabel[0].toUpperCase()}${typeLabel.substring(1)}'
|
||||
: 'Album';
|
||||
final year = track.releaseDate != null && track.releaseDate!.length >= 4
|
||||
? track.releaseDate!.substring(0, 4)
|
||||
: '';
|
||||
subtitleText = '$capitalizedType • ${track.artistName}${year.isNotEmpty ? ' • $year' : ''}';
|
||||
} else {
|
||||
subtitleText = track.artistName;
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
leading: track.coverUrl != null
|
||||
? ClipRRect(
|
||||
@@ -285,22 +304,87 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
child: Icon(
|
||||
isCollection ? Icons.album : Icons.music_note,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(
|
||||
track.artistName,
|
||||
subtitleText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
trailing: Text(
|
||||
_formatDuration(track.duration),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
onTap: () => _downloadTrack(index),
|
||||
trailing: isCollection
|
||||
? Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant)
|
||||
: Text(
|
||||
_formatDuration(track.duration),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
onTap: () => isCollection ? _openCollection(track) : _downloadTrack(index),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openCollection(Track track) async {
|
||||
// Get the extension ID from the track source
|
||||
final extensionId = track.source;
|
||||
if (extensionId == null) return;
|
||||
|
||||
// Fetch album/playlist tracks using the extension
|
||||
try {
|
||||
if (track.isAlbumItem) {
|
||||
final albumData = await PlatformBridge.getAlbumWithExtension(extensionId, track.id);
|
||||
if (albumData != null && mounted) {
|
||||
final trackList = albumData['tracks'] as List<dynamic>? ?? [];
|
||||
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
|
||||
ref.read(trackProvider.notifier).setTracksFromCollection(
|
||||
tracks: tracks,
|
||||
albumName: albumData['name'] as String? ?? track.name,
|
||||
coverUrl: albumData['cover_url'] as String? ?? track.coverUrl,
|
||||
);
|
||||
}
|
||||
} else if (track.isPlaylistItem) {
|
||||
final playlistData = await PlatformBridge.getPlaylistWithExtension(extensionId, track.id);
|
||||
if (playlistData != null && mounted) {
|
||||
final trackList = playlistData['tracks'] as List<dynamic>? ?? [];
|
||||
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
|
||||
ref.read(trackProvider.notifier).setTracksFromCollection(
|
||||
tracks: tracks,
|
||||
playlistName: playlistData['name'] as String? ?? track.name,
|
||||
coverUrl: playlistData['cover_url'] as String? ?? track.coverUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to load: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Track _parseExtensionTrack(Map<String, dynamic> data, String source) {
|
||||
int durationMs = 0;
|
||||
final durationValue = data['duration_ms'];
|
||||
if (durationValue is int) {
|
||||
durationMs = durationValue;
|
||||
} else if (durationValue is double) {
|
||||
durationMs = durationValue.toInt();
|
||||
}
|
||||
|
||||
return Track(
|
||||
id: (data['id'] ?? '').toString(),
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? '').toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
source: source,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+686
-13
@@ -13,6 +13,7 @@ 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';
|
||||
import 'package:spotiflac_android/services/csv_import_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/screens/playlist_screen.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
@@ -636,6 +637,12 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
return [const SliverToBoxAdapter(child: SizedBox.shrink())];
|
||||
}
|
||||
|
||||
// Separate tracks from albums/playlists/artists
|
||||
final realTracks = tracks.where((t) => !t.isCollection).toList();
|
||||
final albumItems = tracks.where((t) => t.isAlbumItem).toList();
|
||||
final playlistItems = tracks.where((t) => t.isPlaylistItem).toList();
|
||||
final artistItems = tracks.where((t) => t.isArtistItem).toList();
|
||||
|
||||
return [
|
||||
// Error message - with special handling for rate limit (429)
|
||||
if (error != null)
|
||||
@@ -648,19 +655,17 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
if (isLoading)
|
||||
const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator())),
|
||||
|
||||
// Artist search results (horizontal scroll)
|
||||
// Artist search results (horizontal scroll) - from built-in providers
|
||||
if (searchArtists != null && searchArtists.isNotEmpty)
|
||||
SliverToBoxAdapter(child: _buildArtistSearchResults(searchArtists, colorScheme)),
|
||||
|
||||
// Songs section header
|
||||
if (tracks.isNotEmpty)
|
||||
// Artists section - from extension search
|
||||
if (artistItems.isNotEmpty)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text('Songs', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
child: Text('Artists', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
)),
|
||||
|
||||
// Track list in grouped card
|
||||
if (tracks.isNotEmpty)
|
||||
if (artistItems.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
@@ -676,13 +681,120 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int i = 0; i < tracks.length; i++)
|
||||
for (int i = 0; i < artistItems.length; i++)
|
||||
_CollectionItemWidget(
|
||||
key: ValueKey('artist-${artistItems[i].id}'),
|
||||
item: artistItems[i],
|
||||
showDivider: i < artistItems.length - 1,
|
||||
onTap: () => _navigateToExtensionArtist(artistItems[i]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Albums section
|
||||
if (albumItems.isNotEmpty)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text('Albums', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
)),
|
||||
if (albumItems.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int i = 0; i < albumItems.length; i++)
|
||||
_CollectionItemWidget(
|
||||
key: ValueKey('album-${albumItems[i].id}'),
|
||||
item: albumItems[i],
|
||||
showDivider: i < albumItems.length - 1,
|
||||
onTap: () => _navigateToExtensionAlbum(albumItems[i]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Playlists section
|
||||
if (playlistItems.isNotEmpty)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text('Playlists', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
)),
|
||||
if (playlistItems.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int i = 0; i < playlistItems.length; i++)
|
||||
_CollectionItemWidget(
|
||||
key: ValueKey('playlist-${playlistItems[i].id}'),
|
||||
item: playlistItems[i],
|
||||
showDivider: i < playlistItems.length - 1,
|
||||
onTap: () => _navigateToExtensionPlaylist(playlistItems[i]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Songs section header
|
||||
if (realTracks.isNotEmpty)
|
||||
SliverToBoxAdapter(child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text('Songs', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
)),
|
||||
|
||||
// Track list in grouped card
|
||||
if (realTracks.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int i = 0; i < realTracks.length; i++)
|
||||
_TrackItemWithStatus(
|
||||
key: ValueKey(tracks[i].id),
|
||||
track: tracks[i],
|
||||
index: i,
|
||||
showDivider: i < tracks.length - 1,
|
||||
onDownload: () => _downloadTrack(i),
|
||||
key: ValueKey(realTracks[i].id),
|
||||
track: realTracks[i],
|
||||
index: tracks.indexOf(realTracks[i]), // Use original index for download
|
||||
showDivider: i < realTracks.length - 1,
|
||||
onDownload: () => _downloadTrack(tracks.indexOf(realTracks[i])),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -785,6 +897,72 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
));
|
||||
}
|
||||
|
||||
void _navigateToExtensionAlbum(Track albumItem) async {
|
||||
final extensionId = albumItem.source;
|
||||
if (extensionId == null || extensionId.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Cannot load album: missing extension source')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
|
||||
// Navigate to AlbumScreen - it will fetch tracks via extension
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => ExtensionAlbumScreen(
|
||||
extensionId: extensionId,
|
||||
albumId: albumItem.id,
|
||||
albumName: albumItem.name,
|
||||
coverUrl: albumItem.coverUrl,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
void _navigateToExtensionPlaylist(Track playlistItem) async {
|
||||
final extensionId = playlistItem.source;
|
||||
if (extensionId == null || extensionId.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Cannot load playlist: missing extension source')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
|
||||
// Navigate to ExtensionPlaylistScreen - it will fetch tracks via extension
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => ExtensionPlaylistScreen(
|
||||
extensionId: extensionId,
|
||||
playlistId: playlistItem.id,
|
||||
playlistName: playlistItem.name,
|
||||
coverUrl: playlistItem.coverUrl,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
void _navigateToExtensionArtist(Track artistItem) {
|
||||
final extensionId = artistItem.source;
|
||||
if (extensionId == null || extensionId.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Cannot load artist: missing extension source')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
|
||||
// Navigate to ExtensionArtistScreen - it will fetch albums via extension
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => ExtensionArtistScreen(
|
||||
extensionId: extensionId,
|
||||
artistId: artistItem.id,
|
||||
artistName: artistItem.name,
|
||||
coverUrl: artistItem.coverUrl,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
/// Get search hint based on selected provider
|
||||
String _getSearchHint() {
|
||||
final settings = ref.read(settingsProvider);
|
||||
@@ -1109,3 +1287,498 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget for displaying album/playlist items in search results
|
||||
class _CollectionItemWidget extends StatelessWidget {
|
||||
final Track item;
|
||||
final bool showDivider;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _CollectionItemWidget({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.showDivider,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isPlaylist = item.isPlaylistItem;
|
||||
final isArtist = item.isArtistItem;
|
||||
|
||||
// Determine icon for placeholder
|
||||
IconData placeholderIcon = Icons.album;
|
||||
if (isPlaylist) placeholderIcon = Icons.playlist_play;
|
||||
if (isArtist) placeholderIcon = Icons.person;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
// Cover art (circular for artists)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(isArtist ? 28 : 10),
|
||||
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
memCacheHeight: 112,
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
placeholderIcon,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.name,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.artistName.isNotEmpty ? item.artistName : (isPlaylist ? 'Playlist' : 'Album'),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Arrow indicator
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 80,
|
||||
endIndent: 12,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Screen for viewing extension album with track fetching
|
||||
class ExtensionAlbumScreen extends ConsumerStatefulWidget {
|
||||
final String extensionId;
|
||||
final String albumId;
|
||||
final String albumName;
|
||||
final String? coverUrl;
|
||||
|
||||
const ExtensionAlbumScreen({
|
||||
super.key,
|
||||
required this.extensionId,
|
||||
required this.albumId,
|
||||
required this.albumName,
|
||||
this.coverUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ExtensionAlbumScreen> createState() => _ExtensionAlbumScreenState();
|
||||
}
|
||||
|
||||
class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
List<Track>? _tracks;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchTracks();
|
||||
}
|
||||
|
||||
Future<void> _fetchTracks() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.getAlbumWithExtension(
|
||||
widget.extensionId,
|
||||
widget.albumId,
|
||||
);
|
||||
|
||||
if (result == null) {
|
||||
setState(() {
|
||||
_error = 'Failed to load album';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse tracks from result
|
||||
final trackList = result['tracks'] as List<dynamic>?;
|
||||
if (trackList == null) {
|
||||
setState(() {
|
||||
_error = 'No tracks found';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Error: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
int durationMs = 0;
|
||||
final durationValue = data['duration_ms'];
|
||||
if (durationValue is int) {
|
||||
durationMs = durationValue;
|
||||
} else if (durationValue is double) {
|
||||
durationMs = durationValue.toInt();
|
||||
}
|
||||
|
||||
return Track(
|
||||
id: (data['id'] ?? '').toString(),
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? widget.albumName).toString(),
|
||||
coverUrl: _resolveCoverUrl(data['cover_url']?.toString(), widget.coverUrl),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
source: widget.extensionId,
|
||||
);
|
||||
}
|
||||
|
||||
String? _resolveCoverUrl(String? trackCover, String? albumCover) {
|
||||
if (trackCover != null && trackCover.isNotEmpty) return trackCover;
|
||||
return albumCover;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.albumName)),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.albumName)),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(onPressed: _fetchTracks, child: const Text('Retry')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Navigate to AlbumScreen with fetched tracks
|
||||
return AlbumScreen(
|
||||
albumId: widget.albumId,
|
||||
albumName: widget.albumName,
|
||||
coverUrl: widget.coverUrl,
|
||||
tracks: _tracks,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Screen for viewing extension playlist with track fetching
|
||||
class ExtensionPlaylistScreen extends ConsumerStatefulWidget {
|
||||
final String extensionId;
|
||||
final String playlistId;
|
||||
final String playlistName;
|
||||
final String? coverUrl;
|
||||
|
||||
const ExtensionPlaylistScreen({
|
||||
super.key,
|
||||
required this.extensionId,
|
||||
required this.playlistId,
|
||||
required this.playlistName,
|
||||
this.coverUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ExtensionPlaylistScreen> createState() => _ExtensionPlaylistScreenState();
|
||||
}
|
||||
|
||||
class _ExtensionPlaylistScreenState extends ConsumerState<ExtensionPlaylistScreen> {
|
||||
List<Track>? _tracks;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchTracks();
|
||||
}
|
||||
|
||||
Future<void> _fetchTracks() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.getPlaylistWithExtension(
|
||||
widget.extensionId,
|
||||
widget.playlistId,
|
||||
);
|
||||
|
||||
if (result == null) {
|
||||
setState(() {
|
||||
_error = 'Failed to load playlist';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse tracks from result
|
||||
final trackList = result['tracks'] as List<dynamic>?;
|
||||
if (trackList == null) {
|
||||
setState(() {
|
||||
_error = 'No tracks found';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
|
||||
setState(() {
|
||||
_tracks = tracks;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Error: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
int durationMs = 0;
|
||||
final durationValue = data['duration_ms'];
|
||||
if (durationValue is int) {
|
||||
durationMs = durationValue;
|
||||
} else if (durationValue is double) {
|
||||
durationMs = durationValue.toInt();
|
||||
}
|
||||
|
||||
return Track(
|
||||
id: (data['id'] ?? '').toString(),
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? '').toString(),
|
||||
coverUrl: _resolveCoverUrl(data['cover_url']?.toString(), widget.coverUrl),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
source: widget.extensionId,
|
||||
);
|
||||
}
|
||||
|
||||
String? _resolveCoverUrl(String? trackCover, String? playlistCover) {
|
||||
if (trackCover != null && trackCover.isNotEmpty) return trackCover;
|
||||
return playlistCover;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.playlistName)),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.playlistName)),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(onPressed: _fetchTracks, child: const Text('Retry')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Navigate to PlaylistScreen with fetched tracks
|
||||
return PlaylistScreen(
|
||||
playlistName: widget.playlistName,
|
||||
coverUrl: widget.coverUrl,
|
||||
tracks: _tracks!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Screen for viewing extension artist with album fetching
|
||||
class ExtensionArtistScreen extends ConsumerStatefulWidget {
|
||||
final String extensionId;
|
||||
final String artistId;
|
||||
final String artistName;
|
||||
final String? coverUrl;
|
||||
|
||||
const ExtensionArtistScreen({
|
||||
super.key,
|
||||
required this.extensionId,
|
||||
required this.artistId,
|
||||
required this.artistName,
|
||||
this.coverUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ExtensionArtistScreen> createState() => _ExtensionArtistScreenState();
|
||||
}
|
||||
|
||||
class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
||||
List<ArtistAlbum>? _albums;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchArtist();
|
||||
}
|
||||
|
||||
Future<void> _fetchArtist() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.getArtistWithExtension(
|
||||
widget.extensionId,
|
||||
widget.artistId,
|
||||
);
|
||||
|
||||
if (result == null) {
|
||||
setState(() {
|
||||
_error = 'Failed to load artist';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse albums from result
|
||||
final albumList = result['albums'] as List<dynamic>?;
|
||||
if (albumList == null) {
|
||||
setState(() {
|
||||
_albums = [];
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final albums = albumList.map((a) => _parseAlbum(a as Map<String, dynamic>)).toList();
|
||||
|
||||
setState(() {
|
||||
_albums = albums;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Error: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ArtistAlbum _parseAlbum(Map<String, dynamic> data) {
|
||||
return ArtistAlbum(
|
||||
id: (data['id'] ?? '').toString(),
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artists: (data['artists'] ?? '').toString(),
|
||||
releaseDate: (data['release_date'] ?? '').toString(),
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
coverUrl: data['cover_url']?.toString(),
|
||||
albumType: (data['album_type'] ?? 'album').toString(),
|
||||
providerId: (data['provider_id'] ?? widget.extensionId).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.artistName)),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.artistName)),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(onPressed: _fetchArtist, child: const Text('Retry')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Navigate to ArtistScreen with fetched albums
|
||||
return ArtistScreen(
|
||||
artistId: widget.artistId,
|
||||
artistName: widget.artistName,
|
||||
coverUrl: widget.coverUrl,
|
||||
albums: _albums,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,9 +200,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
SettingsItem(
|
||||
icon: Icons.folder_outlined,
|
||||
title: 'Album Folder Structure',
|
||||
subtitle: settings.albumFolderStructure == 'album_only'
|
||||
? 'Albums/Album Name/'
|
||||
: 'Albums/Artist/Album Name/',
|
||||
subtitle: _getAlbumFolderStructureLabel(settings.albumFolderStructure),
|
||||
onTap: () => _showAlbumFolderStructurePicker(
|
||||
context,
|
||||
ref,
|
||||
@@ -234,6 +232,19 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
String _getAlbumFolderStructureLabel(String structure) {
|
||||
switch (structure) {
|
||||
case 'album_only':
|
||||
return 'Albums/Album Name/';
|
||||
case 'artist_year_album':
|
||||
return 'Albums/Artist/[Year] Album/';
|
||||
case 'year_album':
|
||||
return 'Albums/[Year] Album/';
|
||||
default:
|
||||
return 'Albums/Artist/Album Name/';
|
||||
}
|
||||
}
|
||||
|
||||
void _showAlbumFolderStructurePicker(BuildContext context, WidgetRef ref, String current) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -251,6 +262,16 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.calendar_today_outlined),
|
||||
title: const Text('Artist / [Year] Album'),
|
||||
subtitle: const Text('Albums/Artist Name/[2005] Album Name/'),
|
||||
trailing: current == 'artist_year_album' ? const Icon(Icons.check) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_year_album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.album_outlined),
|
||||
title: const Text('Album Only'),
|
||||
@@ -261,6 +282,16 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.event_outlined),
|
||||
title: const Text('[Year] Album Only'),
|
||||
subtitle: const Text('Albums/[2005] Album Name/'),
|
||||
trailing: current == 'year_album' ? const Icon(Icons.check) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('year_album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -907,16 +907,12 @@ class _SourceChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback? onTap;
|
||||
final String? badge;
|
||||
final Color? badgeColor;
|
||||
|
||||
const _SourceChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
this.onTap,
|
||||
this.badge,
|
||||
this.badgeColor,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -962,24 +958,6 @@ class _SourceChip extends StatelessWidget {
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (badge != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: (badgeColor ?? colorScheme.tertiary).withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
badge!,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: badgeColor ?? colorScheme.tertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -787,6 +787,60 @@ class PlatformBridge {
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Get album tracks using an extension
|
||||
static Future<Map<String, dynamic>?> getAlbumWithExtension(
|
||||
String extensionId,
|
||||
String albumId,
|
||||
) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('getAlbumWithExtension', {
|
||||
'extension_id': extensionId,
|
||||
'album_id': albumId,
|
||||
});
|
||||
if (result == null || result == '') return null;
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
_log.e('getAlbumWithExtension failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get playlist tracks using an extension
|
||||
static Future<Map<String, dynamic>?> getPlaylistWithExtension(
|
||||
String extensionId,
|
||||
String playlistId,
|
||||
) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('getPlaylistWithExtension', {
|
||||
'extension_id': extensionId,
|
||||
'playlist_id': playlistId,
|
||||
});
|
||||
if (result == null || result == '') return null;
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
_log.e('getPlaylistWithExtension failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get artist info and albums using an extension
|
||||
static Future<Map<String, dynamic>?> getArtistWithExtension(
|
||||
String extensionId,
|
||||
String artistId,
|
||||
) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('getArtistWithExtension', {
|
||||
'extension_id': extensionId,
|
||||
'artist_id': artistId,
|
||||
});
|
||||
if (result == null || result == '') return null;
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
_log.e('getArtistWithExtension failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== EXTENSION POST-PROCESSING ====================
|
||||
|
||||
/// Run post-processing hooks on a file
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: "none"
|
||||
version: 3.0.0+57
|
||||
version: 3.0.1+58
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
Reference in New Issue
Block a user