mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
feat: download cancellation, duplicate detection, progress tracking improvements
This commit is contained in:
parent
1a90887465
commit
b193bc0b8f
19 changed files with 428 additions and 37 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -1,7 +1,5 @@
|
|||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [3.1.0] - 2026-01-19
|
||||
|
||||
### Added
|
||||
|
|
@ -22,13 +20,13 @@
|
|||
- 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
|
||||
- **Download Cancel**: Canceling a download now stops in-flight built-in provider downloads (Tidal/Qobuz/Amazon) and clears backend progress tracking.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
@ -49,6 +47,13 @@
|
|||
- 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
|
||||
- Fixed cancel action briefly resuming downloads in the queue UI after ~1 second.
|
||||
- Fixed cancelled downloads being marked as failed when the backend returns after cancellation.
|
||||
- Fixed cancel triggering provider fallback (cancel now stops the download flow immediately).
|
||||
- Fixed stale ISRC cache returning deleted files after cancel.
|
||||
- Fixed search results mixing extension and built-in artists when using default provider.
|
||||
- Fixed audio files opening with non-music apps by passing audio MIME type on open.
|
||||
- Fixed album artist showing null/blank by normalizing empty metadata and using artist fallback for tags.
|
||||
- **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
|
||||
|
|
|
|||
|
|
@ -117,6 +117,13 @@ class MainActivity: FlutterActivity() {
|
|||
}
|
||||
result.success(null)
|
||||
}
|
||||
"cancelDownload" -> {
|
||||
val itemId = call.argument<String>("item_id") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.cancelDownload(itemId)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"setDownloadDirectory" -> {
|
||||
val path = call.argument<String>("path") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -346,13 +348,21 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
|
|||
|
||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Initialize item progress (required for all downloads)
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
|
@ -361,6 +371,9 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
|
@ -400,6 +413,9 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||
// Check for any errors
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
|
|
@ -527,6 +543,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||
|
||||
// Download audio file with item ID for progress tracking
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return AmazonDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
|
|
|
|||
79
go_backend/cancel.go
Normal file
79
go_backend/cancel.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ErrDownloadCancelled is returned when a download is cancelled by the user.
|
||||
var ErrDownloadCancelled = errors.New("download cancelled")
|
||||
|
||||
type cancelEntry struct {
|
||||
cancel context.CancelFunc
|
||||
canceled bool
|
||||
}
|
||||
|
||||
var (
|
||||
cancelMu sync.Mutex
|
||||
cancelMap = make(map[string]*cancelEntry)
|
||||
)
|
||||
|
||||
func initDownloadCancel(itemID string) context.Context {
|
||||
if itemID == "" {
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
defer cancelMu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancelMap[itemID] = &cancelEntry{
|
||||
cancel: cancel,
|
||||
canceled: false,
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func cancelDownload(itemID string) {
|
||||
if itemID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
entry, ok := cancelMap[itemID]
|
||||
if ok {
|
||||
entry.canceled = true
|
||||
if entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
} else {
|
||||
cancelMap[itemID] = &cancelEntry{canceled: true}
|
||||
}
|
||||
cancelMu.Unlock()
|
||||
|
||||
// Hide progress for cancelled items.
|
||||
RemoveItemProgress(itemID)
|
||||
}
|
||||
|
||||
func isDownloadCancelled(itemID string) bool {
|
||||
if itemID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
entry, ok := cancelMap[itemID]
|
||||
canceled := ok && entry.canceled
|
||||
cancelMu.Unlock()
|
||||
return canceled
|
||||
}
|
||||
|
||||
func clearDownloadCancel(itemID string) {
|
||||
if itemID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
delete(cancelMap, itemID)
|
||||
cancelMu.Unlock()
|
||||
}
|
||||
|
|
@ -103,6 +103,18 @@ func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
|||
return path, exists
|
||||
}
|
||||
|
||||
// remove deletes an ISRC entry from the index (internal use)
|
||||
func (idx *ISRCIndex) remove(isrc string) {
|
||||
if isrc == "" {
|
||||
return
|
||||
}
|
||||
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
|
||||
delete(idx.index, strings.ToUpper(isrc))
|
||||
}
|
||||
|
||||
// Lookup checks if an ISRC exists in the index (gomobile compatible)
|
||||
// Returns filepath if found, empty string if not found
|
||||
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
||||
|
|
@ -138,7 +150,18 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
|||
|
||||
// Use index for fast lookup
|
||||
idx := GetISRCIndex(outputDir)
|
||||
return idx.lookup(isrc)
|
||||
filePath, exists := idx.lookup(isrc)
|
||||
if !exists {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if !CheckFileExists(filePath) {
|
||||
// Stale index entry; remove it and return not found.
|
||||
idx.remove(isrc)
|
||||
return "", false
|
||||
}
|
||||
|
||||
return filePath, true
|
||||
}
|
||||
|
||||
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ package gobackend
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -405,7 +406,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||
DiscNumber: tidalResult.DiscNumber,
|
||||
ISRC: tidalResult.ISRC,
|
||||
}
|
||||
} else {
|
||||
} else if !errors.Is(tidalErr, ErrDownloadCancelled) {
|
||||
GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
|
||||
}
|
||||
err = tidalErr
|
||||
|
|
@ -424,7 +425,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||
DiscNumber: qobuzResult.DiscNumber,
|
||||
ISRC: qobuzResult.ISRC,
|
||||
}
|
||||
} else {
|
||||
} else if !errors.Is(qobuzErr, ErrDownloadCancelled) {
|
||||
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
||||
}
|
||||
err = qobuzErr
|
||||
|
|
@ -443,12 +444,16 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||
DiscNumber: amazonResult.DiscNumber,
|
||||
ISRC: amazonResult.ISRC,
|
||||
}
|
||||
} else {
|
||||
} else if !errors.Is(amazonErr, ErrDownloadCancelled) {
|
||||
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
||||
}
|
||||
err = amazonErr
|
||||
}
|
||||
|
||||
if err != nil && errors.Is(err, ErrDownloadCancelled) {
|
||||
return errorResponse("Download cancelled")
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
// Check if file already exists
|
||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||
|
|
@ -542,6 +547,11 @@ func ClearItemProgress(itemID string) {
|
|||
RemoveItemProgress(itemID)
|
||||
}
|
||||
|
||||
// CancelDownload cancels an in-progress download for the given item.
|
||||
func CancelDownload(itemID string) {
|
||||
cancelDownload(itemID)
|
||||
}
|
||||
|
||||
// CleanupConnections closes idle HTTP connections
|
||||
// Call this periodically during large batch downloads to prevent TCP exhaustion
|
||||
func CleanupConnections() {
|
||||
|
|
@ -1031,6 +1041,8 @@ func errorResponse(msg string) (string, error) {
|
|||
strings.Contains(lowerMsg, "try using vpn") ||
|
||||
strings.Contains(lowerMsg, "change dns") {
|
||||
errorType = "isp_blocked"
|
||||
} else if strings.Contains(lowerMsg, "cancel") {
|
||||
errorType = "cancelled"
|
||||
} else if strings.Contains(lowerMsg, "permission") ||
|
||||
strings.Contains(lowerMsg, "operation not permitted") ||
|
||||
strings.Contains(lowerMsg, "access denied") ||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package gobackend
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
|
@ -835,6 +836,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||
}
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Download cancelled",
|
||||
ErrorType: "cancelled",
|
||||
Service: req.Source,
|
||||
}, nil
|
||||
}
|
||||
lastErr = err
|
||||
} else if result.ErrorMessage != "" {
|
||||
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
||||
|
|
@ -879,6 +888,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||
return result, nil
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Download cancelled",
|
||||
ErrorType: "cancelled",
|
||||
Service: providerID,
|
||||
}, nil
|
||||
}
|
||||
lastErr = err
|
||||
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err)
|
||||
}
|
||||
|
|
@ -964,6 +981,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||
}
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Download cancelled",
|
||||
ErrorType: "cancelled",
|
||||
Service: providerID,
|
||||
}, nil
|
||||
}
|
||||
lastErr = err
|
||||
} else if result.ErrorMessage != "" {
|
||||
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
||||
|
|
|
|||
|
|
@ -240,6 +240,9 @@ func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID str
|
|||
|
||||
// Write implements io.Writer with threshold-based progress updates and speed tracking
|
||||
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||
if pw.itemID != "" && isDownloadCancelled(pw.itemID) {
|
||||
return 0, ErrDownloadCancelled
|
||||
}
|
||||
n, err := pw.writer.Write(p)
|
||||
if err != nil {
|
||||
return n, err
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -864,19 +866,30 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||
|
||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Initialize item progress (required for all downloads)
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
|
@ -916,6 +929,9 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||
// Check for any errors
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
|
|
@ -1095,6 +1111,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||
|
||||
// Download audio file with item ID for progress tracking
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return QobuzDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -886,29 +888,45 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||
|
||||
// DownloadFile downloads a file from URL with progress tracking
|
||||
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Handle manifest-based download (DASH/BTS)
|
||||
if strings.HasPrefix(downloadURL, "MANIFEST:") {
|
||||
// Initialize progress tracking for manifest downloads
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
|
||||
}
|
||||
|
||||
// Initialize item progress for direct downloads
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := DoRequestWithUserAgent(t.client, req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
|
@ -948,6 +966,9 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||
// Check for any errors
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
|
|
@ -968,7 +989,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||
return nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID string) error {
|
||||
func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath, itemID string) error {
|
||||
fmt.Println("[Tidal] Parsing manifest...")
|
||||
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
||||
if err != nil {
|
||||
|
|
@ -987,7 +1008,11 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
|
||||
// Note: Progress tracking is initialized by the caller (DownloadFile)
|
||||
|
||||
req, err := http.NewRequest("GET", directURL, nil)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", directURL, nil)
|
||||
if err != nil {
|
||||
GoLog("[Tidal] BTS request creation failed: %v\n", err)
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
|
|
@ -995,6 +1020,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
GoLog("[Tidal] BTS download failed: %v\n", err)
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
|
|
@ -1030,6 +1058,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
|
|
@ -1062,10 +1093,25 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||
|
||||
// Download initialization segment
|
||||
GoLog("[Tidal] Downloading init segment...\n")
|
||||
resp, err := client.Get(initURL)
|
||||
if isDownloadCancelled(itemID) {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", initURL, nil)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
GoLog("[Tidal] Init segment request failed: %v\n", err)
|
||||
return fmt.Errorf("failed to create init segment request: %w", err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
GoLog("[Tidal] Init segment download failed: %v\n", err)
|
||||
return fmt.Errorf("failed to download init segment: %w", err)
|
||||
}
|
||||
|
|
@ -1081,6 +1127,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
GoLog("[Tidal] Init segment write failed: %v\n", err)
|
||||
return fmt.Errorf("failed to write init segment: %w", err)
|
||||
}
|
||||
|
|
@ -1088,6 +1137,12 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||
// Download media segments with progress
|
||||
totalSegments := len(mediaURLs)
|
||||
for i, mediaURL := range mediaURLs {
|
||||
if isDownloadCancelled(itemID) {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
if i%10 == 0 || i == totalSegments-1 {
|
||||
GoLog("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments)
|
||||
}
|
||||
|
|
@ -1098,10 +1153,20 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||
SetItemProgress(itemID, progress, 0, 0)
|
||||
}
|
||||
|
||||
resp, err := client.Get(mediaURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", mediaURL, nil)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
GoLog("[Tidal] Segment %d request failed: %v\n", i+1, err)
|
||||
return fmt.Errorf("failed to create segment %d request: %w", i+1, err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
GoLog("[Tidal] Segment %d download failed: %v\n", i+1, err)
|
||||
return fmt.Errorf("failed to download segment %d: %w", i+1, err)
|
||||
}
|
||||
|
|
@ -1117,6 +1182,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
GoLog("[Tidal] Segment %d write failed: %v\n", i+1, err)
|
||||
return fmt.Errorf("failed to write segment %d: %w", i+1, err)
|
||||
}
|
||||
|
|
@ -1686,6 +1754,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||
}())
|
||||
|
||||
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return TidalDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
GoLog("[Tidal] Download failed with error: %v\n", err)
|
||||
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,6 +120,12 @@ import Gobackend // Import Go framework
|
|||
let itemId = args["item_id"] as! String
|
||||
GobackendClearItemProgress(itemId)
|
||||
return nil
|
||||
|
||||
case "cancelDownload":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let itemId = args["item_id"] as! String
|
||||
GobackendCancelDownload(itemId)
|
||||
return nil
|
||||
|
||||
case "setDownloadDirectory":
|
||||
let args = call.arguments as! [String: Any]
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@ import 'package:spotiflac_android/utils/logger.dart';
|
|||
final _log = AppLogger('DownloadQueue');
|
||||
final _historyLog = AppLogger('DownloadHistory');
|
||||
|
||||
String? _normalizeOptionalString(String? value) {
|
||||
if (value == null) return null;
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return null;
|
||||
if (trimmed.toLowerCase() == 'null') return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Download History Item model
|
||||
class DownloadHistoryItem {
|
||||
final String id;
|
||||
|
|
@ -89,7 +97,7 @@ class DownloadHistoryItem {
|
|||
trackName: json['trackName'] as String,
|
||||
artistName: json['artistName'] as String,
|
||||
albumName: json['albumName'] as String,
|
||||
albumArtist: json['albumArtist'] as String?,
|
||||
albumArtist: _normalizeOptionalString(json['albumArtist'] as String?),
|
||||
coverUrl: json['coverUrl'] as String?,
|
||||
filePath: json['filePath'] as String,
|
||||
service: json['service'] as String,
|
||||
|
|
@ -492,6 +500,20 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
|
||||
for (final entry in items.entries) {
|
||||
final itemId = entry.key;
|
||||
final localItem = state.items
|
||||
.where((i) => i.id == itemId)
|
||||
.firstOrNull;
|
||||
if (localItem == null) {
|
||||
continue;
|
||||
}
|
||||
if (localItem.status == DownloadStatus.skipped) {
|
||||
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
|
||||
continue;
|
||||
}
|
||||
if (localItem.status == DownloadStatus.completed ||
|
||||
localItem.status == DownloadStatus.failed) {
|
||||
continue;
|
||||
}
|
||||
final itemProgress = entry.value as Map<String, dynamic>;
|
||||
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
|
||||
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
|
||||
|
|
@ -671,6 +693,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
/// Build output directory based on folder organization setting and separateSingles
|
||||
Future<String> _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false, String albumFolderStructure = 'artist_album'}) async {
|
||||
String baseDir = state.outputDir;
|
||||
final albumArtist = _normalizeOptionalString(track.albumArtist) ?? track.artistName;
|
||||
|
||||
// If separateSingles is enabled, use Albums/Singles structure
|
||||
if (separateSingles) {
|
||||
|
|
@ -688,7 +711,7 @@ 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 artistName = _sanitizeFolderName(albumArtist);
|
||||
final year = _extractYear(track.releaseDate);
|
||||
String albumPath;
|
||||
|
||||
|
|
@ -729,7 +752,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
String subPath = '';
|
||||
switch (folderOrganization) {
|
||||
case 'artist':
|
||||
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
|
||||
final artistName = _sanitizeFolderName(albumArtist);
|
||||
subPath = artistName;
|
||||
break;
|
||||
case 'album':
|
||||
|
|
@ -737,7 +760,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
subPath = albumName;
|
||||
break;
|
||||
case 'artist_album':
|
||||
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
|
||||
final artistName = _sanitizeFolderName(albumArtist);
|
||||
final albumName = _sanitizeFolderName(track.albumName);
|
||||
subPath = '$artistName${Platform.pathSeparator}$albumName';
|
||||
break;
|
||||
|
|
@ -874,6 +897,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
}
|
||||
|
||||
void updateProgress(String id, double progress, {double? speedMBps}) {
|
||||
final item = state.items.where((i) => i.id == id).firstOrNull;
|
||||
if (item == null ||
|
||||
item.status == DownloadStatus.skipped ||
|
||||
item.status == DownloadStatus.completed ||
|
||||
item.status == DownloadStatus.failed) {
|
||||
return;
|
||||
}
|
||||
updateItemStatus(
|
||||
id,
|
||||
DownloadStatus.downloading,
|
||||
|
|
@ -884,6 +914,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
|
||||
void cancelItem(String id) {
|
||||
updateItemStatus(id, DownloadStatus.skipped);
|
||||
PlatformBridge.cancelDownload(id).catchError((_) {});
|
||||
PlatformBridge.clearItemProgress(id).catchError((_) {});
|
||||
}
|
||||
|
||||
void clearCompleted() {
|
||||
|
|
@ -1002,7 +1034,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
'title': track.name,
|
||||
'artist': track.artistName,
|
||||
'album': track.albumName,
|
||||
'album_artist': track.albumArtist ?? track.artistName,
|
||||
'album_artist': _normalizeOptionalString(track.albumArtist) ?? track.artistName,
|
||||
'track_number': track.trackNumber ?? 1,
|
||||
'disc_number': track.discNumber ?? 1,
|
||||
'isrc': track.isrc ?? '',
|
||||
|
|
@ -1105,9 +1137,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
'ALBUM': track.albumName,
|
||||
};
|
||||
|
||||
if (track.albumArtist != null) {
|
||||
metadata['ALBUMARTIST'] = track.albumArtist!;
|
||||
}
|
||||
final albumArtist = _normalizeOptionalString(track.albumArtist) ??
|
||||
track.artistName;
|
||||
metadata['ALBUMARTIST'] = albumArtist;
|
||||
|
||||
if (track.trackNumber != null) {
|
||||
metadata['TRACKNUMBER'] = track.trackNumber.toString();
|
||||
|
|
@ -1415,6 +1447,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
_log.d('Processing: ${item.track.name} by ${item.track.artistName}');
|
||||
_log.d('Cover URL: ${item.track.coverUrl}');
|
||||
|
||||
final currentItem = state.items.firstWhere(
|
||||
(i) => i.id == item.id,
|
||||
orElse: () => item,
|
||||
);
|
||||
if (currentItem.status == DownloadStatus.skipped) {
|
||||
_log.i('Download was cancelled before start, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set currentDownload for UI reference
|
||||
state = state.copyWith(currentDownload: item);
|
||||
|
||||
|
|
@ -1505,6 +1546,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
// Log cover URL for debugging CSV import issues
|
||||
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
|
||||
|
||||
final normalizedAlbumArtist =
|
||||
_normalizeOptionalString(trackToDownload.albumArtist);
|
||||
|
||||
final outputDir = await _buildOutputDir(
|
||||
trackToDownload,
|
||||
settings.folderOrganization,
|
||||
|
|
@ -1535,7 +1579,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
trackName: trackToDownload.name,
|
||||
artistName: trackToDownload.artistName,
|
||||
albumName: trackToDownload.albumName,
|
||||
albumArtist: trackToDownload.albumArtist,
|
||||
albumArtist: normalizedAlbumArtist,
|
||||
coverUrl: trackToDownload.coverUrl,
|
||||
outputDir: outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
|
|
@ -1559,7 +1603,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
trackName: trackToDownload.name,
|
||||
artistName: trackToDownload.artistName,
|
||||
albumName: trackToDownload.albumName,
|
||||
albumArtist: trackToDownload.albumArtist,
|
||||
albumArtist: normalizedAlbumArtist,
|
||||
coverUrl: trackToDownload.coverUrl,
|
||||
outputDir: outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
|
|
@ -1580,7 +1624,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
trackName: trackToDownload.name,
|
||||
artistName: trackToDownload.artistName,
|
||||
albumName: trackToDownload.albumName,
|
||||
albumArtist: trackToDownload.albumArtist,
|
||||
albumArtist: normalizedAlbumArtist,
|
||||
coverUrl: trackToDownload.coverUrl,
|
||||
outputDir: outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
|
|
@ -1645,7 +1689,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
_log.i('Actual quality: $actualQuality');
|
||||
}
|
||||
|
||||
// M4A files from Tidal DASH streams - try to convert to FLAC
|
||||
// M4A files from Tidal DASH streams - try to convert to FLAC
|
||||
if (filePath != null && filePath.endsWith('.m4a')) {
|
||||
_log.d(
|
||||
|
|
@ -1715,7 +1758,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
name: trackToDownload.name,
|
||||
artistName: trackToDownload.artistName,
|
||||
albumName: backendAlbum ?? trackToDownload.albumName,
|
||||
albumArtist: trackToDownload.albumArtist,
|
||||
albumArtist: normalizedAlbumArtist,
|
||||
coverUrl: trackToDownload.coverUrl,
|
||||
duration: trackToDownload.duration,
|
||||
isrc: trackToDownload.isrc,
|
||||
|
|
@ -1806,6 +1849,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
// Log cover URL for debugging
|
||||
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
|
||||
|
||||
final historyAlbumArtist =
|
||||
(normalizedAlbumArtist != null &&
|
||||
normalizedAlbumArtist != trackToDownload.artistName)
|
||||
? normalizedAlbumArtist
|
||||
: null;
|
||||
|
||||
ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.addToHistory(
|
||||
|
|
@ -1820,7 +1869,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
albumName: (backendAlbum != null && backendAlbum.isNotEmpty)
|
||||
? backendAlbum
|
||||
: trackToDownload.albumName,
|
||||
albumArtist: trackToDownload.albumArtist,
|
||||
albumArtist: historyAlbumArtist,
|
||||
coverUrl: trackToDownload.coverUrl,
|
||||
filePath: filePath,
|
||||
service: result['service'] as String? ?? item.service,
|
||||
|
|
@ -1849,8 +1898,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
removeItem(item.id);
|
||||
}
|
||||
} else {
|
||||
final itemAfterFailure = state.items.firstWhere(
|
||||
(i) => i.id == item.id,
|
||||
orElse: () => item,
|
||||
);
|
||||
if (itemAfterFailure.status == DownloadStatus.skipped) {
|
||||
_log.i('Download was cancelled, skipping error handling');
|
||||
return;
|
||||
}
|
||||
|
||||
final errorMsg = result['error'] as String? ?? 'Download failed';
|
||||
final errorTypeStr = result['error_type'] as String? ?? 'unknown';
|
||||
if (errorTypeStr == 'cancelled') {
|
||||
_log.i('Download was cancelled by backend, skipping error handling');
|
||||
updateItemStatus(item.id, DownloadStatus.skipped);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert error type string to enum
|
||||
DownloadErrorType errorType;
|
||||
|
|
@ -1894,6 +1957,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
}
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
final itemAfterError = state.items.firstWhere(
|
||||
(i) => i.id == item.id,
|
||||
orElse: () => item,
|
||||
);
|
||||
if (itemAfterError.status == DownloadStatus.skipped) {
|
||||
_log.i('Download was cancelled, skipping error handling');
|
||||
return;
|
||||
}
|
||||
|
||||
_log.e('Exception: $e', e, stackTrace);
|
||||
|
||||
String errorMsg = e.toString();
|
||||
|
|
|
|||
|
|
@ -277,12 +277,19 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||
final hasActiveMetadataExtensions = extensionState.extensions.any(
|
||||
(e) => e.enabled && e.hasMetadataProvider,
|
||||
);
|
||||
final useExtensions = settings.useExtensionProviders && hasActiveMetadataExtensions;
|
||||
final searchProvider = settings.searchProvider;
|
||||
final useExtensions =
|
||||
settings.useExtensionProviders &&
|
||||
hasActiveMetadataExtensions &&
|
||||
searchProvider != null &&
|
||||
searchProvider.isNotEmpty;
|
||||
|
||||
// Use Deezer or Spotify based on settings
|
||||
final source = metadataSource ?? 'deezer';
|
||||
|
||||
_log.i('Search started: source=$source, query="$query", useExtensions=$useExtensions');
|
||||
_log.i(
|
||||
'Search started: source=$source, query="$query", useExtensions=$useExtensions',
|
||||
);
|
||||
|
||||
Map<String, dynamic> results;
|
||||
List<Track> extensionTracks = [];
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
|
||||
|
|
@ -132,7 +133,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
|
||||
Future<void> _openFile(String filePath) async {
|
||||
try {
|
||||
await OpenFilex.open(filePath);
|
||||
final mimeType = audioMimeTypeForPath(filePath);
|
||||
await OpenFilex.open(filePath, type: mimeType);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
|
|
@ -172,7 +173,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||
Future<void> _openFile(String filePath) async {
|
||||
final cleanPath = _cleanFilePath(filePath);
|
||||
try {
|
||||
await OpenFilex.open(cleanPath);
|
||||
final mimeType = audioMimeTypeForPath(cleanPath);
|
||||
await OpenFilex.open(cleanPath, type: mimeType);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
|
|
|||
|
|
@ -98,9 +98,9 @@ class AboutPage extends StatelessWidget {
|
|||
child: SettingsGroup(
|
||||
children: [
|
||||
_ContributorItem(
|
||||
name: 'uimaxbai',
|
||||
name: 'binimum',
|
||||
description: 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!',
|
||||
githubUsername: 'uimaxbai',
|
||||
githubUsername: 'binimum',
|
||||
showDivider: true,
|
||||
),
|
||||
_ContributorItem(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
|
|
@ -27,6 +28,14 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
bool _lyricsLoading = false;
|
||||
String? _lyricsError;
|
||||
|
||||
String? _normalizeOptionalString(String? value) {
|
||||
if (value == null) return null;
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return null;
|
||||
if (trimmed.toLowerCase() == 'null') return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -68,7 +77,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
String get trackName => item.trackName;
|
||||
String get artistName => item.artistName;
|
||||
String get albumName => item.albumName;
|
||||
String? get albumArtist => item.albumArtist;
|
||||
String? get albumArtist => _normalizeOptionalString(item.albumArtist);
|
||||
int? get trackNumber => item.trackNumber;
|
||||
int? get discNumber => item.discNumber;
|
||||
String? get releaseDate => item.releaseDate;
|
||||
|
|
@ -970,7 +979,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
|
||||
Future<void> _openFile(BuildContext context, String filePath) async {
|
||||
try {
|
||||
final result = await OpenFilex.open(filePath);
|
||||
final mimeType = audioMimeTypeForPath(filePath);
|
||||
final result = await OpenFilex.open(filePath, type: mimeType);
|
||||
if (result.type != ResultType.done && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Cannot open: ${result.message}')),
|
||||
|
|
|
|||
|
|
@ -199,6 +199,11 @@ class PlatformBridge {
|
|||
await _channel.invokeMethod('clearItemProgress', {'item_id': itemId});
|
||||
}
|
||||
|
||||
/// Cancel an in-progress download
|
||||
static Future<void> cancelDownload(String itemId) async {
|
||||
await _channel.invokeMethod('cancelDownload', {'item_id': itemId});
|
||||
}
|
||||
|
||||
/// Set download directory
|
||||
static Future<void> setDownloadDirectory(String path) async {
|
||||
await _channel.invokeMethod('setDownloadDirectory', {'path': path});
|
||||
|
|
|
|||
24
lib/utils/mime_utils.dart
Normal file
24
lib/utils/mime_utils.dart
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
String audioMimeTypeForPath(String filePath) {
|
||||
final dotIndex = filePath.lastIndexOf('.');
|
||||
if (dotIndex == -1 || dotIndex == filePath.length - 1) {
|
||||
return 'audio/*';
|
||||
}
|
||||
|
||||
final ext = filePath.substring(dotIndex + 1).toLowerCase();
|
||||
switch (ext) {
|
||||
case 'flac':
|
||||
return 'audio/flac';
|
||||
case 'm4a':
|
||||
return 'audio/mp4';
|
||||
case 'mp3':
|
||||
return 'audio/mpeg';
|
||||
case 'ogg':
|
||||
return 'audio/ogg';
|
||||
case 'wav':
|
||||
return 'audio/wav';
|
||||
case 'aac':
|
||||
return 'audio/aac';
|
||||
default:
|
||||
return 'audio/*';
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue