mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-06-09 08:03:57 +02:00
fix: improve logging for release builds and UI improvements
- Fix Flutter logs not appearing in release mode by bypassing Logger package - Add detailed logging for Deezer search API calls - Replace music_note icon with app logo on home screen - Remove shadow/border from logo in About and Home screens - Align icon size (40x40) with avatar in About page for consistent layout
This commit is contained in:
+98
-65
@@ -19,9 +19,9 @@ const (
|
||||
deezerAlbumURL = deezerBaseURL + "/album/%s"
|
||||
deezerArtistURL = deezerBaseURL + "/artist/%s"
|
||||
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
|
||||
|
||||
|
||||
deezerCacheTTL = 10 * time.Minute
|
||||
|
||||
|
||||
// Parallel ISRC fetching settings
|
||||
deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches
|
||||
)
|
||||
@@ -58,27 +58,27 @@ func GetDeezerClient() *DeezerClient {
|
||||
|
||||
// Deezer API response types
|
||||
type deezerTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Duration int `json:"duration"` // in seconds
|
||||
TrackPosition int `json:"track_position"`
|
||||
DiskNumber int `json:"disk_number"`
|
||||
ISRC string `json:"isrc"`
|
||||
Link string `json:"link"`
|
||||
ReleaseDate string `json:"release_date"` // Sometimes at track level
|
||||
Artist deezerArtist `json:"artist"`
|
||||
Album deezerAlbumSimple `json:"album"`
|
||||
Contributors []deezerArtist `json:"contributors"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Duration int `json:"duration"` // in seconds
|
||||
TrackPosition int `json:"track_position"`
|
||||
DiskNumber int `json:"disk_number"`
|
||||
ISRC string `json:"isrc"`
|
||||
Link string `json:"link"`
|
||||
ReleaseDate string `json:"release_date"` // Sometimes at track level
|
||||
Artist deezerArtist `json:"artist"`
|
||||
Album deezerAlbumSimple `json:"album"`
|
||||
Contributors []deezerArtist `json:"contributors"`
|
||||
}
|
||||
|
||||
type deezerArtist struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
PictureMedium string `json:"picture_medium"`
|
||||
PictureBig string `json:"picture_big"`
|
||||
PictureXL string `json:"picture_xl"`
|
||||
NbFan int `json:"nb_fan"`
|
||||
NbFan int `json:"nb_fan"`
|
||||
}
|
||||
|
||||
type deezerAlbumSimple struct {
|
||||
@@ -90,6 +90,7 @@ type deezerAlbumSimple struct {
|
||||
CoverXL string `json:"cover_xl"`
|
||||
ReleaseDate string `json:"release_date"` // Sometimes at album level
|
||||
}
|
||||
|
||||
// ... (skip other structs as they are fine/unchanged) ...
|
||||
|
||||
// ... (in convertTrack) ...
|
||||
@@ -113,7 +114,7 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||
if albumImage == "" {
|
||||
albumImage = track.Album.Cover
|
||||
}
|
||||
|
||||
|
||||
// Try to find release date
|
||||
releaseDate := track.ReleaseDate
|
||||
if releaseDate == "" {
|
||||
@@ -137,17 +138,17 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||
}
|
||||
|
||||
type deezerAlbumFull struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"`
|
||||
CoverMedium string `json:"cover_medium"`
|
||||
CoverBig string `json:"cover_big"`
|
||||
CoverXL string `json:"cover_xl"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
Artist deezerArtist `json:"artist"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"`
|
||||
CoverMedium string `json:"cover_medium"`
|
||||
CoverBig string `json:"cover_big"`
|
||||
CoverXL string `json:"cover_xl"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
Artist deezerArtist `json:"artist"`
|
||||
Contributors []deezerArtist `json:"contributors"`
|
||||
Tracks struct {
|
||||
Tracks struct {
|
||||
Data []deezerTrack `json:"data"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
@@ -164,17 +165,17 @@ type deezerArtistFull struct {
|
||||
}
|
||||
|
||||
type deezerPlaylistFull struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Picture string `json:"picture"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Picture string `json:"picture"`
|
||||
PictureMedium string `json:"picture_medium"`
|
||||
PictureBig string `json:"picture_big"`
|
||||
PictureXL string `json:"picture_xl"`
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
Creator struct {
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
Creator struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"creator"`
|
||||
Tracks struct {
|
||||
Tracks struct {
|
||||
Data []deezerTrack `json:"data"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
@@ -182,11 +183,14 @@ type deezerPlaylistFull struct {
|
||||
// SearchAll searches for tracks and artists on Deezer
|
||||
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
|
||||
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
|
||||
|
||||
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||
|
||||
|
||||
c.cacheMu.RLock()
|
||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||
c.cacheMu.RUnlock()
|
||||
GoLog("[Deezer] SearchAll: returning cached result\n")
|
||||
return entry.data.(*SearchAllResult), nil
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
@@ -198,13 +202,28 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
||||
|
||||
// Search tracks - NO ISRC fetch for performance
|
||||
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
||||
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
|
||||
|
||||
var trackResp struct {
|
||||
Data []deezerTrack `json:"data"`
|
||||
Data []deezerTrack `json:"data"`
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
|
||||
GoLog("[Deezer] Track search failed: %v\n", err)
|
||||
return nil, fmt.Errorf("deezer track search failed: %w", err)
|
||||
}
|
||||
|
||||
if trackResp.Error != nil {
|
||||
GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message)
|
||||
return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code)
|
||||
}
|
||||
|
||||
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
|
||||
|
||||
for _, track := range trackResp.Data {
|
||||
// Convert directly without fetching ISRC - much faster
|
||||
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
||||
@@ -212,21 +231,37 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
||||
|
||||
// Search artists
|
||||
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
||||
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
|
||||
|
||||
var artistResp struct {
|
||||
Data []deezerArtist `json:"data"`
|
||||
Data []deezerArtist `json:"data"`
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
|
||||
for _, artist := range artistResp.Data {
|
||||
result.Artists = append(result.Artists, SearchArtistResult{
|
||||
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
||||
Name: artist.Name,
|
||||
Images: c.getBestArtistImage(artist),
|
||||
Followers: artist.NbFan,
|
||||
Popularity: 0,
|
||||
})
|
||||
if artistResp.Error != nil {
|
||||
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
|
||||
} else {
|
||||
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
|
||||
for _, artist := range artistResp.Data {
|
||||
result.Artists = append(result.Artists, SearchArtistResult{
|
||||
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
||||
Name: artist.Name,
|
||||
Images: c.getBestArtistImage(artist),
|
||||
Followers: artist.NbFan,
|
||||
Popularity: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
GoLog("[Deezer] Artist search failed: %v\n", err)
|
||||
}
|
||||
|
||||
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
|
||||
|
||||
// Cache result
|
||||
c.cacheMu.Lock()
|
||||
c.searchCache[cacheKey] = &cacheEntry{
|
||||
@@ -241,7 +276,7 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
||||
// GetTrack fetches a single track by Deezer ID
|
||||
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
|
||||
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
||||
|
||||
|
||||
var track deezerTrack
|
||||
if err := c.getJSON(ctx, trackURL, &track); err != nil {
|
||||
return nil, err
|
||||
@@ -263,7 +298,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
|
||||
|
||||
|
||||
var album deezerAlbumFull
|
||||
if err := c.getJSON(ctx, albumURL, &album); err != nil {
|
||||
return nil, err
|
||||
@@ -375,7 +410,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
||||
if albumType == "compile" {
|
||||
albumType = "compilation"
|
||||
}
|
||||
|
||||
|
||||
coverURL := album.CoverXL
|
||||
if coverURL == "" {
|
||||
coverURL = album.CoverBig
|
||||
@@ -418,7 +453,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
||||
// ISRC is fetched in parallel for better performance
|
||||
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
|
||||
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
|
||||
|
||||
|
||||
var playlist deezerPlaylistFull
|
||||
if err := c.getJSON(ctx, playlistURL, &playlist); err != nil {
|
||||
return nil, err
|
||||
@@ -482,7 +517,7 @@ func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMet
|
||||
// Use direct ISRC endpoint (API 2.0)
|
||||
// https://api.deezer.com/2.0/track/isrc:{ISRC}
|
||||
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
|
||||
|
||||
|
||||
var track deezerTrack
|
||||
if err := c.getJSON(ctx, directURL, &track); err != nil {
|
||||
// Fallback to search if direct endpoint fails
|
||||
@@ -522,7 +557,7 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee
|
||||
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
|
||||
result := make(map[string]string)
|
||||
var resultMu sync.Mutex
|
||||
|
||||
|
||||
// First, check cache for existing ISRCs
|
||||
var tracksToFetch []deezerTrack
|
||||
c.cacheMu.RLock()
|
||||
@@ -535,20 +570,20 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
||||
}
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
|
||||
if len(tracksToFetch) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
// Use semaphore to limit concurrent requests
|
||||
sem := make(chan struct{}, deezerMaxParallelISRC)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
|
||||
for _, track := range tracksToFetch {
|
||||
wg.Add(1)
|
||||
go func(t deezerTrack) {
|
||||
defer wg.Done()
|
||||
|
||||
|
||||
// Acquire semaphore
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
@@ -556,24 +591,24 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
trackIDStr := fmt.Sprintf("%d", t.ID)
|
||||
fullTrack, err := c.fetchFullTrack(ctx, trackIDStr)
|
||||
if err != nil || fullTrack == nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Store in result and cache
|
||||
resultMu.Lock()
|
||||
result[trackIDStr] = fullTrack.ISRC
|
||||
resultMu.Unlock()
|
||||
|
||||
|
||||
c.cacheMu.Lock()
|
||||
c.isrcCache[trackIDStr] = fullTrack.ISRC
|
||||
c.cacheMu.Unlock()
|
||||
}(track)
|
||||
}
|
||||
|
||||
|
||||
wg.Wait()
|
||||
return result
|
||||
}
|
||||
@@ -588,23 +623,21 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
|
||||
return isrc, nil
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
|
||||
// Fetch from API
|
||||
fullTrack, err := c.fetchFullTrack(ctx, trackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
// Cache the result
|
||||
c.cacheMu.Lock()
|
||||
c.isrcCache[trackID] = fullTrack.ISRC
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
|
||||
return fullTrack.ISRC, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string {
|
||||
if artist.PictureXL != "" {
|
||||
return artist.PictureXL
|
||||
@@ -687,7 +720,7 @@ func parseDeezerURL(input string) (string, string, error) {
|
||||
}
|
||||
|
||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
|
||||
|
||||
// Skip language prefix if present (e.g., /en/, /fr/)
|
||||
if len(parts) > 0 && len(parts[0]) == 2 {
|
||||
parts = parts[1:]
|
||||
|
||||
@@ -35,7 +35,7 @@ func GetLogBuffer() *LogBuffer {
|
||||
globalLogBuffer = &LogBuffer{
|
||||
entries: make([]LogEntry, 0, 500),
|
||||
maxSize: 500,
|
||||
loggingEnabled: false, // Default: disabled for performance
|
||||
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
||||
}
|
||||
})
|
||||
return globalLogBuffer
|
||||
@@ -143,11 +143,11 @@ func LogError(tag, format string, args ...interface{}) {
|
||||
func GoLog(format string, args ...interface{}) {
|
||||
message := fmt.Sprintf(format, args...)
|
||||
message = strings.TrimSuffix(message, "\n")
|
||||
|
||||
|
||||
// Extract tag from message if present (e.g., "[Tidal] message")
|
||||
tag := "Go"
|
||||
level := "INFO"
|
||||
|
||||
|
||||
if strings.HasPrefix(message, "[") {
|
||||
endBracket := strings.Index(message, "]")
|
||||
if endBracket > 1 {
|
||||
@@ -155,7 +155,7 @@ func GoLog(format string, args ...interface{}) {
|
||||
message = strings.TrimSpace(message[endBracket+1:])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Determine level from message content
|
||||
msgLower := strings.ToLower(message)
|
||||
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") || strings.HasPrefix(message, "✗") {
|
||||
@@ -167,7 +167,7 @@ func GoLog(format string, args ...interface{}) {
|
||||
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
|
||||
level = "DEBUG"
|
||||
}
|
||||
|
||||
|
||||
GetLogBuffer().Add(level, tag, message)
|
||||
}
|
||||
|
||||
|
||||
+13
-13
@@ -128,7 +128,7 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
||||
// Extract core title (before any parentheses/brackets)
|
||||
coreExpected := qobuzExtractCoreTitle(normExpected)
|
||||
coreFound := qobuzExtractCoreTitle(normFound)
|
||||
|
||||
|
||||
if coreExpected != "" && coreFound != "" && coreExpected == coreFound {
|
||||
return true
|
||||
}
|
||||
@@ -151,7 +151,7 @@ func qobuzExtractCoreTitle(title string) string {
|
||||
parenIdx := strings.Index(title, "(")
|
||||
bracketIdx := strings.Index(title, "[")
|
||||
dashIdx := strings.Index(title, " - ")
|
||||
|
||||
|
||||
cutIdx := len(title)
|
||||
if parenIdx > 0 && parenIdx < cutIdx {
|
||||
cutIdx = parenIdx
|
||||
@@ -162,7 +162,7 @@ func qobuzExtractCoreTitle(title string) string {
|
||||
if dashIdx > 0 && dashIdx < cutIdx {
|
||||
cutIdx = dashIdx
|
||||
}
|
||||
|
||||
|
||||
return strings.TrimSpace(title[:cutIdx])
|
||||
}
|
||||
|
||||
@@ -173,11 +173,11 @@ func qobuzCleanTitle(title string) string {
|
||||
// Remove content in parentheses/brackets that are version indicators
|
||||
// This helps match "Song (Remastered)" with "Song" or "Song (2024 Remaster)"
|
||||
versionPatterns := []string{
|
||||
"remaster", "remastered", "deluxe", "bonus", "single",
|
||||
"remaster", "remastered", "deluxe", "bonus", "single",
|
||||
"album version", "radio edit", "original mix", "extended",
|
||||
"club mix", "remix", "live", "acoustic", "demo",
|
||||
}
|
||||
|
||||
|
||||
// Remove parenthetical content if it contains version indicators
|
||||
for {
|
||||
startParen := strings.LastIndex(cleaned, "(")
|
||||
@@ -198,7 +198,7 @@ func qobuzCleanTitle(title string) string {
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
// Same for brackets
|
||||
for {
|
||||
startBracket := strings.LastIndex(cleaned, "[")
|
||||
@@ -370,7 +370,7 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
||||
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
|
||||
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
|
||||
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
|
||||
|
||||
@@ -602,12 +602,12 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
// Return best quality among duration matches
|
||||
for _, track := range durationMatches {
|
||||
if track.MaximumBitDepth >= 24 {
|
||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
|
||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
|
||||
track.Title, track.Performer.Name)
|
||||
return track, nil
|
||||
}
|
||||
}
|
||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified)\n",
|
||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified)\n",
|
||||
durationMatches[0].Title, durationMatches[0].Performer.Name)
|
||||
return durationMatches[0], nil
|
||||
}
|
||||
@@ -619,18 +619,18 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
// No duration verification, return best quality from title matches
|
||||
for _, track := range tracksToCheck {
|
||||
if track.MaximumBitDepth >= 24 {
|
||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified, hi-res)\n",
|
||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified, hi-res)\n",
|
||||
track.Title, track.Performer.Name)
|
||||
return track, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if len(tracksToCheck) > 0 {
|
||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified)\n",
|
||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified)\n",
|
||||
tracksToCheck[0].Title, tracksToCheck[0].Performer.Name)
|
||||
return tracksToCheck[0], nil
|
||||
}
|
||||
|
||||
|
||||
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
|
||||
}
|
||||
|
||||
|
||||
+154
-7
@@ -477,7 +477,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
|
||||
if len(result.Items) > 0 {
|
||||
GoLog("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery)
|
||||
|
||||
|
||||
// OPTIMIZATION: If ISRC provided, check for match immediately and return early
|
||||
if spotifyISRC != "" {
|
||||
for i := range result.Items {
|
||||
@@ -494,7 +494,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
return track, nil
|
||||
}
|
||||
// Duration mismatch, continue searching
|
||||
GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
|
||||
GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
|
||||
expectedDuration, track.Duration)
|
||||
} else {
|
||||
GoLog("[Tidal] ✓ ISRC match: '%s'\n", track.Title)
|
||||
@@ -503,7 +503,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
allTracks = append(allTracks, result.Items...)
|
||||
}
|
||||
}
|
||||
@@ -638,7 +638,154 @@ type TidalDownloadInfo struct {
|
||||
SampleRate int
|
||||
}
|
||||
|
||||
// getDownloadURLSequential requests download URL from APIs sequentially
|
||||
// tidalAPIResult holds the result from a parallel API request
|
||||
type tidalAPIResult struct {
|
||||
apiURL string
|
||||
info TidalDownloadInfo
|
||||
err error
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
// getDownloadURLParallel requests download URL from all APIs in parallel
|
||||
// Returns the first successful result (supports both v1 and v2 API formats)
|
||||
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
||||
}
|
||||
|
||||
GoLog("[Tidal] Requesting download URL from %d APIs in parallel...\n", len(apis))
|
||||
|
||||
resultChan := make(chan tidalAPIResult, len(apis))
|
||||
startTime := time.Now()
|
||||
|
||||
// Start all requests in parallel
|
||||
for _, apiURL := range apis {
|
||||
go func(api string) {
|
||||
reqStart := time.Now()
|
||||
|
||||
// Create client with longer timeout for parallel requests
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
|
||||
GoLog("[Tidal] [Parallel] Starting request to: %s\n", api)
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
GoLog("[Tidal] [Parallel] %s - Failed to create request: %v\n", api, err)
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
GoLog("[Tidal] [Parallel] %s - Request failed: %v\n", api, err)
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
GoLog("[Tidal] [Parallel] %s - HTTP %d\n", api, resp.StatusCode)
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
GoLog("[Tidal] [Parallel] %s - Failed to read body: %v\n", api, err)
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
// Try v2 format first (object with manifest)
|
||||
var v2Response TidalAPIResponseV2
|
||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
|
||||
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||
GoLog("[Tidal] [Parallel] %s - Rejecting PREVIEW response\n", api)
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
GoLog("[Tidal] [Parallel] %s - Got FULL track (v2): %d-bit/%dHz in %v\n",
|
||||
api, v2Response.Data.BitDepth, v2Response.Data.SampleRate, time.Since(reqStart))
|
||||
|
||||
info := TidalDownloadInfo{
|
||||
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
||||
BitDepth: v2Response.Data.BitDepth,
|
||||
SampleRate: v2Response.Data.SampleRate,
|
||||
}
|
||||
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to v1 format (array with OriginalTrackUrl)
|
||||
var v1Responses []struct {
|
||||
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
||||
for _, item := range v1Responses {
|
||||
if item.OriginalTrackURL != "" {
|
||||
GoLog("[Tidal] [Parallel] %s - Got direct URL (v1) in %v\n", api, time.Since(reqStart))
|
||||
info := TidalDownloadInfo{
|
||||
URL: item.OriginalTrackURL,
|
||||
BitDepth: 16,
|
||||
SampleRate: 44100,
|
||||
}
|
||||
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Tidal] [Parallel] %s - No download URL in response\n", api)
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("no download URL or manifest in response"), duration: time.Since(reqStart)}
|
||||
}(apiURL)
|
||||
}
|
||||
|
||||
// Collect results - return first success
|
||||
var errors []string
|
||||
successCount := 0
|
||||
failCount := 0
|
||||
|
||||
for i := 0; i < len(apis); i++ {
|
||||
result := <-resultChan
|
||||
if result.err == nil {
|
||||
successCount++
|
||||
if successCount == 1 {
|
||||
// First success - use this one
|
||||
GoLog("[Tidal] [Parallel] ✓ Using response from %s (took %v, total %v)\n",
|
||||
result.apiURL, result.duration, time.Since(startTime))
|
||||
|
||||
// Don't return immediately - let other goroutines finish to avoid leaks
|
||||
// But we'll use this result
|
||||
go func() {
|
||||
// Drain remaining results
|
||||
for j := i + 1; j < len(apis); j++ {
|
||||
<-resultChan
|
||||
}
|
||||
}()
|
||||
|
||||
return result.apiURL, result.info, nil
|
||||
}
|
||||
} else {
|
||||
failCount++
|
||||
errMsg := result.err.Error()
|
||||
if len(errMsg) > 50 {
|
||||
errMsg = errMsg[:50] + "..."
|
||||
}
|
||||
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
|
||||
GoLog("[Tidal] [Parallel] ✗ %s failed: %s (took %v)\n", result.apiURL, errMsg, result.duration)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Tidal] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime))
|
||||
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
|
||||
}
|
||||
|
||||
// getDownloadURLSequential requests download URL from APIs sequentially (fallback)
|
||||
// Returns the first successful result (supports both v1 and v2 API formats)
|
||||
func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||
if len(apis) == 0 {
|
||||
@@ -1390,7 +1537,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
|
||||
var tidalURL string
|
||||
var slErr error
|
||||
|
||||
|
||||
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
|
||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||
@@ -1400,7 +1547,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
} else {
|
||||
tidalURL, slErr = downloader.GetTidalURLFromSpotify(req.SpotifyID)
|
||||
}
|
||||
|
||||
|
||||
if slErr == nil && tidalURL != "" {
|
||||
// Extract track ID and get track info
|
||||
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
||||
@@ -1456,7 +1603,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
}
|
||||
|
||||
|
||||
// Verify title first
|
||||
if !titlesMatch(req.TrackName, track.Title) {
|
||||
GoLog("[Tidal] Title mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('TrackProvider');
|
||||
|
||||
class TrackState {
|
||||
final List<Track> tracks;
|
||||
@@ -210,54 +213,60 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
// Use Deezer or Spotify based on settings
|
||||
final source = metadataSource ?? 'deezer';
|
||||
|
||||
// Debug log to show which source is being used
|
||||
// ignore: avoid_print
|
||||
print('[Search] Using metadata source: $source for query: "$query"');
|
||||
_log.i('Search started: source=$source, query="$query"');
|
||||
|
||||
Map<String, dynamic> results;
|
||||
if (source == 'deezer') {
|
||||
_log.d('Calling Deezer search API...');
|
||||
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
||||
// ignore: avoid_print
|
||||
print('[Search] Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
|
||||
_log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
||||
} else {
|
||||
_log.d('Calling Spotify search API...');
|
||||
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
||||
// ignore: avoid_print
|
||||
print('[Search] Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
|
||||
_log.i('Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
||||
}
|
||||
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
if (!_isRequestValid(requestId)) {
|
||||
_log.w('Search request cancelled (requestId=$requestId)');
|
||||
return;
|
||||
}
|
||||
|
||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||
|
||||
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists');
|
||||
|
||||
// Parse tracks with error handling per item
|
||||
final tracks = <Track>[];
|
||||
for (final t in trackList) {
|
||||
for (int i = 0; i < trackList.length; i++) {
|
||||
final t = trackList[i];
|
||||
try {
|
||||
if (t is Map<String, dynamic>) {
|
||||
tracks.add(_parseSearchTrack(t));
|
||||
} else {
|
||||
_log.w('Track[$i] is not a Map: ${t.runtimeType}');
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore: avoid_print
|
||||
print('[Search] Failed to parse track: $e');
|
||||
_log.e('Failed to parse track[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse artists with error handling per item
|
||||
final artists = <SearchArtist>[];
|
||||
for (final a in artistList) {
|
||||
for (int i = 0; i < artistList.length; i++) {
|
||||
final a = artistList[i];
|
||||
try {
|
||||
if (a is Map<String, dynamic>) {
|
||||
artists.add(_parseSearchArtist(a));
|
||||
} else {
|
||||
_log.w('Artist[$i] is not a Map: ${a.runtimeType}');
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore: avoid_print
|
||||
print('[Search] Failed to parse artist: $e');
|
||||
_log.e('Failed to parse artist[$i]: $e', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: avoid_print
|
||||
print('[Search] Parsed ${tracks.length} tracks, ${artists.length} artists');
|
||||
_log.i('Search complete: ${tracks.length} tracks, ${artists.length} artists parsed successfully');
|
||||
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
@@ -265,9 +274,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
);
|
||||
} catch (e) {
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
// Preserve hasSearchText on error so user stays on search screen
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
_log.e('Search failed: $e', e, stackTrace);
|
||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,13 +328,27 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
: Column(
|
||||
children: [
|
||||
SizedBox(height: screenHeight * 0.06),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 96,
|
||||
height: 96,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
size: 48,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Icon(Icons.music_note, size: 48, color: colorScheme.primary),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
|
||||
@@ -109,14 +109,14 @@ class AboutPage extends StatelessWidget {
|
||||
githubUsername: 'sachinsenal0x64',
|
||||
showDivider: true,
|
||||
),
|
||||
SettingsItem(
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.cloud_outlined,
|
||||
title: 'DoubleDouble',
|
||||
subtitle: 'Amazing API for Amazon Music downloads. Thank you for making it free!',
|
||||
onTap: () => _launchUrl('https://doubledouble.top'),
|
||||
showDivider: true,
|
||||
),
|
||||
SettingsItem(
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.music_note_outlined,
|
||||
title: 'DAB Music',
|
||||
subtitle: 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!',
|
||||
@@ -250,26 +250,21 @@ class _AppHeaderCard extends StatelessWidget {
|
||||
child: Column(
|
||||
children: [
|
||||
// App logo
|
||||
Container(
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.primary.withValues(alpha: 0.2),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 88,
|
||||
height: 88,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Container(
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Image.asset(
|
||||
'assets/images/logo.png',
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Icon(
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
size: 48,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
@@ -417,3 +412,80 @@ class _ContributorItem extends StatelessWidget {
|
||||
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings item with 40x40 icon area to align with contributor avatars
|
||||
class _AboutSettingsItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final VoidCallback? onTap;
|
||||
final bool showDivider;
|
||||
|
||||
const _AboutSettingsItem({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.onTap,
|
||||
this.showDivider = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Icon with 40x40 size to match avatar
|
||||
SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Icon(icon, color: colorScheme.onSurfaceVariant, size: 24),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onTap != null)
|
||||
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 76, // 20 + 40 + 16 = 76 (same as contributor item)
|
||||
endIndent: 20,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+46
-17
@@ -50,6 +50,7 @@ class LogBuffer extends ChangeNotifier {
|
||||
int _lastGoLogIndex = 0;
|
||||
|
||||
/// Whether logging is enabled (controlled by settings)
|
||||
/// User must enable "Detailed Logging" in settings to capture logs
|
||||
static bool _loggingEnabled = false;
|
||||
static bool get loggingEnabled => _loggingEnabled;
|
||||
static set loggingEnabled(bool value) {
|
||||
@@ -242,39 +243,63 @@ final log = Logger(
|
||||
|
||||
/// Logger with class/tag prefix for better traceability
|
||||
/// Now also writes to LogBuffer for in-app viewing
|
||||
/// Works in both debug and release mode
|
||||
class AppLogger {
|
||||
final String _tag;
|
||||
late final Logger _logger;
|
||||
late final Logger? _logger;
|
||||
|
||||
AppLogger(this._tag) {
|
||||
_logger = Logger(
|
||||
printer: SimplePrinter(printTime: false, colors: false),
|
||||
output: BufferedOutput(_tag),
|
||||
level: Level.debug,
|
||||
);
|
||||
// Only create Logger instance in debug mode
|
||||
// In release mode, we write directly to LogBuffer
|
||||
if (kDebugMode) {
|
||||
_logger = Logger(
|
||||
printer: SimplePrinter(printTime: false, colors: false),
|
||||
output: BufferedOutput(_tag),
|
||||
level: Level.debug,
|
||||
);
|
||||
} else {
|
||||
_logger = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _addToBuffer(String level, String message, {String? error}) {
|
||||
LogBuffer().add(LogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
level: level,
|
||||
tag: _tag,
|
||||
message: message,
|
||||
error: error,
|
||||
));
|
||||
}
|
||||
|
||||
void d(String message) {
|
||||
_logger.d(message);
|
||||
if (kDebugMode) {
|
||||
_logger?.d(message);
|
||||
} else {
|
||||
// In release mode, write directly to buffer
|
||||
_addToBuffer('DEBUG', message);
|
||||
}
|
||||
}
|
||||
|
||||
void i(String message) {
|
||||
_logger.i(message);
|
||||
if (kDebugMode) {
|
||||
_logger?.i(message);
|
||||
} else {
|
||||
_addToBuffer('INFO', message);
|
||||
}
|
||||
}
|
||||
|
||||
void w(String message) {
|
||||
_logger.w(message);
|
||||
if (kDebugMode) {
|
||||
_logger?.w(message);
|
||||
} else {
|
||||
_addToBuffer('WARN', message);
|
||||
}
|
||||
}
|
||||
|
||||
void e(String message, [Object? error, StackTrace? stackTrace]) {
|
||||
if (error != null) {
|
||||
LogBuffer().add(LogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
level: 'ERROR',
|
||||
tag: _tag,
|
||||
message: message,
|
||||
error: error.toString(),
|
||||
));
|
||||
_addToBuffer('ERROR', message, error: error.toString());
|
||||
if (kDebugMode) {
|
||||
debugPrint('[$_tag] ERROR: $message | $error');
|
||||
if (stackTrace != null) {
|
||||
@@ -282,7 +307,11 @@ class AppLogger {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_logger.e(message);
|
||||
if (kDebugMode) {
|
||||
_logger?.e(message);
|
||||
} else {
|
||||
_addToBuffer('ERROR', message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user