diff --git a/CHANGELOG.md b/CHANGELOG.md index 224ead95..e98e8891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,25 @@ ## [3.2.1] - 2026-01-22 -> **Note:** Starting from the next release, version format will change from `major.minor.patch` to `year.month.day` (e.g., 26.1.23). +> **Note:** Next release will use `year.month.day` format (e.g., 26.2.1) and is scheduled for early February. Developer is taking a short break! + +### Added + +- **Artist/Album + Singles Folder Structure**: Singles go inside artist folder (`Artist/Album/`, `Artist/Singles/`) +- **Embed Lyrics Button**: Manually embed online lyrics into tracks from Track Info screen (preserves synced timestamps) +- **Pause/Resume Button**: Added pause and resume controls next to "Downloading" header in History screen +- **Instrumental Detection**: Tracks marked as instrumental on lrclib.net now show "Instrumental track" instead of "Lyrics not available" ### Fixed -- **iOS History Migration**: Fixed "File not found" after updating from 3.1.x to 3.2.0 (container UUID change) -- **Home Feed Greeting**: Fixed wrong timezone - now uses device local time instead of extension -- **Deezer Track Position**: Fallback to index+1 when API returns 0 for track position -- **Spanish & Portuguese Plurals**: Fixed 16 ICU syntax warnings in localization files +- **Lyrics**: Multi-artist tracks now search by primary artist first, then full string +- **Lyrics**: Metadata tags (`[ti:...]`, `[ar:...]`, `[by:...]`) no longer shown in display +- **Lyrics**: Embed button now correctly appears for tracks with online lyrics +- **Lyrics**: Manual embed preserves original timestamps instead of plain text +- **iOS**: Fixed "File not found" after 3.1.x → 3.2.0 update (container UUID migration) +- **Home Feed**: Greeting now uses device local time +- **Deezer**: Track position fallback to index+1 when API returns 0 +- **Localization**: Fixed 16 ICU plural syntax warnings in Spanish & Portuguese --- diff --git a/go_backend/exports.go b/go_backend/exports.go index 8f8b0476..46236cb4 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -615,10 +615,11 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str } result := map[string]interface{}{ - "success": true, - "source": lyrics.Source, - "sync_type": lyrics.SyncType, - "lines": lyrics.Lines, + "success": true, + "source": lyrics.Source, + "sync_type": lyrics.SyncType, + "lines": lyrics.Lines, + "instrumental": lyrics.Instrumental, } jsonBytes, err := json.Marshal(result) @@ -630,11 +631,15 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str } func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) { + // If filePath is provided, ONLY check file - don't fallback to online + // This allows Flutter to distinguish between "from file" vs "from online" if filePath != "" { lyrics, err := ExtractLyrics(filePath) if err == nil && lyrics != "" { return lyrics, nil } + // File has no lyrics - return empty, let Flutter call again without filePath + return "", nil } client := NewLyricsClient() @@ -644,6 +649,11 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura return "", err } + // Return special marker for instrumental tracks + if lyricsData.Instrumental { + return "[instrumental:true]", nil + } + lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName) return lrcContent, nil } @@ -1698,6 +1708,11 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) { if trackCover == "" { trackCover = album.CoverURL } + // Use track number from extension, fallback to index+1 if not provided + trackNum := track.TrackNumber + if trackNum == 0 { + trackNum = i + 1 + } tracks[i] = map[string]interface{}{ "id": track.ID, "name": track.Name, @@ -1707,7 +1722,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) { "duration_ms": track.DurationMS, "cover_url": trackCover, "release_date": track.ReleaseDate, - "track_number": track.TrackNumber, + "track_number": trackNum, "disc_number": track.DiscNumber, "isrc": track.ISRC, "provider_id": track.ProviderID, diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index b22b200a..c82ed04a 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -240,7 +240,10 @@ func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool // durationSec: track duration in seconds for matching, use 0 to skip duration matching func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) { - // Check cache first + // Normalize artist name - take first artist before comma/semicolon for better matching + primaryArtist := normalizeArtistName(artistName) + + // Check cache first (use original artist name for cache key) if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found { fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName) cachedCopy := *cached @@ -251,29 +254,44 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st var lyrics *LyricsResponse var err error - // Try exact match first - lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName) - if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { + // Helper to check if lyrics result is valid (has lines OR is instrumental) + isValidResult := func(l *LyricsResponse) bool { + return l != nil && (len(l.Lines) > 0 || l.Instrumental) + } + + // Try exact match first with primary artist + lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName) + if err == nil && isValidResult(lyrics) { lyrics.Source = "LRCLIB" globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) return lyrics, nil } + // Try with full artist name if different from primary + if primaryArtist != artistName { + lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName) + if err == nil && isValidResult(lyrics) { + lyrics.Source = "LRCLIB" + globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) + return lyrics, nil + } + } + // Try with simplified track name simplifiedTrack := simplifyTrackName(trackName) if simplifiedTrack != trackName { - lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack) - if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { + lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack) + if err == nil && isValidResult(lyrics) { lyrics.Source = "LRCLIB (simplified)" globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) return lyrics, nil } } - // Search with duration matching - query := artistName + " " + trackName + // Search with duration matching (use primary artist for search) + query := primaryArtist + " " + trackName lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec) - if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { + if err == nil && isValidResult(lyrics) { lyrics.Source = "LRCLIB Search" globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) return lyrics, nil @@ -281,9 +299,9 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st // Search with simplified name and duration matching if simplifiedTrack != trackName { - query = artistName + " " + simplifiedTrack + query = primaryArtist + " " + simplifiedTrack lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec) - if err == nil && lyrics != nil && len(lyrics.Lines) > 0 { + if err == nil && isValidResult(lyrics) { lyrics.Source = "LRCLIB Search (simplified)" globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) return lyrics, nil @@ -462,6 +480,24 @@ func simplifyTrackName(name string) string { return strings.TrimSpace(result) } +// normalizeArtistName extracts the primary artist from multi-artist strings +// e.g., "HOYO-MiX, AURORA" -> "HOYO-MiX" +// e.g., "Artist1; Artist2" -> "Artist1" +func normalizeArtistName(name string) string { + // Split by common separators: ", " or "; " or " & " or " feat. " or " ft. " + separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "} + + result := name + for _, sep := range separators { + if idx := strings.Index(strings.ToLower(result), strings.ToLower(sep)); idx > 0 { + result = result[:idx] + break + } + } + + return strings.TrimSpace(result) +} + func SaveLRCFile(audioFilePath, lrcContent string) (string, error) { if lrcContent == "" { return "", fmt.Errorf("empty LRC content") diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 630a53e5..be159c0e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2962,6 +2962,24 @@ abstract class AppLocalizations { /// **'Failed to load lyrics'** String get trackLyricsLoadFailed; + /// Action - embed lyrics into audio file + /// + /// In en, this message translates to: + /// **'Embed Lyrics'** + String get trackEmbedLyrics; + + /// Snackbar - lyrics saved to file + /// + /// In en, this message translates to: + /// **'Lyrics embedded successfully'** + String get trackLyricsEmbedded; + + /// Message when track is instrumental (no lyrics) + /// + /// In en, this message translates to: + /// **'Instrumental track'** + String get trackInstrumental; + /// Snackbar - content copied /// /// In en, this message translates to: @@ -3688,6 +3706,18 @@ abstract class AppLocalizations { /// **'Albums/[2005] Album Name/'** String get albumFolderYearAlbumSubtitle; + /// Album folder option with singles inside artist + /// + /// In en, this message translates to: + /// **'Artist / Album + Singles'** + String get albumFolderArtistAlbumSingles; + + /// Folder structure example + /// + /// In en, this message translates to: + /// **'Artist/Album/ and Artist/Singles/'** + String get albumFolderArtistAlbumSinglesSubtitle; + /// Button - delete selected tracks /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 705153fc..0cbbbefa 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1631,6 +1631,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get trackLyricsLoadFailed => 'Failed to load lyrics'; + @override + String get trackEmbedLyrics => 'Embed Lyrics'; + + @override + String get trackLyricsEmbedded => 'Lyrics embedded successfully'; + + @override + String get trackInstrumental => 'Instrumental track'; + @override String get trackCopiedToClipboard => 'Copied to clipboard'; @@ -2019,6 +2028,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; + @override + String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles'; + + @override + String get albumFolderArtistAlbumSinglesSubtitle => + 'Artist/Album/ and Artist/Singles/'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 3a06bdd2..9eff2a37 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1618,6 +1618,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trackLyricsLoadFailed => 'Failed to load lyrics'; + @override + String get trackEmbedLyrics => 'Embed Lyrics'; + + @override + String get trackLyricsEmbedded => 'Lyrics embedded successfully'; + + @override + String get trackInstrumental => 'Instrumental track'; + @override String get trackCopiedToClipboard => 'Copied to clipboard'; @@ -2006,6 +2015,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; + @override + String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles'; + + @override + String get albumFolderArtistAlbumSinglesSubtitle => + 'Artist/Album/ and Artist/Singles/'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index cf725903..0a47926d 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1618,6 +1618,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trackLyricsLoadFailed => 'Failed to load lyrics'; + @override + String get trackEmbedLyrics => 'Embed Lyrics'; + + @override + String get trackLyricsEmbedded => 'Lyrics embedded successfully'; + + @override + String get trackInstrumental => 'Instrumental track'; + @override String get trackCopiedToClipboard => 'Copied to clipboard'; @@ -2006,6 +2015,13 @@ class AppLocalizationsEs extends AppLocalizations { @override String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; + @override + String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles'; + + @override + String get albumFolderArtistAlbumSinglesSubtitle => + 'Artist/Album/ and Artist/Singles/'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 69816a74..74b8e6c0 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1618,6 +1618,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trackLyricsLoadFailed => 'Failed to load lyrics'; + @override + String get trackEmbedLyrics => 'Embed Lyrics'; + + @override + String get trackLyricsEmbedded => 'Lyrics embedded successfully'; + + @override + String get trackInstrumental => 'Instrumental track'; + @override String get trackCopiedToClipboard => 'Copied to clipboard'; @@ -2006,6 +2015,13 @@ class AppLocalizationsFr extends AppLocalizations { @override String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; + @override + String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles'; + + @override + String get albumFolderArtistAlbumSinglesSubtitle => + 'Artist/Album/ and Artist/Singles/'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index cfdf09bc..9a690ec5 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -1618,6 +1618,15 @@ class AppLocalizationsHi extends AppLocalizations { @override String get trackLyricsLoadFailed => 'Failed to load lyrics'; + @override + String get trackEmbedLyrics => 'Embed Lyrics'; + + @override + String get trackLyricsEmbedded => 'Lyrics embedded successfully'; + + @override + String get trackInstrumental => 'Instrumental track'; + @override String get trackCopiedToClipboard => 'Copied to clipboard'; @@ -2006,6 +2015,13 @@ class AppLocalizationsHi extends AppLocalizations { @override String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; + @override + String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles'; + + @override + String get albumFolderArtistAlbumSinglesSubtitle => + 'Artist/Album/ and Artist/Singles/'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 155aa52b..3fa36e0e 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1628,6 +1628,15 @@ class AppLocalizationsId extends AppLocalizations { @override String get trackLyricsLoadFailed => 'Gagal memuat lirik'; + @override + String get trackEmbedLyrics => 'Embed Lyrics'; + + @override + String get trackLyricsEmbedded => 'Lyrics embedded successfully'; + + @override + String get trackInstrumental => 'Instrumental track'; + @override String get trackCopiedToClipboard => 'Disalin ke clipboard'; @@ -2019,6 +2028,13 @@ class AppLocalizationsId extends AppLocalizations { @override String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/'; + @override + String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles'; + + @override + String get albumFolderArtistAlbumSinglesSubtitle => + 'Artist/Album/ and Artist/Singles/'; + @override String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index e7822eb9..76d048b8 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1618,6 +1618,15 @@ class AppLocalizationsJa extends AppLocalizations { @override String get trackLyricsLoadFailed => 'Failed to load lyrics'; + @override + String get trackEmbedLyrics => 'Embed Lyrics'; + + @override + String get trackLyricsEmbedded => 'Lyrics embedded successfully'; + + @override + String get trackInstrumental => 'Instrumental track'; + @override String get trackCopiedToClipboard => 'Copied to clipboard'; @@ -2006,6 +2015,13 @@ class AppLocalizationsJa extends AppLocalizations { @override String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; + @override + String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles'; + + @override + String get albumFolderArtistAlbumSinglesSubtitle => + 'Artist/Album/ and Artist/Singles/'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 68d7a880..fed8e128 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1618,6 +1618,15 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trackLyricsLoadFailed => 'Failed to load lyrics'; + @override + String get trackEmbedLyrics => 'Embed Lyrics'; + + @override + String get trackLyricsEmbedded => 'Lyrics embedded successfully'; + + @override + String get trackInstrumental => 'Instrumental track'; + @override String get trackCopiedToClipboard => 'Copied to clipboard'; @@ -2006,6 +2015,13 @@ class AppLocalizationsKo extends AppLocalizations { @override String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; + @override + String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles'; + + @override + String get albumFolderArtistAlbumSinglesSubtitle => + 'Artist/Album/ and Artist/Singles/'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 4cb2dec6..eecb34ae 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1618,6 +1618,15 @@ class AppLocalizationsNl extends AppLocalizations { @override String get trackLyricsLoadFailed => 'Failed to load lyrics'; + @override + String get trackEmbedLyrics => 'Embed Lyrics'; + + @override + String get trackLyricsEmbedded => 'Lyrics embedded successfully'; + + @override + String get trackInstrumental => 'Instrumental track'; + @override String get trackCopiedToClipboard => 'Copied to clipboard'; @@ -2006,6 +2015,13 @@ class AppLocalizationsNl extends AppLocalizations { @override String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; + @override + String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles'; + + @override + String get albumFolderArtistAlbumSinglesSubtitle => + 'Artist/Album/ and Artist/Singles/'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index dd0f80e1..4a1d3424 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1618,6 +1618,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trackLyricsLoadFailed => 'Failed to load lyrics'; + @override + String get trackEmbedLyrics => 'Embed Lyrics'; + + @override + String get trackLyricsEmbedded => 'Lyrics embedded successfully'; + + @override + String get trackInstrumental => 'Instrumental track'; + @override String get trackCopiedToClipboard => 'Copied to clipboard'; @@ -2006,6 +2015,13 @@ class AppLocalizationsPt extends AppLocalizations { @override String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; + @override + String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles'; + + @override + String get albumFolderArtistAlbumSinglesSubtitle => + 'Artist/Album/ and Artist/Singles/'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 04a05e2d..15ad6280 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1652,6 +1652,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни'; + @override + String get trackEmbedLyrics => 'Embed Lyrics'; + + @override + String get trackLyricsEmbedded => 'Lyrics embedded successfully'; + + @override + String get trackInstrumental => 'Instrumental track'; + @override String get trackCopiedToClipboard => 'Скопировано в буфер обмена'; @@ -2047,6 +2056,13 @@ class AppLocalizationsRu extends AppLocalizations { String get albumFolderYearAlbumSubtitle => 'Альбомы/[2005] Название Альбома /'; + @override + String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles'; + + @override + String get albumFolderArtistAlbumSinglesSubtitle => + 'Artist/Album/ and Artist/Singles/'; + @override String get downloadedAlbumDeleteSelected => 'Удалить выбранные'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 9b150fbf..a1b0f73a 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -1618,6 +1618,15 @@ class AppLocalizationsTr extends AppLocalizations { @override String get trackLyricsLoadFailed => 'Failed to load lyrics'; + @override + String get trackEmbedLyrics => 'Embed Lyrics'; + + @override + String get trackLyricsEmbedded => 'Lyrics embedded successfully'; + + @override + String get trackInstrumental => 'Instrumental track'; + @override String get trackCopiedToClipboard => 'Copied to clipboard'; @@ -2006,6 +2015,13 @@ class AppLocalizationsTr extends AppLocalizations { @override String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; + @override + String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles'; + + @override + String get albumFolderArtistAlbumSinglesSubtitle => + 'Artist/Album/ and Artist/Singles/'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index f3e36581..6b15a933 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1618,6 +1618,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get trackLyricsLoadFailed => 'Failed to load lyrics'; + @override + String get trackEmbedLyrics => 'Embed Lyrics'; + + @override + String get trackLyricsEmbedded => 'Lyrics embedded successfully'; + + @override + String get trackInstrumental => 'Instrumental track'; + @override String get trackCopiedToClipboard => 'Copied to clipboard'; @@ -2006,6 +2015,13 @@ class AppLocalizationsZh extends AppLocalizations { @override String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; + @override + String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles'; + + @override + String get albumFolderArtistAlbumSinglesSubtitle => + 'Artist/Album/ and Artist/Singles/'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 1d467f08..4dd87f36 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1188,6 +1188,12 @@ "@trackLyricsTimeout": {"description": "Message when lyrics request times out"}, "trackLyricsLoadFailed": "Failed to load lyrics", "@trackLyricsLoadFailed": {"description": "Message when lyrics loading fails"}, + "trackEmbedLyrics": "Embed Lyrics", + "@trackEmbedLyrics": {"description": "Action - embed lyrics into audio file"}, + "trackLyricsEmbedded": "Lyrics embedded successfully", + "@trackLyricsEmbedded": {"description": "Snackbar - lyrics saved to file"}, + "trackInstrumental": "Instrumental track", + "@trackInstrumental": {"description": "Message when track is instrumental (no lyrics)"}, "trackCopiedToClipboard": "Copied to clipboard", "@trackCopiedToClipboard": {"description": "Snackbar - content copied"}, "trackDeleteConfirmTitle": "Remove from device?", @@ -1477,6 +1483,10 @@ "@albumFolderYearAlbum": {"description": "Album folder option with year"}, "albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/", "@albumFolderYearAlbumSubtitle": {"description": "Folder structure example"}, + "albumFolderArtistAlbumSingles": "Artist / Album + Singles", + "@albumFolderArtistAlbumSingles": {"description": "Album folder option with singles inside artist"}, + "albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/", + "@albumFolderArtistAlbumSinglesSubtitle": {"description": "Folder structure example"}, "downloadedAlbumDeleteSelected": "Delete Selected", "@downloadedAlbumDeleteSelected": {"description": "Button - delete selected tracks"}, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 2af7c32c..80e3f9f4 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -712,14 +712,29 @@ class DownloadQueueNotifier extends Notifier { if (separateSingles) { final isSingle = track.isSingle; + final artistName = _sanitizeFolderName(albumArtist); + // New option: Singles folder inside Artist folder + if (albumFolderStructure == 'artist_album_singles') { + if (isSingle) { + final singlesPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}Singles'; + await _ensureDirExists(singlesPath, label: 'Artist Singles folder'); + return singlesPath; + } else { + final albumName = _sanitizeFolderName(track.albumName); + final albumPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName'; + await _ensureDirExists(albumPath, label: 'Artist Album folder'); + return albumPath; + } + } + + // Existing behavior: Separate Albums/ and Singles/ at root if (isSingle) { final singlesPath = '$baseDir${Platform.pathSeparator}Singles'; await _ensureDirExists(singlesPath, label: 'Singles folder'); return singlesPath; } else { final albumName = _sanitizeFolderName(track.albumName); - final artistName = _sanitizeFolderName(albumArtist); final year = _extractYear(track.releaseDate); String albumPath; @@ -1169,10 +1184,13 @@ class DownloadQueueNotifier extends Notifier { durationMs: durationMs, ); - if (lrcContent.isNotEmpty) { + // Skip instrumental tracks (no lyrics to embed) + if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') { metadata['LYRICS'] = lrcContent; metadata['UNSYNCEDLYRICS'] = lrcContent; _log.d('Lyrics fetched for embedding (${lrcContent.length} chars)'); + } else if (lrcContent == '[instrumental:true]') { + _log.d('Track is instrumental, skipping lyrics embedding'); } } catch (e) { _log.w('Failed to fetch lyrics for embedding: $e'); diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index cfb68838..6132d452 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -783,11 +783,17 @@ final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items)); SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), - child: Text( - 'Downloading (${queueItems.length})', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + child: Row( + children: [ + Text( + 'Downloading (${queueItems.length})', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + _buildPauseResumeButton(context, ref, colorScheme), + ], ), ), ), @@ -1146,6 +1152,31 @@ if (queueItems.isEmpty && ); } + Widget _buildPauseResumeButton( + BuildContext context, + WidgetRef ref, + ColorScheme colorScheme, + ) { + final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused)); + + return TextButton.icon( + onPressed: () { + ref.read(downloadQueueProvider.notifier).togglePause(); + }, + icon: Icon( + isPaused ? Icons.play_arrow : Icons.pause, + size: 18, + ), + label: Text( + isPaused ? context.l10n.actionResume : context.l10n.actionPause, + ), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + foregroundColor: isPaused ? colorScheme.primary : colorScheme.onSurfaceVariant, + ), + ); + } + Widget _buildEmptyState( BuildContext context, ColorScheme colorScheme, diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index f408c64d..2182bd76 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -276,6 +276,8 @@ class DownloadSettingsPage extends ConsumerWidget { return 'Albums/Artist/[Year] Album/'; case 'year_album': return 'Albums/[Year] Album/'; + case 'artist_album_singles': + return 'Artist/Album/ + Artist/Singles/'; default: return 'Albums/Artist/Album Name/'; } @@ -328,6 +330,16 @@ class DownloadSettingsPage extends ConsumerWidget { Navigator.pop(context); }, ), + ListTile( + leading: const Icon(Icons.person_outlined), + title: Text(context.l10n.albumFolderArtistAlbumSingles), + subtitle: Text(context.l10n.albumFolderArtistAlbumSinglesSubtitle), + trailing: current == 'artist_album_singles' ? const Icon(Icons.check) : null, + onTap: () { + ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album_singles'); + Navigator.pop(context); + }, + ), ], ), ), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 30f07f47..41f3e7f6 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -25,11 +25,15 @@ class TrackMetadataScreen extends ConsumerStatefulWidget { class _TrackMetadataScreenState extends ConsumerState { bool _fileExists = false; int? _fileSize; - String? _lyrics; + String? _lyrics; // Cleaned lyrics for display (no timestamps) + String? _rawLyrics; // Raw LRC with timestamps for embedding bool _lyricsLoading = false; String? _lyricsError; Color? _dominantColor; bool _showTitleInAppBar = false; + bool _lyricsEmbedded = false; // Track if lyrics are embedded in file + bool _isEmbedding = false; // Track embed operation in progress + bool _isInstrumental = false; // Track if detected as instrumental final ScrollController _scrollController = ScrollController(); static final RegExp _lrcTimestampPattern = RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]'); @@ -844,18 +848,62 @@ class _TrackMetadataScreenState extends ConsumerState { ], ), ) - else if (_lyrics != null) + else if (_isInstrumental) Container( - constraints: const BoxConstraints(maxHeight: 300), - child: SingleChildScrollView( - child: Text( - _lyrics!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurface, - height: 1.6, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.music_note, color: colorScheme.tertiary, size: 20), + const SizedBox(width: 12), + Text( + context.l10n.trackInstrumental, + style: TextStyle( + color: colorScheme.onTertiaryContainer, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ) + else if (_lyrics != null) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + constraints: const BoxConstraints(maxHeight: 300), + child: SingleChildScrollView( + child: Text( + _lyrics!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface, + height: 1.6, + ), + ), ), ), - ), + // Show "Embed Lyrics" button if lyrics are from online (not already embedded) + if (!_lyricsEmbedded && _fileExists) ...[ + const SizedBox(height: 16), + Center( + child: FilledButton.tonalIcon( + onPressed: _isEmbedding ? null : _embedLyrics, + icon: _isEmbedding + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_alt), + label: Text(context.l10n.trackEmbedLyrics), + ), + ), + ], + ], ) else Center( @@ -877,26 +925,57 @@ class _TrackMetadataScreenState extends ConsumerState { setState(() { _lyricsLoading = true; _lyricsError = null; + _isInstrumental = false; }); try { // Convert duration from seconds to milliseconds final durationMs = (item.duration ?? 0) * 1000; - // Add timeout to prevent infinite loading + // First, check if lyrics are embedded in the file + if (_fileExists) { + final embeddedResult = await PlatformBridge.getLyricsLRC( + '', + item.trackName, + item.artistName, + filePath: cleanFilePath, + durationMs: 0, + ).timeout(const Duration(seconds: 5), onTimeout: () => ''); + + if (embeddedResult.isNotEmpty) { + // Lyrics found in file + if (mounted) { + final cleanLyrics = _cleanLrcForDisplay(embeddedResult); + setState(() { + _lyrics = cleanLyrics; + _lyricsEmbedded = true; + _lyricsLoading = false; + }); + } + return; + } + } + + // No embedded lyrics, fetch from online final result = await PlatformBridge.getLyricsLRC( item.spotifyId ?? '', item.trackName, item.artistName, - filePath: _fileExists ? cleanFilePath : null, // Try embedded lyrics first + filePath: null, // Don't check file again durationMs: durationMs, ).timeout( const Duration(seconds: 20), - onTimeout: () => '', // Return empty string on timeout + onTimeout: () => '', ); if (mounted) { - if (result.isEmpty) { + // Check for instrumental marker + if (result == '[instrumental:true]') { + setState(() { + _isInstrumental = true; + _lyricsLoading = false; + }); + } else if (result.isEmpty) { setState(() { _lyricsError = context.l10n.trackLyricsNotAvailable; _lyricsLoading = false; @@ -905,6 +984,8 @@ class _TrackMetadataScreenState extends ConsumerState { final cleanLyrics = _cleanLrcForDisplay(result); setState(() { _lyrics = cleanLyrics; + _rawLyrics = result; // Keep raw LRC with timestamps for embedding + _lyricsEmbedded = false; // Lyrics from online, not embedded _lyricsLoading = false; }); } @@ -921,13 +1002,62 @@ class _TrackMetadataScreenState extends ConsumerState { } } } + + Future _embedLyrics() async { + if (_isEmbedding || _rawLyrics == null || !_fileExists) return; + + setState(() => _isEmbedding = true); + + try { + // Use raw LRC content directly - it already has timestamps and metadata + final result = await PlatformBridge.embedLyricsToFile( + cleanFilePath, + _rawLyrics!, + ); + + if (mounted) { + if (result['success'] == true) { + setState(() { + _lyricsEmbedded = true; + _isEmbedding = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackLyricsEmbedded)), + ); + } else { + setState(() => _isEmbedding = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result['error'] ?? 'Failed to embed lyrics')), + ); + } + } + } catch (e) { + if (mounted) { + setState(() => _isEmbedding = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } + } String _cleanLrcForDisplay(String lrc) { final lines = lrc.split('\n'); final cleanLines = []; + // Pattern to match LRC metadata tags like [ti:...], [ar:...], [al:...], [by:...], etc. + final metadataPattern = RegExp(r'^\[[a-zA-Z]+:.*\]$'); + for (final line in lines) { - final cleanLine = line.replaceAll(_lrcTimestampPattern, '').trim(); + final trimmedLine = line.trim(); + + // Skip metadata tags + if (metadataPattern.hasMatch(trimmedLine)) { + continue; + } + + // Remove timestamp and clean up + final cleanLine = trimmedLine.replaceAll(_lrcTimestampPattern, '').trim(); if (cleanLine.isNotEmpty) { cleanLines.add(cleanLine); }