feat: quality picker with track info, update dialog redesign, finalizing notification fix

- Quality picker now shows track name, artist, and cover
- Tap to expand long track titles (icon only shows when truncated)
- Ripple effect follows rounded corners including drag handle
- Update dialog redesigned with Material Expressive 3 style
- Fixed update notification stuck at 100% after download complete
- Ask before download now enabled by default
- Finalizing notification for multi-progress polling
This commit is contained in:
zarzet
2026-01-03 04:26:19 +07:00
parent 8fcb389bb2
commit b87de1f00a
31 changed files with 2757 additions and 595 deletions
+7
View File
@@ -318,6 +318,13 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
return "", fmt.Errorf("download failed: %w", err)
}
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
}
// Log track info from DoubleDouble (for debugging)
if trackName != "" && artistName != "" {
fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
+20
View File
@@ -70,6 +70,26 @@ func SearchSpotify(query string, limit int) (string, error) {
return string(jsonBytes), nil
}
// SearchSpotifyAll searches for tracks and artists on Spotify
// Returns JSON with tracks and artists arrays
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client := NewSpotifyMetadataClient()
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// CheckAvailability checks track availability on streaming services
// Returns JSON with availability info for Tidal, Qobuz, Amazon
func CheckAvailability(spotifyID, isrc string) (string, error) {
+44
View File
@@ -13,6 +13,7 @@ type DownloadProgress struct {
BytesTotal int64 `json:"bytes_total"`
BytesReceived int64 `json:"bytes_received"`
IsDownloading bool `json:"is_downloading"`
Status string `json:"status"` // "downloading", "finalizing", "completed"
}
// ItemProgress represents progress for a single download item
@@ -22,6 +23,7 @@ type ItemProgress struct {
BytesReceived int64 `json:"bytes_received"`
Progress float64 `json:"progress"` // 0.0 to 1.0
IsDownloading bool `json:"is_downloading"`
Status string `json:"status"` // "downloading", "finalizing", "completed"
}
// MultiProgress holds progress for multiple concurrent downloads
@@ -82,6 +84,7 @@ func StartItemProgress(itemID string) {
BytesReceived: 0,
Progress: 0,
IsDownloading: true,
Status: "downloading",
}
}
@@ -119,6 +122,46 @@ func CompleteItemProgress(itemID string) {
}
}
// SetItemProgress sets progress for an item directly (used to force 100% before embedding)
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
multiMu.Lock()
if item, ok := multiProgress.Items[itemID]; ok {
item.Progress = progress
if bytesReceived > 0 {
item.BytesReceived = bytesReceived
}
if bytesTotal > 0 {
item.BytesTotal = bytesTotal
}
}
multiMu.Unlock()
// Also update legacy progress for backward compatibility
progressMu.Lock()
if progress >= 1.0 {
currentProgress.Progress = 100.0
} else {
currentProgress.Progress = progress * 100.0
}
progressMu.Unlock()
}
// SetItemFinalizing marks an item as finalizing (embedding metadata)
func SetItemFinalizing(itemID string) {
multiMu.Lock()
if item, ok := multiProgress.Items[itemID]; ok {
item.Progress = 1.0
item.Status = "finalizing"
}
multiMu.Unlock()
// Also update legacy progress
progressMu.Lock()
currentProgress.Progress = 100.0
currentProgress.Status = "finalizing"
progressMu.Unlock()
}
// RemoveItemProgress removes progress tracking for an item
func RemoveItemProgress(itemID string) {
multiMu.Lock()
@@ -161,6 +204,7 @@ func SetCurrentFile(filename string) {
currentProgress.Progress = 0
currentProgress.CurrentFile = filename
currentProgress.IsDownloading = true
currentProgress.Status = "downloading"
}
// ResetProgress resets the download progress
+7
View File
@@ -385,6 +385,13 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
return "", fmt.Errorf("download failed: %w", err)
}
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
}
// Embed metadata
metadata := Metadata{
Title: req.TrackName,
+171 -4
View File
@@ -24,10 +24,25 @@ const (
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
searchBaseURL = "https://api.spotify.com/v1/search"
// Cache TTL settings
artistCacheTTL = 10 * time.Minute
searchCacheTTL = 5 * time.Minute
albumCacheTTL = 10 * time.Minute
)
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
// cacheEntry holds cached data with expiration
type cacheEntry struct {
data interface{}
expiresAt time.Time
}
func (e *cacheEntry) isExpired() bool {
return time.Now().After(e.expiresAt)
}
// SpotifyMetadataClient handles Spotify API interactions
type SpotifyMetadataClient struct {
httpClient *http.Client
@@ -39,6 +54,12 @@ type SpotifyMetadataClient struct {
rng *rand.Rand
rngMu sync.Mutex
userAgent string
// Caches to reduce API calls
artistCache map[string]*cacheEntry // key: artistID
searchCache map[string]*cacheEntry // key: query+type
albumCache map[string]*cacheEntry // key: albumID
cacheMu sync.RWMutex
}
// NewSpotifyMetadataClient creates a new Spotify client
@@ -65,6 +86,9 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient {
clientID: clientID,
clientSecret: clientSecret,
rng: rand.New(src),
artistCache: make(map[string]*cacheEntry),
searchCache: make(map[string]*cacheEntry),
albumCache: make(map[string]*cacheEntry),
}
c.userAgent = c.randomUserAgent()
return c
@@ -176,6 +200,21 @@ type SearchResult struct {
Total int `json:"total"`
}
// SearchArtistResult represents an artist in search results
type SearchArtistResult struct {
ID string `json:"id"`
Name string `json:"name"`
Images string `json:"images"`
Followers int `json:"followers"`
Popularity int `json:"popularity"`
}
// SearchAllResult represents combined search results for tracks and artists
type SearchAllResult struct {
Tracks []TrackMetadata `json:"tracks"`
Artists []SearchArtistResult `json:"artists"`
}
type spotifyURI struct {
Type string
ID string
@@ -299,6 +338,98 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
return result, nil
}
// SearchAll searches for tracks and artists on Spotify
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
// Create cache key
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
// Check cache first
c.cacheMu.RLock()
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
return entry.data.(*SearchAllResult), nil
}
c.cacheMu.RUnlock()
token, err := c.getAccessToken(ctx)
if err != nil {
return nil, err
}
searchURL := fmt.Sprintf("%s?q=%s&type=track,artist&limit=%d", searchBaseURL, url.QueryEscape(query), trackLimit)
var response struct {
Tracks struct {
Items []trackFull `json:"items"`
} `json:"tracks"`
Artists struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Images []image `json:"images"`
Followers struct {
Total int `json:"total"`
} `json:"followers"`
Popularity int `json:"popularity"`
} `json:"items"`
} `json:"artists"`
}
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
return nil, err
}
result := &SearchAllResult{
Tracks: make([]TrackMetadata, 0, len(response.Tracks.Items)),
Artists: make([]SearchArtistResult, 0, len(response.Artists.Items)),
}
for _, track := range response.Tracks.Items {
result.Tracks = append(result.Tracks, TrackMetadata{
SpotifyID: track.ID,
Artists: joinArtists(track.Artists),
Name: track.Name,
AlbumName: track.Album.Name,
AlbumArtist: joinArtists(track.Album.Artists),
DurationMS: track.DurationMS,
Images: firstImageURL(track.Album.Images),
ReleaseDate: track.Album.ReleaseDate,
TrackNumber: track.TrackNumber,
TotalTracks: track.Album.TotalTracks,
DiscNumber: track.DiscNumber,
ExternalURL: track.ExternalURL.Spotify,
ISRC: track.ExternalID.ISRC,
})
}
// Limit artists to artistLimit
artistCount := len(response.Artists.Items)
if artistCount > artistLimit {
artistCount = artistLimit
}
for i := 0; i < artistCount; i++ {
artist := response.Artists.Items[i]
result.Artists = append(result.Artists, SearchArtistResult{
ID: artist.ID,
Name: artist.Name,
Images: firstImageURL(artist.Images),
Followers: artist.Followers.Total,
Popularity: artist.Popularity,
})
}
// Store in cache
c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(searchCacheTTL),
}
c.cacheMu.Unlock()
return result, nil
}
func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token string) (*TrackResponse, error) {
var data trackFull
if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil {
@@ -325,6 +456,14 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token s
}
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
// Check cache first
c.cacheMu.RLock()
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
return entry.data.(*AlbumResponsePayload), nil
}
c.cacheMu.RUnlock()
var data struct {
Name string `json:"name"`
ReleaseDate string `json:"release_date"`
@@ -380,10 +519,20 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
})
}
return &AlbumResponsePayload{
result := &AlbumResponsePayload{
AlbumInfo: info,
TrackList: tracks,
}, nil
}
// Store in cache
c.cacheMu.Lock()
c.albumCache[albumID] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(albumCacheTTL),
}
c.cacheMu.Unlock()
return result, nil
}
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
@@ -442,6 +591,14 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
}
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
// Check cache first
c.cacheMu.RLock()
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
c.cacheMu.RUnlock()
return entry.data.(*ArtistResponsePayload), nil
}
c.cacheMu.RUnlock()
// Fetch artist info
var artistData struct {
ID string `json:"id"`
@@ -517,10 +674,20 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
}
}
return &ArtistResponsePayload{
result := &ArtistResponsePayload{
ArtistInfo: artistInfo,
Albums: albums,
}, nil
}
// Store in cache
c.cacheMu.Lock()
c.artistCache[artistID] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(artistCacheTTL),
}
c.cacheMu.Unlock()
return result, nil
}
func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string {
+7
View File
@@ -905,6 +905,13 @@ func downloadFromTidal(req DownloadRequest) (string, error) {
return "", fmt.Errorf("download failed: %w", err)
}
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
}
// Check if file was saved as M4A (DASH stream) instead of FLAC
// downloadFromManifest saves DASH streams as .m4a
actualOutputPath := outputPath