mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 11:18:04 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 525f2fd0cd | |||
| 3e841cef06 | |||
| a8527df80a |
@@ -1,5 +1,24 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [2.0.6] - 2026-01-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Duration Display Bug**: Fixed duration showing incorrect values like "4135:53" instead of "4:14"
|
||||||
|
- `duration_ms` (milliseconds) was being stored directly without conversion to seconds
|
||||||
|
- Now properly converts milliseconds to seconds before display
|
||||||
|
- **Audio Quality from File**: Quality info (bit depth/sample rate) now read from actual FLAC file instead of trusting API
|
||||||
|
- More accurate quality display for all services (Tidal, Qobuz, Amazon)
|
||||||
|
- Also reads quality from existing files when skipping duplicates
|
||||||
|
- **Artist Verification for Downloads**: Added artist name verification to prevent downloading wrong tracks
|
||||||
|
- Verifies artist matches between Spotify metadata and streaming service
|
||||||
|
- Handles different scripts (Japanese/Chinese vs Latin) as same artist with different transliteration
|
||||||
|
- Applied to Tidal, Qobuz, and Amazon downloads
|
||||||
|
- **Metadata Case-Sensitivity**: Fixed FLAC metadata not being properly overwritten when downloaded file has lowercase tags
|
||||||
|
- Now uses case-insensitive comparison when replacing existing Vorbis comments
|
||||||
|
- Fixes issue where Amazon downloads could have duplicate metadata tags
|
||||||
|
- **Settings Navigation Freeze**: Fixed app freezing when navigating back from settings sub-menus on some devices
|
||||||
|
- Added proper PopScope handling for predictive back gesture on Android 14+
|
||||||
|
|
||||||
## [2.0.5] - 2026-01-05
|
## [2.0.5] - 2026-01-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **Active Development Notice**: This app is under heavy development. New builds may be pushed multiple times daily. If frequent update notifications are annoying, tap "Don't remind" when the update dialog appears, or disable update checks in Settings.
|
|
||||||
|
|
||||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|||||||
@@ -36,6 +36,63 @@ type DoubleDoubleStatusResponse struct {
|
|||||||
} `json:"current"`
|
} `json:"current"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// amazonArtistsMatch checks if the artist names are similar enough
|
||||||
|
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||||
|
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||||
|
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if normExpected == normFound {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if one contains the other
|
||||||
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check first artist (before comma or feat)
|
||||||
|
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||||
|
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||||
|
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||||
|
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||||
|
|
||||||
|
foundFirst := strings.Split(normFound, ",")[0]
|
||||||
|
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||||
|
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||||
|
foundFirst = strings.TrimSpace(foundFirst)
|
||||||
|
|
||||||
|
if expectedFirst == foundFirst {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first artist is contained in the other
|
||||||
|
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||||
|
// assume they're the same artist with different transliteration
|
||||||
|
expectedASCII := amazonIsASCIIString(expectedArtist)
|
||||||
|
foundASCII := amazonIsASCIIString(foundArtist)
|
||||||
|
if expectedASCII != foundASCII {
|
||||||
|
fmt.Printf("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// amazonIsASCIIString checks if a string contains only ASCII characters
|
||||||
|
func amazonIsASCIIString(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > 127 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service
|
// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
return &AmazonDownloader{
|
return &AmazonDownloader{
|
||||||
@@ -295,6 +352,15 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify artist matches
|
||||||
|
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
|
||||||
|
fmt.Printf("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
|
||||||
|
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log match found
|
||||||
|
fmt.Printf("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
||||||
|
|
||||||
// Build filename using Spotify metadata (more accurate)
|
// Build filename using Spotify metadata (more accurate)
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
|
|||||||
+49
-10
@@ -5,6 +5,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -216,17 +217,36 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
|
actualPath := result.FilePath[7:]
|
||||||
|
// Read actual quality from existing file
|
||||||
|
quality, qErr := GetAudioQuality(actualPath)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
}
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "File already exists",
|
Message: "File already exists",
|
||||||
FilePath: result.FilePath[7:],
|
FilePath: actualPath,
|
||||||
AlreadyExists: true,
|
AlreadyExists: true,
|
||||||
Service: req.Service,
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: req.Service,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read actual quality from downloaded file (more accurate than API)
|
||||||
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
|
||||||
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Download complete",
|
Message: "Download complete",
|
||||||
@@ -314,17 +334,36 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
|
actualPath := result.FilePath[7:]
|
||||||
|
// Read actual quality from existing file
|
||||||
|
quality, qErr := GetAudioQuality(actualPath)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
}
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "File already exists",
|
Message: "File already exists",
|
||||||
FilePath: result.FilePath[7:],
|
FilePath: actualPath,
|
||||||
AlreadyExists: true,
|
AlreadyExists: true,
|
||||||
Service: service,
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: service,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read actual quality from downloaded file (more accurate than API)
|
||||||
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
|
||||||
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Downloaded from " + service,
|
Message: "Downloaded from " + service,
|
||||||
|
|||||||
+10
-3
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-flac/flacpicture"
|
"github.com/go-flac/flacpicture"
|
||||||
"github.com/go-flac/flacvorbis"
|
"github.com/go-flac/flacvorbis"
|
||||||
@@ -273,10 +274,16 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
|||||||
if value == "" {
|
if value == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Remove existing
|
// Remove existing (case-insensitive comparison for Vorbis comments)
|
||||||
|
keyUpper := strings.ToUpper(key)
|
||||||
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
||||||
if len(cmt.Comments[i]) > len(key)+1 && cmt.Comments[i][:len(key)+1] == key+"=" {
|
comment := cmt.Comments[i]
|
||||||
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
|
eqIdx := strings.Index(comment, "=")
|
||||||
|
if eqIdx > 0 {
|
||||||
|
existingKey := strings.ToUpper(comment[:eqIdx])
|
||||||
|
if existingKey == keyUpper {
|
||||||
|
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Add new
|
// Add new
|
||||||
|
|||||||
+73
-14
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// QobuzDownloader handles Qobuz downloads
|
// QobuzDownloader handles Qobuz downloads
|
||||||
@@ -39,6 +40,63 @@ type QobuzTrack struct {
|
|||||||
} `json:"performer"`
|
} `json:"performer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// qobuzArtistsMatch checks if the artist names are similar enough
|
||||||
|
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||||
|
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||||
|
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if normExpected == normFound {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if one contains the other
|
||||||
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check first artist (before comma or feat)
|
||||||
|
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||||
|
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||||
|
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||||
|
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||||
|
|
||||||
|
foundFirst := strings.Split(normFound, ",")[0]
|
||||||
|
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||||
|
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||||
|
foundFirst = strings.TrimSpace(foundFirst)
|
||||||
|
|
||||||
|
if expectedFirst == foundFirst {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first artist is contained in the other
|
||||||
|
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||||
|
// assume they're the same artist with different transliteration
|
||||||
|
expectedASCII := qobuzIsASCIIString(expectedArtist)
|
||||||
|
foundASCII := qobuzIsASCIIString(foundArtist)
|
||||||
|
if expectedASCII != foundASCII {
|
||||||
|
fmt.Printf("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
||||||
|
func qobuzIsASCIIString(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > 127 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// NewQobuzDownloader creates a new Qobuz downloader
|
// NewQobuzDownloader creates a new Qobuz downloader
|
||||||
func NewQobuzDownloader() *QobuzDownloader {
|
func NewQobuzDownloader() *QobuzDownloader {
|
||||||
return &QobuzDownloader{
|
return &QobuzDownloader{
|
||||||
@@ -451,34 +509,35 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
// Strategy 1: Search by ISRC with duration verification
|
// Strategy 1: Search by ISRC with duration verification
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
||||||
|
// Verify artist
|
||||||
|
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
|
fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, track.Performer.Name)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Search by metadata with duration verification
|
// Strategy 2: Search by metadata with duration verification
|
||||||
if track == nil {
|
if track == nil {
|
||||||
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
||||||
|
// Verify artist
|
||||||
|
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
|
fmt.Printf("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, track.Performer.Name)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if track == nil {
|
if track == nil {
|
||||||
errMsg := "could not find track on Qobuz"
|
errMsg := "could not find matching track on Qobuz (artist/duration mismatch)"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg = err.Error()
|
errMsg = err.Error()
|
||||||
}
|
}
|
||||||
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final duration verification
|
// Log match found
|
||||||
if expectedDurationSec > 0 {
|
fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
||||||
durationDiff := track.Duration - expectedDurationSec
|
|
||||||
if durationDiff < 0 {
|
|
||||||
durationDiff = -durationDiff
|
|
||||||
}
|
|
||||||
if durationDiff > 30 {
|
|
||||||
return QobuzDownloadResult{}, fmt.Errorf("duration mismatch: expected %ds, found %ds (diff: %ds). Track may be wrong version",
|
|
||||||
expectedDurationSec, track.Duration, durationDiff)
|
|
||||||
}
|
|
||||||
fmt.Printf("[Qobuz] Duration verified: expected %ds, found %ds (diff: %ds)\n",
|
|
||||||
expectedDurationSec, track.Duration, durationDiff)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build filename
|
// Build filename
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
|
|||||||
+128
-22
@@ -869,6 +869,64 @@ type TidalDownloadResult struct {
|
|||||||
SampleRate int
|
SampleRate int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// artistsMatch checks if the artist names are similar enough
|
||||||
|
func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||||
|
normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist))
|
||||||
|
normTidal := strings.ToLower(strings.TrimSpace(tidalArtist))
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if normSpotify == normTidal {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if one contains the other (for cases like "Artist" vs "Artist feat. Someone")
|
||||||
|
if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check first artist (before comma or feat)
|
||||||
|
spotifyFirst := strings.Split(normSpotify, ",")[0]
|
||||||
|
spotifyFirst = strings.Split(spotifyFirst, " feat")[0]
|
||||||
|
spotifyFirst = strings.Split(spotifyFirst, " ft.")[0]
|
||||||
|
spotifyFirst = strings.TrimSpace(spotifyFirst)
|
||||||
|
|
||||||
|
tidalFirst := strings.Split(normTidal, ",")[0]
|
||||||
|
tidalFirst = strings.Split(tidalFirst, " feat")[0]
|
||||||
|
tidalFirst = strings.Split(tidalFirst, " ft.")[0]
|
||||||
|
tidalFirst = strings.TrimSpace(tidalFirst)
|
||||||
|
|
||||||
|
if spotifyFirst == tidalFirst {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first artist is contained in the other
|
||||||
|
if strings.Contains(spotifyFirst, tidalFirst) || strings.Contains(tidalFirst, spotifyFirst) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||||
|
// assume they're the same artist with different transliteration
|
||||||
|
// This handles cases like "鈴木雅之" vs "Masayuki Suzuki"
|
||||||
|
spotifyASCII := isASCIIString(spotifyArtist)
|
||||||
|
tidalASCII := isASCIIString(tidalArtist)
|
||||||
|
if spotifyASCII != tidalASCII {
|
||||||
|
fmt.Printf("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isASCIIString checks if a string contains only ASCII characters
|
||||||
|
func isASCIIString(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > 127 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// downloadFromTidal downloads a track using the request parameters
|
// downloadFromTidal downloads a track using the request parameters
|
||||||
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||||
downloader := NewTidalDownloader()
|
downloader := NewTidalDownloader()
|
||||||
@@ -892,17 +950,36 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
||||||
if idErr == nil {
|
if idErr == nil {
|
||||||
track, err = downloader.GetTrackInfoByID(trackID)
|
track, err = downloader.GetTrackInfoByID(trackID)
|
||||||
// Verify duration if we have expected duration
|
if track != nil {
|
||||||
if track != nil && expectedDurationSec > 0 {
|
// Get artist name from track
|
||||||
durationDiff := track.Duration - expectedDurationSec
|
tidalArtist := track.Artist.Name
|
||||||
if durationDiff < 0 {
|
if len(track.Artists) > 0 {
|
||||||
durationDiff = -durationDiff
|
var artistNames []string
|
||||||
|
for _, a := range track.Artists {
|
||||||
|
artistNames = append(artistNames, a.Name)
|
||||||
|
}
|
||||||
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
}
|
}
|
||||||
// Allow 30 seconds tolerance
|
|
||||||
if durationDiff > 30 {
|
// Verify artist matches
|
||||||
fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||||
expectedDurationSec, track.Duration)
|
fmt.Printf("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
||||||
track = nil // Reject this match
|
req.ArtistName, tidalArtist)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify duration if we have expected duration
|
||||||
|
if track != nil && expectedDurationSec > 0 {
|
||||||
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
// Allow 30 seconds tolerance
|
||||||
|
if durationDiff > 30 {
|
||||||
|
fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
||||||
|
expectedDurationSec, track.Duration)
|
||||||
|
track = nil // Reject this match
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -912,34 +989,63 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
// Strategy 2: Search by ISRC with duration verification
|
// Strategy 2: Search by ISRC with duration verification
|
||||||
if track == nil && req.ISRC != "" {
|
if track == nil && req.ISRC != "" {
|
||||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
|
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
|
||||||
|
// Verify artist for ISRC match too
|
||||||
|
if track != nil {
|
||||||
|
tidalArtist := track.Artist.Name
|
||||||
|
if len(track.Artists) > 0 {
|
||||||
|
var artistNames []string
|
||||||
|
for _, a := range track.Artists {
|
||||||
|
artistNames = append(artistNames, a.Name)
|
||||||
|
}
|
||||||
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
|
}
|
||||||
|
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||||
|
fmt.Printf("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, tidalArtist)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Search by metadata only (no ISRC requirement)
|
// Strategy 3: Search by metadata only (no ISRC requirement)
|
||||||
if track == nil {
|
if track == nil {
|
||||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
|
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
|
||||||
|
// Verify artist for metadata search too
|
||||||
|
if track != nil {
|
||||||
|
tidalArtist := track.Artist.Name
|
||||||
|
if len(track.Artists) > 0 {
|
||||||
|
var artistNames []string
|
||||||
|
for _, a := range track.Artists {
|
||||||
|
artistNames = append(artistNames, a.Name)
|
||||||
|
}
|
||||||
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
|
}
|
||||||
|
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||||
|
fmt.Printf("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, tidalArtist)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if track == nil {
|
if track == nil {
|
||||||
errMsg := "could not find track on Tidal"
|
errMsg := "could not find matching track on Tidal (artist/duration mismatch)"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg = err.Error()
|
errMsg = err.Error()
|
||||||
}
|
}
|
||||||
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
|
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final duration verification
|
// Final verification logging
|
||||||
if expectedDurationSec > 0 {
|
tidalArtist := track.Artist.Name
|
||||||
durationDiff := track.Duration - expectedDurationSec
|
if len(track.Artists) > 0 {
|
||||||
if durationDiff < 0 {
|
var artistNames []string
|
||||||
durationDiff = -durationDiff
|
for _, a := range track.Artists {
|
||||||
|
artistNames = append(artistNames, a.Name)
|
||||||
}
|
}
|
||||||
if durationDiff > 30 {
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
return TidalDownloadResult{}, fmt.Errorf("duration mismatch: expected %ds, found %ds (diff: %ds). Track may be wrong version",
|
|
||||||
expectedDurationSec, track.Duration, durationDiff)
|
|
||||||
}
|
|
||||||
fmt.Printf("[Tidal] Duration verified: expected %ds, found %ds (diff: %ds)\n",
|
|
||||||
expectedDurationSec, track.Duration, durationDiff)
|
|
||||||
}
|
}
|
||||||
|
fmt.Printf("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
|
||||||
|
|
||||||
// Build filename
|
// Build filename
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '2.0.5';
|
static const String version = '2.0.6';
|
||||||
static const String buildNumber = '35';
|
static const String buildNumber = '36';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
albumArtist: data['album_artist'] as String?,
|
albumArtist: data['album_artist'] as String?,
|
||||||
coverUrl: data['images'] as String?,
|
coverUrl: data['images'] as String?,
|
||||||
isrc: data['isrc'] as String?,
|
isrc: data['isrc'] as String?,
|
||||||
duration: data['duration_ms'] as int? ?? 0,
|
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date'] as String?,
|
||||||
@@ -282,7 +282,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
albumArtist: data['album_artist'] as String?,
|
albumArtist: data['album_artist'] as String?,
|
||||||
coverUrl: data['images'] as String?,
|
coverUrl: data['images'] as String?,
|
||||||
isrc: data['isrc'] as String?,
|
isrc: data['isrc'] as String?,
|
||||||
duration: data['duration_ms'] as int? ?? 0,
|
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date'] as String?,
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
albumArtist: data['album_artist'] as String?,
|
albumArtist: data['album_artist'] as String?,
|
||||||
coverUrl: data['images'] as String?,
|
coverUrl: data['images'] as String?,
|
||||||
isrc: data['isrc'] as String?,
|
isrc: data['isrc'] as String?,
|
||||||
duration: data['duration_ms'] as int? ?? 0,
|
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date'] as String?,
|
||||||
|
|||||||
@@ -12,53 +12,46 @@ class AboutPage extends StatelessWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
body: CustomScrollView(
|
canPop: true,
|
||||||
slivers: [
|
child: Scaffold(
|
||||||
// Collapsing App Bar with back button
|
body: CustomScrollView(
|
||||||
SliverAppBar(
|
slivers: [
|
||||||
expandedHeight: 120 + topPadding,
|
// Collapsing App Bar with back button
|
||||||
collapsedHeight: kToolbarHeight,
|
SliverAppBar(
|
||||||
floating: false,
|
expandedHeight: 120 + topPadding,
|
||||||
pinned: true,
|
collapsedHeight: kToolbarHeight,
|
||||||
backgroundColor: colorScheme.surface,
|
floating: false,
|
||||||
surfaceTintColor: Colors.transparent,
|
pinned: true,
|
||||||
leading: IconButton(
|
backgroundColor: colorScheme.surface,
|
||||||
icon: const Icon(Icons.arrow_back),
|
surfaceTintColor: Colors.transparent,
|
||||||
onPressed: () => Navigator.pop(context),
|
leading: IconButton(
|
||||||
),
|
icon: const Icon(Icons.arrow_back),
|
||||||
flexibleSpace: LayoutBuilder(
|
onPressed: () => Navigator.pop(context),
|
||||||
builder: (context, constraints) {
|
),
|
||||||
final maxHeight = 120 + topPadding;
|
flexibleSpace: LayoutBuilder(
|
||||||
final minHeight = kToolbarHeight + topPadding;
|
builder: (context, constraints) {
|
||||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
final maxHeight = 120 + topPadding;
|
||||||
final animation = AlwaysStoppedAnimation(expandRatio);
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
return FlexibleSpaceBar(
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
expandedTitleScale: 1.0,
|
// When collapsed (expandRatio=0): left=56 to avoid back button
|
||||||
titlePadding: EdgeInsets.zero,
|
// When expanded (expandRatio=1): left=24 for normal padding
|
||||||
title: SafeArea(
|
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||||
child: Container(
|
return FlexibleSpaceBar(
|
||||||
alignment: Alignment.bottomLeft,
|
expandedTitleScale: 1.0,
|
||||||
padding: EdgeInsets.only(
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
// When collapsed (expandRatio=0): left=56 to align with back button
|
title: Text(
|
||||||
// When expanded (expandRatio=1): left=24 for normal padding
|
'About',
|
||||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
style: TextStyle(
|
||||||
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
|
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
child: Text(
|
color: colorScheme.onSurface,
|
||||||
'About',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// App header card with logo and description
|
// App header card with logo and description
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
@@ -166,6 +159,7 @@ class AboutPage extends StatelessWidget {
|
|||||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,104 +14,113 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
body: CustomScrollView(
|
canPop: true,
|
||||||
slivers: [
|
child: Scaffold(
|
||||||
// Collapsing App Bar with back button
|
body: CustomScrollView(
|
||||||
SliverAppBar(
|
slivers: [
|
||||||
expandedHeight: 120 + topPadding,
|
// Collapsing App Bar with back button
|
||||||
collapsedHeight: kToolbarHeight,
|
SliverAppBar(
|
||||||
floating: false,
|
expandedHeight: 120 + topPadding,
|
||||||
pinned: true,
|
collapsedHeight: kToolbarHeight,
|
||||||
backgroundColor: colorScheme.surface,
|
floating: false,
|
||||||
surfaceTintColor: Colors.transparent,
|
pinned: true,
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
backgroundColor: colorScheme.surface,
|
||||||
flexibleSpace: LayoutBuilder(
|
surfaceTintColor: Colors.transparent,
|
||||||
builder: (context, constraints) {
|
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
||||||
final maxHeight = 120 + topPadding;
|
flexibleSpace: _AppBarTitle(title: 'Appearance', topPadding: topPadding),
|
||||||
final minHeight = kToolbarHeight + topPadding;
|
),
|
||||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
|
||||||
final animation = AlwaysStoppedAnimation(expandRatio);
|
// Theme section
|
||||||
return FlexibleSpaceBar(
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')),
|
||||||
expandedTitleScale: 1.0,
|
SliverToBoxAdapter(
|
||||||
titlePadding: EdgeInsets.zero,
|
child: SettingsGroup(
|
||||||
title: SafeArea(
|
children: [
|
||||||
child: Container(
|
_ThemeModeSelector(
|
||||||
alignment: Alignment.bottomLeft,
|
currentMode: themeSettings.themeMode,
|
||||||
padding: EdgeInsets.only(
|
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
|
||||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
),
|
||||||
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
|
],
|
||||||
),
|
),
|
||||||
child: Text('Appearance',
|
),
|
||||||
style: TextStyle(
|
|
||||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
// Color section
|
||||||
fontWeight: FontWeight.bold,
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
|
||||||
color: colorScheme.onSurface,
|
SliverToBoxAdapter(
|
||||||
),
|
child: SettingsGroup(
|
||||||
),
|
children: [
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.auto_awesome,
|
||||||
|
title: 'Dynamic Color',
|
||||||
|
subtitle: 'Use colors from your wallpaper',
|
||||||
|
value: themeSettings.useDynamicColor,
|
||||||
|
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
|
||||||
|
showDivider: !themeSettings.useDynamicColor,
|
||||||
|
),
|
||||||
|
if (!themeSettings.useDynamicColor)
|
||||||
|
_ColorPicker(
|
||||||
|
currentColor: themeSettings.seedColorValue,
|
||||||
|
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Layout section
|
||||||
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
_HistoryViewSelector(
|
||||||
|
currentMode: settings.historyViewMode,
|
||||||
|
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
|
||||||
),
|
),
|
||||||
);
|
],
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Theme section
|
// Fill remaining for scroll
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')),
|
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||||
SliverToBoxAdapter(
|
],
|
||||||
child: SettingsGroup(
|
),
|
||||||
children: [
|
|
||||||
_ThemeModeSelector(
|
|
||||||
currentMode: themeSettings.themeMode,
|
|
||||||
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Color section
|
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: SettingsGroup(
|
|
||||||
children: [
|
|
||||||
SettingsSwitchItem(
|
|
||||||
icon: Icons.auto_awesome,
|
|
||||||
title: 'Dynamic Color',
|
|
||||||
subtitle: 'Use colors from your wallpaper',
|
|
||||||
value: themeSettings.useDynamicColor,
|
|
||||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
|
|
||||||
showDivider: !themeSettings.useDynamicColor,
|
|
||||||
),
|
|
||||||
if (!themeSettings.useDynamicColor)
|
|
||||||
_ColorPicker(
|
|
||||||
currentColor: themeSettings.seedColorValue,
|
|
||||||
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Layout section
|
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')),
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: SettingsGroup(
|
|
||||||
children: [
|
|
||||||
_HistoryViewSelector(
|
|
||||||
currentMode: settings.historyViewMode,
|
|
||||||
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Fill remaining for scroll
|
|
||||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Optimized app bar title with animation
|
||||||
|
class _AppBarTitle extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final double topPadding;
|
||||||
|
|
||||||
|
const _AppBarTitle({required this.title, required this.topPadding});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final maxHeight = 120 + topPadding;
|
||||||
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
|
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||||
|
return FlexibleSpaceBar(
|
||||||
|
expandedTitleScale: 1.0,
|
||||||
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _ThemeModeSelector extends StatelessWidget {
|
class _ThemeModeSelector extends StatelessWidget {
|
||||||
final ThemeMode currentMode;
|
final ThemeMode currentMode;
|
||||||
final ValueChanged<ThemeMode> onChanged;
|
final ValueChanged<ThemeMode> onChanged;
|
||||||
|
|||||||
@@ -13,47 +13,41 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
body: CustomScrollView(
|
canPop: true,
|
||||||
slivers: [
|
child: Scaffold(
|
||||||
// Collapsing App Bar with back button
|
body: CustomScrollView(
|
||||||
SliverAppBar(
|
slivers: [
|
||||||
expandedHeight: 120 + topPadding,
|
// Collapsing App Bar with back button
|
||||||
collapsedHeight: kToolbarHeight,
|
SliverAppBar(
|
||||||
floating: false,
|
expandedHeight: 120 + topPadding,
|
||||||
pinned: true,
|
collapsedHeight: kToolbarHeight,
|
||||||
backgroundColor: colorScheme.surface,
|
floating: false,
|
||||||
surfaceTintColor: Colors.transparent,
|
pinned: true,
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
backgroundColor: colorScheme.surface,
|
||||||
flexibleSpace: LayoutBuilder(
|
surfaceTintColor: Colors.transparent,
|
||||||
builder: (context, constraints) {
|
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
||||||
final maxHeight = 120 + topPadding;
|
flexibleSpace: LayoutBuilder(
|
||||||
final minHeight = kToolbarHeight + topPadding;
|
builder: (context, constraints) {
|
||||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
final maxHeight = 120 + topPadding;
|
||||||
final animation = AlwaysStoppedAnimation(expandRatio);
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
return FlexibleSpaceBar(
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
expandedTitleScale: 1.0,
|
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||||
titlePadding: EdgeInsets.zero,
|
return FlexibleSpaceBar(
|
||||||
title: SafeArea(
|
expandedTitleScale: 1.0,
|
||||||
child: Container(
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
alignment: Alignment.bottomLeft,
|
title: Text(
|
||||||
padding: EdgeInsets.only(
|
'Download',
|
||||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
style: TextStyle(
|
||||||
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
|
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
child: Text('Download',
|
color: colorScheme.onSurface,
|
||||||
style: TextStyle(
|
|
||||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Service section
|
// Service section
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Service')),
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Service')),
|
||||||
@@ -136,6 +130,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,47 +14,41 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
body: CustomScrollView(
|
canPop: true,
|
||||||
slivers: [
|
child: Scaffold(
|
||||||
// Collapsing App Bar with back button
|
body: CustomScrollView(
|
||||||
SliverAppBar(
|
slivers: [
|
||||||
expandedHeight: 120 + topPadding,
|
// Collapsing App Bar with back button
|
||||||
collapsedHeight: kToolbarHeight,
|
SliverAppBar(
|
||||||
floating: false,
|
expandedHeight: 120 + topPadding,
|
||||||
pinned: true,
|
collapsedHeight: kToolbarHeight,
|
||||||
backgroundColor: colorScheme.surface,
|
floating: false,
|
||||||
surfaceTintColor: Colors.transparent,
|
pinned: true,
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
backgroundColor: colorScheme.surface,
|
||||||
flexibleSpace: LayoutBuilder(
|
surfaceTintColor: Colors.transparent,
|
||||||
builder: (context, constraints) {
|
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
||||||
final maxHeight = 120 + topPadding;
|
flexibleSpace: LayoutBuilder(
|
||||||
final minHeight = kToolbarHeight + topPadding;
|
builder: (context, constraints) {
|
||||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
final maxHeight = 120 + topPadding;
|
||||||
final animation = AlwaysStoppedAnimation(expandRatio);
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
return FlexibleSpaceBar(
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
expandedTitleScale: 1.0,
|
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||||
titlePadding: EdgeInsets.zero,
|
return FlexibleSpaceBar(
|
||||||
title: SafeArea(
|
expandedTitleScale: 1.0,
|
||||||
child: Container(
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
alignment: Alignment.bottomLeft,
|
title: Text(
|
||||||
padding: EdgeInsets.only(
|
'Options',
|
||||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
style: TextStyle(
|
||||||
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
|
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
child: Text('Options',
|
color: colorScheme.onSurface,
|
||||||
style: TextStyle(
|
|
||||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Download options section
|
// Download options section
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Download')),
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Download')),
|
||||||
@@ -168,6 +162,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.0.5+35
|
version: 2.0.6+36
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
Reference in New Issue
Block a user