From 68fa1bfdae27c042b6b7a5a37eb8e5d977601eb4 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 20 Jan 2026 09:55:46 +0700 Subject: [PATCH 01/19] feat: improve providers, l10n updates, and UI enhancements (testing) --- go_backend/qobuz.go | 10 +- go_backend/tidal.go | 24 +- lib/l10n/app_localizations.dart | 36 ++ lib/l10n/app_localizations_de.dart | 18 + lib/l10n/app_localizations_en.dart | 18 + lib/l10n/app_localizations_es.dart | 18 + lib/l10n/app_localizations_fr.dart | 18 + lib/l10n/app_localizations_hi.dart | 18 + lib/l10n/app_localizations_id.dart | 18 + lib/l10n/app_localizations_ja.dart | 18 + lib/l10n/app_localizations_ko.dart | 18 + lib/l10n/app_localizations_nl.dart | 18 + lib/l10n/app_localizations_pt.dart | 18 + lib/l10n/app_localizations_ru.dart | 18 + lib/l10n/app_localizations_zh.dart | 18 + lib/l10n/arb/app_en.arb | 16 +- lib/providers/download_queue_provider.dart | 16 +- lib/screens/downloaded_album_screen.dart | 7 +- lib/screens/queue_tab.dart | 501 ++++++++++++++++----- lib/screens/settings/about_page.dart | 26 +- 20 files changed, 721 insertions(+), 131 deletions(-) diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 40c24d86..06ce66d6 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -1072,13 +1072,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { albumName = req.AlbumName } + // Use track number from request if available, otherwise from Qobuz API + actualTrackNumber := req.TrackNumber + if actualTrackNumber == 0 { + actualTrackNumber = track.TrackNumber + } + metadata := Metadata{ Title: track.Title, Artist: track.Performer.Name, Album: albumName, AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct Date: track.Album.ReleaseDate, - TrackNumber: track.TrackNumber, + TrackNumber: actualTrackNumber, TotalTracks: req.TotalTracks, DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result ISRC: track.ISRC, @@ -1135,7 +1141,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { Artist: track.Performer.Name, Album: track.Album.Title, ReleaseDate: track.Album.ReleaseDate, - TrackNumber: track.TrackNumber, + TrackNumber: actualTrackNumber, DiscNumber: req.DiscNumber, // Qobuz track struct limitations ISRC: track.ISRC, }, nil diff --git a/go_backend/tidal.go b/go_backend/tidal.go index f489741a..9b64aa91 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -331,7 +331,6 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) { return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) } - // Now includes romaji conversion for Japanese text (4 search strategies like PC) func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) { token, err := t.GetAccessToken() @@ -630,7 +629,7 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin var v2Response TidalAPIResponseV2 if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { - if v2Response.Data.AssetPresentation == "PREVIEW" { + if v2Response.Data.AssetPresentation == "PREVIEW" { resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)} return } @@ -903,7 +902,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, if directURL != "" { GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))]) - if isDownloadCancelled(itemID) { + if isDownloadCancelled(itemID) { return ErrDownloadCancelled } @@ -1346,7 +1345,6 @@ func isLatinScript(s string) bool { return true } - func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { downloader := NewTidalDownloader() @@ -1593,15 +1591,25 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate) } + // Use track number from request if available, otherwise from Tidal API + actualTrackNumber := req.TrackNumber + actualDiscNumber := req.DiscNumber + if actualTrackNumber == 0 { + actualTrackNumber = track.TrackNumber + } + if actualDiscNumber == 0 { + actualDiscNumber = track.VolumeNumber + } + metadata := Metadata{ Title: req.TrackName, Artist: req.ArtistName, Album: req.AlbumName, AlbumArtist: req.AlbumArtist, Date: releaseDate, - TrackNumber: track.TrackNumber, + TrackNumber: actualTrackNumber, TotalTracks: req.TotalTracks, - DiscNumber: track.VolumeNumber, + DiscNumber: actualDiscNumber, ISRC: track.ISRC, Genre: req.Genre, Label: req.Label, @@ -1659,8 +1667,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { Artist: track.Artist.Name, Album: track.Album.Title, ReleaseDate: track.Album.ReleaseDate, - TrackNumber: track.TrackNumber, - DiscNumber: track.VolumeNumber, + TrackNumber: actualTrackNumber, + DiscNumber: actualDiscNumber, ISRC: track.ISRC, }, nil } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 4cdaa83d..f450dcd6 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -278,6 +278,12 @@ abstract class AppLocalizations { /// **'Single track downloads will appear here'** String get historyNoSinglesSubtitle; + /// Search bar placeholder in history + /// + /// In en, this message translates to: + /// **'Search history...'** + String get historySearchHint; + /// Settings screen title /// /// In en, this message translates to: @@ -872,6 +878,36 @@ abstract class AppLocalizations { /// **'Suggest new features for the app'** String get aboutFeatureRequestSubtitle; + /// Link to Telegram channel + /// + /// In en, this message translates to: + /// **'Telegram Channel'** + String get aboutTelegramChannel; + + /// Subtitle for Telegram channel + /// + /// In en, this message translates to: + /// **'Announcements and updates'** + String get aboutTelegramChannelSubtitle; + + /// Link to Telegram chat group + /// + /// In en, this message translates to: + /// **'Telegram Community'** + String get aboutTelegramChat; + + /// Subtitle for Telegram chat + /// + /// In en, this message translates to: + /// **'Chat with other users'** + String get aboutTelegramChatSubtitle; + + /// Section for social links + /// + /// In en, this message translates to: + /// **'Social'** + String get aboutSocial; + /// Section for support/donation links /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 2dd62b0d..daa68606 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -111,6 +111,9 @@ class AppLocalizationsDe extends AppLocalizations { String get historyNoSinglesSubtitle => 'Einzelne Titel-Downloads werden hier angezeigt'; + @override + String get historySearchHint => 'Search history...'; + @override String get settingsTitle => 'Einstellungen'; @@ -441,6 +444,21 @@ class AppLocalizationsDe extends AppLocalizations { String get aboutFeatureRequestSubtitle => 'Schlage neue Funktionen für die App vor'; + @override + String get aboutTelegramChannel => 'Telegram Channel'; + + @override + String get aboutTelegramChannelSubtitle => 'Announcements and updates'; + + @override + String get aboutTelegramChat => 'Telegram Community'; + + @override + String get aboutTelegramChatSubtitle => 'Chat with other users'; + + @override + String get aboutSocial => 'Social'; + @override String get aboutSupport => 'Support'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index de382bda..03986a38 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -109,6 +109,9 @@ class AppLocalizationsEn extends AppLocalizations { String get historyNoSinglesSubtitle => 'Single track downloads will appear here'; + @override + String get historySearchHint => 'Search history...'; + @override String get settingsTitle => 'Settings'; @@ -429,6 +432,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; + @override + String get aboutTelegramChannel => 'Telegram Channel'; + + @override + String get aboutTelegramChannelSubtitle => 'Announcements and updates'; + + @override + String get aboutTelegramChat => 'Telegram Community'; + + @override + String get aboutTelegramChatSubtitle => 'Chat with other users'; + + @override + String get aboutSocial => 'Social'; + @override String get aboutSupport => 'Support'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 0ff7baad..ea726e59 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -109,6 +109,9 @@ class AppLocalizationsEs extends AppLocalizations { String get historyNoSinglesSubtitle => 'Single track downloads will appear here'; + @override + String get historySearchHint => 'Search history...'; + @override String get settingsTitle => 'Settings'; @@ -429,6 +432,21 @@ class AppLocalizationsEs extends AppLocalizations { @override String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; + @override + String get aboutTelegramChannel => 'Telegram Channel'; + + @override + String get aboutTelegramChannelSubtitle => 'Announcements and updates'; + + @override + String get aboutTelegramChat => 'Telegram Community'; + + @override + String get aboutTelegramChatSubtitle => 'Chat with other users'; + + @override + String get aboutSocial => 'Social'; + @override String get aboutSupport => 'Support'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 92bf6148..16ba8c0a 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -109,6 +109,9 @@ class AppLocalizationsFr extends AppLocalizations { String get historyNoSinglesSubtitle => 'Single track downloads will appear here'; + @override + String get historySearchHint => 'Search history...'; + @override String get settingsTitle => 'Settings'; @@ -429,6 +432,21 @@ class AppLocalizationsFr extends AppLocalizations { @override String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; + @override + String get aboutTelegramChannel => 'Telegram Channel'; + + @override + String get aboutTelegramChannelSubtitle => 'Announcements and updates'; + + @override + String get aboutTelegramChat => 'Telegram Community'; + + @override + String get aboutTelegramChatSubtitle => 'Chat with other users'; + + @override + String get aboutSocial => 'Social'; + @override String get aboutSupport => 'Support'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index f95470da..dc99086e 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -109,6 +109,9 @@ class AppLocalizationsHi extends AppLocalizations { String get historyNoSinglesSubtitle => 'Single track downloads will appear here'; + @override + String get historySearchHint => 'Search history...'; + @override String get settingsTitle => 'Settings'; @@ -429,6 +432,21 @@ class AppLocalizationsHi extends AppLocalizations { @override String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; + @override + String get aboutTelegramChannel => 'Telegram Channel'; + + @override + String get aboutTelegramChannelSubtitle => 'Announcements and updates'; + + @override + String get aboutTelegramChat => 'Telegram Community'; + + @override + String get aboutTelegramChatSubtitle => 'Chat with other users'; + + @override + String get aboutSocial => 'Social'; + @override String get aboutSupport => 'Support'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 258ebad7..eaeb3a27 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -110,6 +110,9 @@ class AppLocalizationsId extends AppLocalizations { String get historyNoSinglesSubtitle => 'Unduhan lagu satuan akan muncul di sini'; + @override + String get historySearchHint => 'Search history...'; + @override String get settingsTitle => 'Pengaturan'; @@ -434,6 +437,21 @@ class AppLocalizationsId extends AppLocalizations { String get aboutFeatureRequestSubtitle => 'Sarankan fitur baru untuk aplikasi'; + @override + String get aboutTelegramChannel => 'Telegram Channel'; + + @override + String get aboutTelegramChannelSubtitle => 'Announcements and updates'; + + @override + String get aboutTelegramChat => 'Telegram Community'; + + @override + String get aboutTelegramChatSubtitle => 'Chat with other users'; + + @override + String get aboutSocial => 'Social'; + @override String get aboutSupport => 'Dukungan'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 9dd91796..8e945c25 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -109,6 +109,9 @@ class AppLocalizationsJa extends AppLocalizations { String get historyNoSinglesSubtitle => 'Single track downloads will appear here'; + @override + String get historySearchHint => 'Search history...'; + @override String get settingsTitle => '設定'; @@ -429,6 +432,21 @@ class AppLocalizationsJa extends AppLocalizations { @override String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; + @override + String get aboutTelegramChannel => 'Telegram Channel'; + + @override + String get aboutTelegramChannelSubtitle => 'Announcements and updates'; + + @override + String get aboutTelegramChat => 'Telegram Community'; + + @override + String get aboutTelegramChatSubtitle => 'Chat with other users'; + + @override + String get aboutSocial => 'Social'; + @override String get aboutSupport => 'Support'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 4b3487af..d2dd57a8 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -109,6 +109,9 @@ class AppLocalizationsKo extends AppLocalizations { String get historyNoSinglesSubtitle => 'Single track downloads will appear here'; + @override + String get historySearchHint => 'Search history...'; + @override String get settingsTitle => 'Settings'; @@ -429,6 +432,21 @@ class AppLocalizationsKo extends AppLocalizations { @override String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; + @override + String get aboutTelegramChannel => 'Telegram Channel'; + + @override + String get aboutTelegramChannelSubtitle => 'Announcements and updates'; + + @override + String get aboutTelegramChat => 'Telegram Community'; + + @override + String get aboutTelegramChatSubtitle => 'Chat with other users'; + + @override + String get aboutSocial => 'Social'; + @override String get aboutSupport => 'Support'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 67086594..64409a58 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -109,6 +109,9 @@ class AppLocalizationsNl extends AppLocalizations { String get historyNoSinglesSubtitle => 'Single track downloads will appear here'; + @override + String get historySearchHint => 'Search history...'; + @override String get settingsTitle => 'Settings'; @@ -429,6 +432,21 @@ class AppLocalizationsNl extends AppLocalizations { @override String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; + @override + String get aboutTelegramChannel => 'Telegram Channel'; + + @override + String get aboutTelegramChannelSubtitle => 'Announcements and updates'; + + @override + String get aboutTelegramChat => 'Telegram Community'; + + @override + String get aboutTelegramChatSubtitle => 'Chat with other users'; + + @override + String get aboutSocial => 'Social'; + @override String get aboutSupport => 'Support'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 42c9e1c6..49bbccd1 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -109,6 +109,9 @@ class AppLocalizationsPt extends AppLocalizations { String get historyNoSinglesSubtitle => 'Single track downloads will appear here'; + @override + String get historySearchHint => 'Search history...'; + @override String get settingsTitle => 'Settings'; @@ -429,6 +432,21 @@ class AppLocalizationsPt extends AppLocalizations { @override String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; + @override + String get aboutTelegramChannel => 'Telegram Channel'; + + @override + String get aboutTelegramChannelSubtitle => 'Announcements and updates'; + + @override + String get aboutTelegramChat => 'Telegram Community'; + + @override + String get aboutTelegramChatSubtitle => 'Chat with other users'; + + @override + String get aboutSocial => 'Social'; + @override String get aboutSupport => 'Support'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 8bd4c674..22caac4c 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -114,6 +114,9 @@ class AppLocalizationsRu extends AppLocalizations { String get historyNoSinglesSubtitle => 'Здесь будут отображаться загрузки синглов'; + @override + String get historySearchHint => 'Search history...'; + @override String get settingsTitle => 'Настройки'; @@ -442,6 +445,21 @@ class AppLocalizationsRu extends AppLocalizations { String get aboutFeatureRequestSubtitle => 'Предложить новые функции для приложения'; + @override + String get aboutTelegramChannel => 'Telegram Channel'; + + @override + String get aboutTelegramChannelSubtitle => 'Announcements and updates'; + + @override + String get aboutTelegramChat => 'Telegram Community'; + + @override + String get aboutTelegramChatSubtitle => 'Chat with other users'; + + @override + String get aboutSocial => 'Social'; + @override String get aboutSupport => 'Поддержка'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 30835ee2..5c41a232 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -109,6 +109,9 @@ class AppLocalizationsZh extends AppLocalizations { String get historyNoSinglesSubtitle => 'Single track downloads will appear here'; + @override + String get historySearchHint => 'Search history...'; + @override String get settingsTitle => 'Settings'; @@ -429,6 +432,21 @@ class AppLocalizationsZh extends AppLocalizations { @override String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; + @override + String get aboutTelegramChannel => 'Telegram Channel'; + + @override + String get aboutTelegramChannelSubtitle => 'Announcements and updates'; + + @override + String get aboutTelegramChat => 'Telegram Community'; + + @override + String get aboutTelegramChatSubtitle => 'Chat with other users'; + + @override + String get aboutSocial => 'Social'; + @override String get aboutSupport => 'Support'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index fce509d7..4b3388cb 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -75,8 +75,10 @@ "@historyNoAlbumsSubtitle": {"description": "Empty state subtitle for albums filter"}, "historyNoSingles": "No single downloads", "@historyNoSingles": {"description": "Empty state when filtering singles"}, - "historyNoSinglesSubtitle": "Single track downloads will appear here", +"historyNoSinglesSubtitle": "Single track downloads will appear here", "@historyNoSinglesSubtitle": {"description": "Empty state subtitle for singles filter"}, + "historySearchHint": "Search history...", + "@historySearchHint": {"description": "Search bar placeholder in history"}, "settingsTitle": "Settings", "@settingsTitle": {"description": "Settings screen title"}, @@ -304,10 +306,20 @@ "@aboutReportIssue": {"description": "Link to report bugs"}, "aboutReportIssueSubtitle": "Report any problems you encounter", "@aboutReportIssueSubtitle": {"description": "Subtitle for report issue"}, - "aboutFeatureRequest": "Feature request", +"aboutFeatureRequest": "Feature request", "@aboutFeatureRequest": {"description": "Link to suggest features"}, "aboutFeatureRequestSubtitle": "Suggest new features for the app", "@aboutFeatureRequestSubtitle": {"description": "Subtitle for feature request"}, + "aboutTelegramChannel": "Telegram Channel", + "@aboutTelegramChannel": {"description": "Link to Telegram channel"}, + "aboutTelegramChannelSubtitle": "Announcements and updates", + "@aboutTelegramChannelSubtitle": {"description": "Subtitle for Telegram channel"}, + "aboutTelegramChat": "Telegram Community", + "@aboutTelegramChat": {"description": "Link to Telegram chat group"}, + "aboutTelegramChatSubtitle": "Chat with other users", + "@aboutTelegramChatSubtitle": {"description": "Subtitle for Telegram chat"}, + "aboutSocial": "Social", + "@aboutSocial": {"description": "Section for social links"}, "aboutSupport": "Support", "@aboutSupport": {"description": "Section for support/donation links"}, "aboutBuyMeCoffee": "Buy me a coffee", diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 989cee9d..e4be0292 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1655,7 +1655,7 @@ class DownloadQueueNotifier extends Notifier { final quality = item.qualityOverride ?? state.audioQuality; - // Fetch extended metadata (genre, label) from Deezer if available +// Fetch extended metadata (genre, label) from Deezer if available String? genre; String? label; @@ -1667,6 +1667,20 @@ class DownloadQueueNotifier extends Notifier { deezerTrackId = trackToDownload.availability!.deezerId; } + // If no deezerTrackId but we have ISRC, try to find track via ISRC + if (deezerTrackId == null && trackToDownload.isrc != null && trackToDownload.isrc!.isNotEmpty) { + try { + _log.d('No Deezer ID, searching by ISRC: ${trackToDownload.isrc}'); + final deezerResult = await PlatformBridge.searchDeezerByISRC(trackToDownload.isrc!); + if (deezerResult['success'] == true && deezerResult['track_id'] != null) { + deezerTrackId = deezerResult['track_id'].toString(); + _log.d('Found Deezer track ID via ISRC: $deezerTrackId'); + } + } catch (e) { + _log.w('Failed to search Deezer by ISRC: $e'); + } + } + if (deezerTrackId != null && deezerTrackId.isNotEmpty) { try { final extendedMetadata = await PlatformBridge.getDeezerExtendedMetadata(deezerTrackId); diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 6cc2f621..a2ae3f42 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -83,12 +83,13 @@ class _DownloadedAlbumScreenState extends ConsumerState { /// Get tracks for this album from history provider (reactive) List _getAlbumTracks(List allItems) { return allItems.where((item) { - // Use albumArtist if available and not empty, otherwise artistName +// Use albumArtist if available and not empty, otherwise artistName final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty) ? item.albumArtist! : item.artistName; - final itemKey = '${item.albumName}|$itemArtist'; - final albumKey = '${widget.albumName}|${widget.artistName}'; + // Use lowercase for case-insensitive matching + final itemKey = '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}'; + final albumKey = '${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}'; return itemKey == albumKey; }).toList() ..sort((a, b) { diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index c8bd4f7e..cfb68838 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -1,4 +1,6 @@ +import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -19,6 +21,7 @@ class _GroupedAlbum { final String? coverUrl; final List tracks; final DateTime latestDownload; + final String searchKey; _GroupedAlbum({ required this.albumName, @@ -26,7 +29,7 @@ class _GroupedAlbum { this.coverUrl, required this.tracks, required this.latestDownload, - }); + }) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; String get key => '$albumName|$artistName'; } @@ -45,6 +48,42 @@ class _HistoryStats { }); } +Map> _filterHistoryInIsolate( + Map payload, +) { + final entries = (payload['entries'] as List).cast(); + final albumCounts = (payload['albumCounts'] as Map).cast(); + final query = (payload['query'] as String?) ?? ''; + + final allIds = []; + final albumIds = []; + final singleIds = []; + + for (final entry in entries) { + final id = entry[0] as String; + final albumKey = entry[1] as String; + final searchKey = entry[2] as String; + + if (query.isNotEmpty && !searchKey.contains(query)) { + continue; + } + + allIds.add(id); + final count = albumCounts[albumKey] ?? 0; + if (count > 1) { + albumIds.add(id); + } else if (count == 1) { + singleIds.add(id); + } + } + + return { + 'all': allIds, + 'albums': albumIds, + 'singles': singleIds, + }; +} + class QueueTab extends ConsumerStatefulWidget { final PageController? parentPageController; final int parentPageIndex; @@ -73,6 +112,24 @@ class _QueueTabState extends ConsumerState { final List _filterModes = ['all', 'albums', 'singles']; bool _isPageControllerInitialized = false; +// Search functionality + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + String _searchQuery = ''; + Timer? _searchDebounce; + List? _historyItemsCache; + _HistoryStats? _historyStatsCache; + final Map _searchIndexCache = {}; + Map _historyItemsById = {}; + List> _historyFilterEntries = const []; + Map> _filteredHistoryCache = const {}; + List? _filterItemsCache; + String _filterQueryCache = ''; + bool _filterRefreshScheduled = false; + bool _isFilteringHistory = false; + int _filterRequestId = 0; + static const int _filterIsolateThreshold = 800; + @override @@ -88,12 +145,178 @@ class _QueueTabState extends ConsumerState { _filterPageController = PageController(initialPage: initialPage); } - @override +@override void dispose() { _filterPageController?.dispose(); + _searchController.dispose(); + _searchFocusNode.dispose(); + _searchDebounce?.cancel(); super.dispose(); } + void _onSearchChanged(String value) { + _searchDebounce?.cancel(); + final normalized = value.trim().toLowerCase(); + _searchDebounce = Timer(const Duration(milliseconds: 180), () { + if (!mounted || _searchQuery == normalized) return; + setState(() => _searchQuery = normalized); + _requestFilterRefresh(); + }); + } + + void _clearSearch() { + _searchDebounce?.cancel(); + if (_searchQuery.isEmpty) return; + setState(() => _searchQuery = ''); + _requestFilterRefresh(); + } + + void _ensureHistoryCaches(List items) { + if (identical(items, _historyItemsCache)) return; + _historyItemsCache = items; + _historyStatsCache = _buildHistoryStats(items); + _searchIndexCache + ..clear() + ..addEntries( + items.map((item) => MapEntry(item.id, _buildSearchKey(item))), + ); + _historyItemsById = {for (final item in items) item.id: item}; + _historyFilterEntries = List>.generate( + items.length, + (index) { + final item = items[index]; + final searchKey = + _searchIndexCache[item.id] ?? _buildSearchKey(item); +final albumKey = + '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; + return [item.id, albumKey, searchKey]; + }, + growable: false, + ); + _requestFilterRefresh(); + } + + String _buildSearchKey(DownloadHistoryItem item) { + return '${item.trackName} ${item.artistName} ${item.albumName}' + .toLowerCase(); + } + + bool _isFilterCacheValid(List items, String query) { + return identical(items, _filterItemsCache) && query == _filterQueryCache; + } + + void _requestFilterRefresh() { + if (_filterRefreshScheduled) return; + _filterRefreshScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _filterRefreshScheduled = false; + if (!mounted) return; + _scheduleHistoryFilterUpdate(); + }); + } + + void _scheduleHistoryFilterUpdate() { + final items = _historyItemsCache; + if (items == null) return; + final query = _searchQuery; + if (_isFilterCacheValid(items, query)) return; + + final albumCounts = + _historyStatsCache?.albumCounts ?? const {}; + if (items.isEmpty) { + setState(() { + _filteredHistoryCache = const {}; + _filterItemsCache = items; + _filterQueryCache = query; + _isFilteringHistory = false; + }); + return; + } + + if (items.length <= _filterIsolateThreshold) { + final filteredAll = + _filterHistoryItems(items, 'all', albumCounts, query); + final filteredAlbums = + _filterHistoryItems(items, 'albums', albumCounts, query); + final filteredSingles = + _filterHistoryItems(items, 'singles', albumCounts, query); + setState(() { + _filteredHistoryCache = { + 'all': filteredAll, + 'albums': filteredAlbums, + 'singles': filteredSingles, + }; + _filterItemsCache = items; + _filterQueryCache = query; + _isFilteringHistory = false; + }); + return; + } + + if (!_isFilteringHistory) { + setState(() => _isFilteringHistory = true); + } + + final requestId = ++_filterRequestId; + final payload = { + 'entries': _historyFilterEntries, + 'albumCounts': albumCounts, + 'query': query, + }; + + compute(_filterHistoryInIsolate, payload).then((result) { + if (!mounted || requestId != _filterRequestId) return; + final itemsById = _historyItemsById; + final filtered = >{}; + for (final entry in result.entries) { + filtered[entry.key] = entry.value + .map((id) => itemsById[id]) + .whereType() + .toList(growable: false); + } + setState(() { + _filteredHistoryCache = filtered; + _filterItemsCache = items; + _filterQueryCache = query; + _isFilteringHistory = false; + }); + }); + } + + List _resolveHistoryItems({ + required String filterMode, + required List allHistoryItems, + required Map albumCounts, + }) { + final query = _searchQuery; + if (_isFilterCacheValid(allHistoryItems, query)) { + final cached = _filteredHistoryCache[filterMode]; + if (cached != null) return cached; + } + if (allHistoryItems.isEmpty) return const []; + if (query.isEmpty && filterMode == 'all') return allHistoryItems; + if (allHistoryItems.length <= _filterIsolateThreshold) { + return _filterHistoryItems( + allHistoryItems, + filterMode, + albumCounts, + query, + ); + } + return const []; + } + + bool _shouldShowFilteringIndicator({ + required List allHistoryItems, + required String filterMode, + }) { + if (allHistoryItems.isEmpty) return false; + if (_searchQuery.isEmpty && filterMode == 'all') return false; + if (allHistoryItems.length <= _filterIsolateThreshold) return false; + return !_isFilterCacheValid(allHistoryItems, _searchQuery) || + _isFilteringHistory; + } + void _onFilterPageChanged(int index) { final filterMode = _filterModes[index]; ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode); @@ -274,7 +497,8 @@ class _QueueTabState extends ConsumerState { ), ); - _precacheCover(historyItem.coverUrl); +_precacheCover(historyItem.coverUrl); + _searchFocusNode.unfocus(); Navigator.push( context, PageRouteBuilder( @@ -285,11 +509,12 @@ class _QueueTabState extends ConsumerState { transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), ), - ); + ).then((_) => _searchFocusNode.unfocus()); } void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) { _precacheCover(item.coverUrl); + _searchFocusNode.unfocus(); Navigator.push( context, PageRouteBuilder( @@ -300,46 +525,63 @@ class _QueueTabState extends ConsumerState { transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), ), - ); + ).then((_) => _searchFocusNode.unfocus()); } - List _filterHistoryItems( +List _filterHistoryItems( List items, String filterMode, - Map albumCounts, - ) { - if (filterMode == 'all') return items; + Map albumCounts, [ + String searchQuery = '', + ]) { + // First apply search filter + var filteredItems = items; + if (searchQuery.isNotEmpty) { + final query = searchQuery; + filteredItems = items.where((item) { + final searchKey = + _searchIndexCache[item.id] ?? _buildSearchKey(item); + if (!_searchIndexCache.containsKey(item.id)) { + _searchIndexCache[item.id] = searchKey; + } + return searchKey.contains(query); + }).toList(); + } - switch (filterMode) { + // Then apply filter mode + if (filterMode == 'all') return filteredItems; + +switch (filterMode) { case 'albums': - return items.where((item) { + return filteredItems.where((item) { final key = - '${item.albumName}|${item.albumArtist ?? item.artistName}'; + '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; return (albumCounts[key] ?? 0) > 1; }).toList(); case 'singles': - return items.where((item) { + return filteredItems.where((item) { final key = - '${item.albumName}|${item.albumArtist ?? item.artistName}'; + '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; return (albumCounts[key] ?? 0) == 1; }).toList(); default: - return items; + return filteredItems; } } - _HistoryStats _buildHistoryStats(List items) { +_HistoryStats _buildHistoryStats(List items) { final albumCounts = {}; final albumMap = >{}; for (final item in items) { - final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; + // Use lowercase key for case-insensitive grouping + final key = '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; albumCounts[key] = (albumCounts[key] ?? 0) + 1; albumMap.putIfAbsent(key, () => []).add(item); } int singleTracks = 0; for (final item in items) { - final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; + final key = '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; if ((albumCounts[key] ?? 0) <= 1) { singleTracks++; } @@ -380,7 +622,8 @@ class _QueueTabState extends ConsumerState { ); } - void _navigateToDownloadedAlbum(_GroupedAlbum album) { +void _navigateToDownloadedAlbum(_GroupedAlbum album) { + _searchFocusNode.unfocus(); Navigator.push( context, PageRouteBuilder( @@ -395,27 +638,18 @@ class _QueueTabState extends ConsumerState { transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), ), - ); + ).then((_) => _searchFocusNode.unfocus()); } @override Widget build(BuildContext context) { _initializePageController(); - final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items)); - final isProcessing = ref.watch( - downloadQueueProvider.select((s) => s.isProcessing), - ); - final isPaused = ref.watch(downloadQueueProvider.select((s) => s.isPaused)); - final queuedCount = ref.watch( - downloadQueueProvider.select((s) => s.queuedCount), - ); - final completedCount = ref.watch( - downloadQueueProvider.select((s) => s.completedCount), - ); +final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items)); final allHistoryItems = ref.watch( downloadHistoryProvider.select((s) => s.items), ); + _ensureHistoryCaches(allHistoryItems); final historyViewMode = ref.watch( settingsProvider.select((s) => s.historyViewMode), ); @@ -425,7 +659,8 @@ class _QueueTabState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; final topPadding = MediaQuery.of(context).padding.top; - final historyStats = _buildHistoryStats(allHistoryItems); + final historyStats = + _historyStatsCache ?? _buildHistoryStats(allHistoryItems); final groupedAlbums = historyStats.groupedAlbums; final albumCount = historyStats.albumCount; final singleCount = historyStats.singleTracks; @@ -480,68 +715,82 @@ class _QueueTabState extends ConsumerState { ); }, ), - ), +), - if ((isProcessing || queuedCount > 0) && - (queueItems.length > 1 || isPaused)) + // Search bar - always at top + if (allHistoryItems.isNotEmpty || queueItems.isNotEmpty) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: isPaused - ? colorScheme.errorContainer - : colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - isPaused ? Icons.pause : Icons.downloading, - color: isPaused - ? colorScheme.onErrorContainer - : colorScheme.onPrimaryContainer, - ), + child: GestureDetector( + onTap: () {}, + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + autofocus: false, + canRequestFocus: true, + decoration: InputDecoration( + hintText: context.l10n.historySearchHint, + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + _clearSearch(); + FocusScope.of(context).unfocus(); + }, + ) + : null, + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: colorScheme.outlineVariant, + width: 1, ), - const SizedBox(width: 12), - Expanded( - child: Text( - isPaused - ? 'Paused' - : '$completedCount/${queueItems.length}', - style: Theme.of(context).textTheme.titleSmall - ?.copyWith(fontWeight: FontWeight.bold), - ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: colorScheme.outlineVariant, + width: 1.5, ), - FilledButton.tonal( - onPressed: () => ref - .read(downloadQueueProvider.notifier) - .togglePause(), - child: Text(isPaused ? 'Resume' : 'Pause'), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: colorScheme.primary, + width: 2.5, ), - ], + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), ), + onChanged: _onSearchChanged, + onTapOutside: (_) { + FocusScope.of(context).unfocus(); + }, ), ), + ), ), - ), if (queueItems.isNotEmpty) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Text( 'Downloading (${queueItems.length})', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), + ), ), - ), if (queueItems.isNotEmpty) SliverList( @@ -551,7 +800,7 @@ class _QueueTabState extends ConsumerState { key: ValueKey(item.id), child: _buildQueueItem(context, item, colorScheme), ); - }, childCount: queueItems.length), +}, childCount: queueItems.length), ), if (allHistoryItems.isNotEmpty) @@ -655,42 +904,24 @@ class _QueueTabState extends ConsumerState { return false; }, - child: PageView( + child: PageView.builder( controller: _filterPageController!, physics: const ClampingScrollPhysics(), onPageChanged: _onFilterPageChanged, - children: [ - _buildFilterContent( + itemCount: _filterModes.length, + itemBuilder: (context, index) { + final filterMode = _filterModes[index]; + return _buildFilterContent( context: context, colorScheme: colorScheme, - filterMode: 'all', + filterMode: filterMode, allHistoryItems: allHistoryItems, historyViewMode: historyViewMode, queueItems: queueItems, groupedAlbums: groupedAlbums, albumCounts: historyStats.albumCounts, - ), - _buildFilterContent( - context: context, - colorScheme: colorScheme, - filterMode: 'albums', - allHistoryItems: allHistoryItems, - historyViewMode: historyViewMode, - queueItems: queueItems, - groupedAlbums: groupedAlbums, - albumCounts: historyStats.albumCounts, - ), - _buildFilterContent( - context: context, - colorScheme: colorScheme, - filterMode: 'singles', - allHistoryItems: allHistoryItems, - historyViewMode: historyViewMode, - queueItems: queueItems, - groupedAlbums: groupedAlbums, - albumCounts: historyStats.albumCounts, - ), - ], + ); + }, ), ), ), @@ -702,13 +933,13 @@ class _QueueTabState extends ConsumerState { left: 0, right: 0, bottom: _isSelectionMode ? 0 : -(200 + bottomPadding), - child: _buildSelectionBottomBar( +child: _buildSelectionBottomBar( context, colorScheme, - _filterHistoryItems( - allHistoryItems, - historyFilterMode, - historyStats.albumCounts, + _resolveHistoryItems( + filterMode: historyFilterMode, + allHistoryItems: allHistoryItems, + albumCounts: historyStats.albumCounts, ), bottomPadding, ), @@ -726,10 +957,25 @@ class _QueueTabState extends ConsumerState { required String historyViewMode, required List queueItems, required List<_GroupedAlbum> groupedAlbums, - required Map albumCounts, +required Map albumCounts, }) { - final historyItems = - _filterHistoryItems(allHistoryItems, filterMode, albumCounts); +final historyItems = _resolveHistoryItems( + filterMode: filterMode, + allHistoryItems: allHistoryItems, + albumCounts: albumCounts, + ); + final showFilteringIndicator = _shouldShowFilteringIndicator( + allHistoryItems: allHistoryItems, + filterMode: filterMode, + ); + + // Filter grouped albums based on search query + final searchQuery = _searchQuery; + final filteredGroupedAlbums = searchQuery.isEmpty + ? groupedAlbums + : groupedAlbums + .where((album) => album.searchKey.contains(searchQuery)) + .toList(); return CustomScrollView( slivers: [ @@ -763,14 +1009,14 @@ class _QueueTabState extends ConsumerState { ), ), - if (groupedAlbums.isNotEmpty && +if (filteredGroupedAlbums.isNotEmpty && queueItems.isEmpty && filterMode == 'albums') SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Text( - '${groupedAlbums.length} ${groupedAlbums.length == 1 ? 'album' : 'albums'}', + '${filteredGroupedAlbums.length} ${filteredGroupedAlbums.length == 1 ? 'album' : 'albums'}', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -791,7 +1037,33 @@ class _QueueTabState extends ConsumerState { ), ), - if (filterMode == 'albums' && groupedAlbums.isNotEmpty) + if (showFilteringIndicator) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Row( + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Text( + 'Filtering...', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + +if (filterMode == 'albums' && filteredGroupedAlbums.isNotEmpty) SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), sliver: SliverGrid( @@ -803,12 +1075,12 @@ class _QueueTabState extends ConsumerState { childAspectRatio: 0.75, ), delegate: SliverChildBuilderDelegate((context, index) { - final album = groupedAlbums[index]; + final album = filteredGroupedAlbums[index]; return KeyedSubtree( key: ValueKey(album.key), child: _buildAlbumGridItem(context, album, colorScheme), ); - }, childCount: groupedAlbums.length), + }, childCount: filteredGroupedAlbums.length), ), ), @@ -854,9 +1126,10 @@ class _QueueTabState extends ConsumerState { }, childCount: historyItems.length ), ), - if (queueItems.isEmpty && +if (queueItems.isEmpty && historyItems.isEmpty && - (filterMode != 'albums' || groupedAlbums.isEmpty)) + (filterMode != 'albums' || filteredGroupedAlbums.isEmpty) && + !showFilteringIndicator) SliverFillRemaining( hasScrollBody: false, child: _buildEmptyState( diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index a68d1f03..826bc1b9 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -157,7 +157,7 @@ class AboutPage extends StatelessWidget { onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'), showDivider: true, ), - _AboutSettingsItem( +_AboutSettingsItem( icon: Icons.lightbulb_outline, title: context.l10n.aboutFeatureRequest, subtitle: context.l10n.aboutFeatureRequestSubtitle, @@ -168,6 +168,30 @@ class AboutPage extends StatelessWidget { ), ), + SliverToBoxAdapter( + child: SettingsSectionHeader(title: context.l10n.aboutSocial), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + _AboutSettingsItem( + icon: Icons.telegram, + title: context.l10n.aboutTelegramChannel, + subtitle: context.l10n.aboutTelegramChannelSubtitle, + onTap: () => _launchUrl('https://t.me/spotiflac'), + showDivider: true, + ), + _AboutSettingsItem( + icon: Icons.forum_outlined, + title: context.l10n.aboutTelegramChat, + subtitle: context.l10n.aboutTelegramChatSubtitle, + onTap: () => _launchUrl('https://t.me/spotiflacchat'), + showDivider: false, + ), + ], + ), + ), + SliverToBoxAdapter( child: SettingsSectionHeader(title: context.l10n.aboutSupport), ), From c62ad005f594692c381756c8eddb94cfb2315fa3 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 20 Jan 2026 09:58:31 +0700 Subject: [PATCH 02/19] docs: update README and release workflow --- .github/workflows/release.yml | 95 +++++++++++++++++++++++++++++++++++ README.md | 14 +++++- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d79b15f1..9938d2c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -412,3 +412,98 @@ jobs: prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + notify-telegram: + runs-on: ubuntu-latest + needs: [get-version, create-release] + if: ${{ needs.get-version.outputs.is_prerelease != 'true' }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download Android APK + uses: actions/download-artifact@v4 + with: + name: android-apk + path: ./release + + - name: Extract changelog for version + id: changelog + run: | + VERSION=${{ needs.get-version.outputs.version }} + VERSION_NUM=${VERSION#v} + + # Extract changelog, limit to ~1500 chars for Telegram (4096 limit minus message overhead) + FULL_CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md | sed '/^---$/d') + + if [ -z "$FULL_CHANGELOG" ]; then + CHANGELOG="See release notes on GitHub for details." + else + # Take first 1500 characters, then cut at last complete line + CHANGELOG=$(echo "$FULL_CHANGELOG" | head -c 1500 | sed '$d') + + # Check if truncated + FULL_LEN=${#FULL_CHANGELOG} + if [ $FULL_LEN -gt 1500 ]; then + CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)' + fi + fi + + echo "$CHANGELOG" > /tmp/changelog.txt + + - name: Send to Telegram Channel + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHANNEL_ID: ${{ secrets.TELEGRAM_CHANNEL_ID }} + run: | + VERSION=${{ needs.get-version.outputs.version }} + CHANGELOG=$(cat /tmp/changelog.txt) + + # Find APK files + ARM64_APK=$(find ./release -name "*arm64*.apk" | head -1) + ARM32_APK=$(find ./release -name "*arm32*.apk" | head -1) + + # Prepare message with changelog + printf '%s\n' \ + "*SpotiFLAC ${VERSION} Released!*" \ + "" \ + "*What's New:*" \ + "${CHANGELOG}" \ + "" \ + "*Download:*" \ + "- [arm64 APK](https://github.com/${{ github.repository }}/releases/download/${VERSION}/SpotiFLAC-${VERSION}-arm64.apk) (recommended)" \ + "- [arm32 APK](https://github.com/${{ github.repository }}/releases/download/${VERSION}/SpotiFLAC-${VERSION}-arm32.apk)" \ + "- [iOS IPA](https://github.com/${{ github.repository }}/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa)" \ + "" \ + "[View Release Notes](https://github.com/${{ github.repository }}/releases/tag/${VERSION})" \ + > /tmp/telegram_message.txt + + MESSAGE=$(cat /tmp/telegram_message.txt) + + # Send message first + curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -d chat_id="${TELEGRAM_CHANNEL_ID}" \ + -d text="${MESSAGE}" \ + -d parse_mode="Markdown" \ + -d disable_web_page_preview="true" + + # Upload arm64 APK to channel + if [ -f "$ARM64_APK" ]; then + echo "Uploading arm64 APK to Telegram..." + curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \ + -F chat_id="${TELEGRAM_CHANNEL_ID}" \ + -F document=@"${ARM64_APK}" \ + -F caption="SpotiFLAC ${VERSION} - arm64 (recommended)" + fi + + # Upload arm32 APK to channel + if [ -f "$ARM32_APK" ]; then + echo "Uploading arm32 APK to Telegram..." + curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \ + -F chat_id="${TELEGRAM_CHANNEL_ID}" \ + -F document=@"${ARM32_APK}" \ + -F caption="SpotiFLAC ${VERSION} - arm32" + fi + + echo "Telegram notification sent!" diff --git a/README.md b/README.md index 872a5310..b0df7bf7 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,16 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc ![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white) ![iOS](https://img.shields.io/badge/iOS-14.0%2B-000000?style=for-the-badge&logo=apple&logoColor=white) +

+ + Telegram Channel + + + + Telegram Community + +

+ ### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases) @@ -52,6 +62,8 @@ Want to create your own extension? Check out the [Extension Development Guide](h ### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC) Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux +> **Note:** Currently unavailable because the GitHub account is suspended. Alternatively, use [SpotiFLAC-Next](https://github.com/spotiverse/SpotiFLAC-Next) until the original is restored. + ## FAQ **Q: Why is my download failing with "Song not found"?** @@ -69,8 +81,6 @@ A: The app needs permission to save downloaded files to your device. On Android **Q: Is this app safe?** A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README). -[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20Me-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/zarzet) - ## Disclaimer This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement. From d960708dac7c1787d28bada3270c22ecad498e52 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 20 Jan 2026 10:08:35 +0700 Subject: [PATCH 03/19] feat: improve Telegram notification - upload IPA, remove redundant links, increase changelog limit --- .github/workflows/release.yml | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9938d2c4..92fdd545 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -428,24 +428,30 @@ jobs: name: android-apk path: ./release + - name: Download iOS IPA + uses: actions/download-artifact@v4 + with: + name: ios-ipa + path: ./release + - name: Extract changelog for version id: changelog run: | VERSION=${{ needs.get-version.outputs.version }} VERSION_NUM=${VERSION#v} - # Extract changelog, limit to ~1500 chars for Telegram (4096 limit minus message overhead) + # Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead) FULL_CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md | sed '/^---$/d') if [ -z "$FULL_CHANGELOG" ]; then CHANGELOG="See release notes on GitHub for details." else - # Take first 1500 characters, then cut at last complete line - CHANGELOG=$(echo "$FULL_CHANGELOG" | head -c 1500 | sed '$d') + # Take first 2500 characters, then cut at last complete line + CHANGELOG=$(echo "$FULL_CHANGELOG" | head -c 2500 | sed '$d') # Check if truncated FULL_LEN=${#FULL_CHANGELOG} - if [ $FULL_LEN -gt 1500 ]; then + if [ $FULL_LEN -gt 2500 ]; then CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)' fi fi @@ -464,18 +470,13 @@ jobs: ARM64_APK=$(find ./release -name "*arm64*.apk" | head -1) ARM32_APK=$(find ./release -name "*arm32*.apk" | head -1) - # Prepare message with changelog + # Prepare message with changelog (files uploaded separately) printf '%s\n' \ - "*SpotiFLAC ${VERSION} Released!*" \ + "*SpotiFLAC Mobile ${VERSION} Released!*" \ "" \ "*What's New:*" \ "${CHANGELOG}" \ "" \ - "*Download:*" \ - "- [arm64 APK](https://github.com/${{ github.repository }}/releases/download/${VERSION}/SpotiFLAC-${VERSION}-arm64.apk) (recommended)" \ - "- [arm32 APK](https://github.com/${{ github.repository }}/releases/download/${VERSION}/SpotiFLAC-${VERSION}-arm32.apk)" \ - "- [iOS IPA](https://github.com/${{ github.repository }}/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa)" \ - "" \ "[View Release Notes](https://github.com/${{ github.repository }}/releases/tag/${VERSION})" \ > /tmp/telegram_message.txt @@ -506,4 +507,14 @@ jobs: -F caption="SpotiFLAC ${VERSION} - arm32" fi + # Upload iOS IPA to channel + IOS_IPA=$(find ./release -name "*ios*.ipa" | head -1) + if [ -f "$IOS_IPA" ]; then + echo "Uploading iOS IPA to Telegram..." + curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \ + -F chat_id="${TELEGRAM_CHANNEL_ID}" \ + -F document=@"${IOS_IPA}" \ + -F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)" + fi + echo "Telegram notification sent!" From e725a7be7724c0bf3968c9ed61e6bd250211d8e1 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 20 Jan 2026 10:12:01 +0700 Subject: [PATCH 04/19] feat: convert GitHub Markdown to Telegram format in release notification --- .github/workflows/release.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 92fdd545..b8d64d08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -446,8 +446,20 @@ jobs: if [ -z "$FULL_CHANGELOG" ]; then CHANGELOG="See release notes on GitHub for details." else + # Convert GitHub Markdown to Telegram Markdown: + # - **text** → *text* (GitHub bold to Telegram bold) + # - ### Header → *Header* (headers to bold) + # - Add extra line break before major list items for readability + CHANGELOG=$(echo "$FULL_CHANGELOG" | \ + sed 's/\*\*\([^*]*\)\*\*/*\1*/g' | \ + sed 's/^### \(.*\)$/*\1*/g' | \ + sed 's/^## \(.*\)$/*\1*/g' | \ + sed 's/^- \*\*\([^:]*\):\*\*/\n• *\1:*/g' | \ + sed 's/^- /• /g' | \ + sed 's/^ - / ◦ /g') + # Take first 2500 characters, then cut at last complete line - CHANGELOG=$(echo "$FULL_CHANGELOG" | head -c 2500 | sed '$d') + CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d') # Check if truncated FULL_LEN=${#FULL_CHANGELOG} From 79180dd9182d12be730a339afddea1b2736d0ffa Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 21 Jan 2026 08:30:44 +0700 Subject: [PATCH 05/19] feat: add Home Feed with pull-to-refresh and gobackend.getLocalTime() API - Add Home Feed/Explore feature with extension capabilities system - Add pull-to-refresh on home feed (replaces refresh button) - Add gobackend.getLocalTime() API for accurate device timezone detection - Add YT Music Quick Picks UI with swipeable vertical format - Fix greeting time showing wrong time due to Goja getTimezoneOffset() returning 0 - Update spotify-web and ytmusic extensions to use getLocalTime() - Add Turkish language support - Update CHANGELOG for v3.2.0 --- CHANGELOG.md | 72 + README.md | 25 +- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 15 + go_backend/exports.go | 82 + go_backend/extension_manager.go | 44 +- go_backend/extension_manifest.go | 37 +- go_backend/extension_runtime_utils.go | 21 + ios/Runner/AppDelegate.swift | 15 + lib/l10n/app_localizations.dart | 5 + lib/l10n/app_localizations_tr.dart | 2085 +++++++++++++++++ lib/l10n/arb/app_tr.arb | 7 + lib/providers/explore_provider.dart | 220 ++ lib/providers/extension_provider.dart | 7 + lib/screens/home_tab.dart | 622 ++++- .../settings/appearance_settings_page.dart | 1 + lib/services/platform_bridge.dart | 28 + 16 files changed, 3231 insertions(+), 55 deletions(-) create mode 100644 lib/l10n/app_localizations_tr.dart create mode 100644 lib/l10n/arb/app_tr.arb create mode 100644 lib/providers/explore_provider.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 05962b4e..495d9f1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,77 @@ # Changelog +## [3.2.0] - 2026-01-22 + +### Added + +- **Home Feed / Explore Feature**: Personalized home feed sections on the home screen + - Works with any extension that has `homeFeed` capability + - Displays sections like "Trending Songs", "Quick Picks", "New Releases", etc. + - Each item shows thumbnail, name, artist, and supports navigation to album/artist/playlist + - Prefers spotify-web if available, otherwise uses first available homeFeed extension + +- **Extension Capabilities System**: Extensions can now declare capabilities in manifest + - New `capabilities` field in extension manifest (e.g., `{ "homeFeed": true, "browseCategories": true }`) + - `Extension` model now has `hasHomeFeed` and `hasBrowseCategories` getters + - Capabilities are parsed from Go backend and exposed to Flutter + +- **YT Music Quick Picks UI**: Special vertical swipeable format for YT Music track sections + - Detects YT Music track-only sections and renders them differently + - PageView with 5 tracks per page, swipeable left/right + - Animated page indicator dots (active = primary color, inactive = gray) + - Each item shows: 48x48 thumbnail, track name, artist, 3-dot menu button + +- **Pull-to-Refresh on Home Feed**: Swipe down to refresh explore sections + - Replaced refresh button next to greeting with pull-to-refresh gesture + - Only active when explore sections are displayed + - Cleaner UI with greeting text only (no refresh icon) + +- **`gobackend.getLocalTime()` API for Extensions**: New utility function to get accurate device local time + - Returns: `{ year, month, day, hour, minute, second, weekday, offsetMinutes, timezone, timestamp }` + - Uses Go's `time.Now()` for accurate device timezone detection + - Solves Goja JS engine's `getTimezoneOffset()` returning 0 issue + +### Fixed + +- **YT Music Greeting Time**: Fixed "Good night" showing in the morning + - Root cause: Goja JS engine returns `getTimezoneOffset() = 0` instead of actual offset + - Now uses `gobackend.getLocalTime().hour` for accurate local hour + - Greeting correctly shows "Good morning/afternoon/evening/night" based on device time + +- **Spotify Home Feed Timezone**: Fixed timezone detection for Spotify API calls + - Now uses `gobackend.getLocalTime().timezone` or offset mapping + - Ensures personalized content is based on correct user timezone + +### Extensions + +- **spotify-web Extension**: Updated to v1.8.0 + - Added `capabilities: { homeFeed: true, browseCategories: true }` to manifest + - `fetchHomeFeed()` now uses `gobackend.getLocalTime()` for timezone detection + - Removed reliance on Goja's broken `getTimezoneOffset()` and `Intl.DateTimeFormat()` + +- **ytmusic-spotiflac Extension**: Updated to v1.6.0 + - Added `capabilities: { homeFeed: true }` to manifest + - `getTimeBasedGreeting()` now uses `gobackend.getLocalTime().hour` directly + - Simplified greeting logic - no more manual UTC offset calculations + +### Technical + +- **Go Backend**: Added `getLocalTime` function to `RegisterGoBackendAPIs()` + - File: `go_backend/extension_runtime_utils.go` + - Returns device local time with timezone info via `time.Now()` + - Offset follows JS convention (negative for east of UTC) + +- **Flutter Explore Provider**: Updated extension selection logic + - File: `lib/providers/explore_provider.dart` + - Finds extensions with `hasHomeFeed` capability + - Prefers spotify-web if available, falls back to first available + +- **Flutter Home Tab**: Refactored explore sections rendering + - File: `lib/screens/home_tab.dart` + - Added `RefreshIndicator` wrapper with `notificationPredicate` for conditional refresh + - Added `_buildYTMusicQuickPicksSection()` for special YT Music format + - Added `_QuickPicksPageView` StatefulWidget for swipeable track pages + ## [3.1.3] - 2026-01-19 ### Added diff --git a/README.md b/README.md index b0df7bf7..0e2811d1 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,6 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc ![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white) ![iOS](https://img.shields.io/badge/iOS-14.0%2B-000000?style=for-the-badge&logo=apple&logoColor=white) -

- - Telegram Channel - - - - Telegram Community - -

- ### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases) @@ -64,6 +54,18 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window > **Note:** Currently unavailable because the GitHub account is suspended. Alternatively, use [SpotiFLAC-Next](https://github.com/spotiverse/SpotiFLAC-Next) until the original is restored. +## Telegram + +

+ + Telegram Channel + + + + Telegram Community + +

+ ## FAQ **Q: Why is my download failing with "Song not found"?** @@ -81,6 +83,9 @@ A: The app needs permission to save downloaded files to your device. On Android **Q: Is this app safe?** A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README). +**Q: Why is download not working in my country?** +A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region. + ## Disclaimer This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement. diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 625bfd66..85a32ffe 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -678,6 +678,21 @@ class MainActivity: FlutterActivity() { } result.success(null) } + // Extension Home Feed (Explore) + "getExtensionHomeFeed" -> { + val extensionId = call.argument("extension_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getExtensionHomeFeedJSON(extensionId) + } + result.success(response) + } + "getExtensionBrowseCategories" -> { + val extensionId = call.argument("extension_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getExtensionBrowseCategoriesJSON(extensionId) + } + result.success(response) + } else -> result.notImplemented() } } catch (e: Exception) { diff --git a/go_backend/exports.go b/go_backend/exports.go index 17112d93..6c718a52 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -2082,3 +2082,85 @@ func ClearStoreCacheJSON() error { store.ClearCache() return nil } + +// GetExtensionHomeFeedJSON calls getHomeFeed on any extension that supports it +func GetExtensionHomeFeedJSON(extensionID string) (string, error) { + manager := GetExtensionManager() + ext, err := manager.GetExtension(extensionID) + if err != nil { + return "", err + } + + if !ext.Enabled { + return "", fmt.Errorf("extension '%s' is disabled", extensionID) + } + + provider := NewExtensionProviderWrapper(ext) + + script := ` + (function() { + if (typeof extension !== 'undefined' && typeof extension.getHomeFeed === 'function') { + return extension.getHomeFeed(); + } + return null; + })() + ` + + result, err := RunWithTimeoutAndRecover(provider.vm, script, 60*time.Second) + if err != nil { + return "", fmt.Errorf("getHomeFeed failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return "", fmt.Errorf("getHomeFeed returned null") + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return "", fmt.Errorf("failed to marshal result: %w", err) + } + + return string(jsonBytes), nil +} + +// GetExtensionBrowseCategoriesJSON calls getBrowseCategories on any extension that supports it +func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) { + manager := GetExtensionManager() + ext, err := manager.GetExtension(extensionID) + if err != nil { + return "", err + } + + if !ext.Enabled { + return "", fmt.Errorf("extension '%s' is disabled", extensionID) + } + + provider := NewExtensionProviderWrapper(ext) + + script := ` + (function() { + if (typeof extension !== 'undefined' && typeof extension.getBrowseCategories === 'function') { + return extension.getBrowseCategories(); + } + return null; + })() + ` + + result, err := RunWithTimeoutAndRecover(provider.vm, script, 30*time.Second) + if err != nil { + return "", fmt.Errorf("getBrowseCategories failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return "", fmt.Errorf("getBrowseCategories returned null") + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return "", fmt.Errorf("failed to marshal result: %w", err) + } + + return string(jsonBytes), nil +} diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index baa4ba72..706a32b9 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -719,27 +719,28 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) { extensions := m.GetAllExtensions() type ExtensionInfo struct { - ID string `json:"id"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - Version string `json:"version"` - Author string `json:"author"` - Description string `json:"description"` - Homepage string `json:"homepage,omitempty"` - IconPath string `json:"icon_path,omitempty"` - Types []ExtensionType `json:"types"` - Enabled bool `json:"enabled"` - Status string `json:"status"` - Error string `json:"error_message,omitempty"` - Settings []ExtensionSetting `json:"settings,omitempty"` - QualityOptions []QualityOption `json:"quality_options,omitempty"` - Permissions []string `json:"permissions"` - HasMetadataProvider bool `json:"has_metadata_provider"` - HasDownloadProvider bool `json:"has_download_provider"` - SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"` - SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"` - TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"` - PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + Homepage string `json:"homepage,omitempty"` + IconPath string `json:"icon_path,omitempty"` + Types []ExtensionType `json:"types"` + Enabled bool `json:"enabled"` + Status string `json:"status"` + Error string `json:"error_message,omitempty"` + Settings []ExtensionSetting `json:"settings,omitempty"` + QualityOptions []QualityOption `json:"quality_options,omitempty"` + Permissions []string `json:"permissions"` + HasMetadataProvider bool `json:"has_metadata_provider"` + HasDownloadProvider bool `json:"has_download_provider"` + SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"` + SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"` + TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"` + PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"` + Capabilities map[string]interface{} `json:"capabilities,omitempty"` } infos := make([]ExtensionInfo, len(extensions)) @@ -796,6 +797,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) { SearchBehavior: ext.Manifest.SearchBehavior, TrackMatching: ext.Manifest.TrackMatching, PostProcessing: ext.Manifest.PostProcessing, + Capabilities: ext.Manifest.Capabilities, } } diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index 7a850a55..65740067 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -107,24 +107,25 @@ type PostProcessingConfig struct { // ExtensionManifest represents the manifest.json of an extension type ExtensionManifest struct { - Name string `json:"name"` - DisplayName string `json:"displayName"` - Version string `json:"version"` - Author string `json:"author"` - Description string `json:"description"` - Homepage string `json:"homepage,omitempty"` - Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png") - Types []ExtensionType `json:"type"` - Permissions ExtensionPermissions `json:"permissions"` - Settings []ExtensionSetting `json:"settings,omitempty"` - QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers - MinAppVersion string `json:"minAppVersion,omitempty"` - SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify - SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon) - SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior - URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling - TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching - PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks + Name string `json:"name"` + DisplayName string `json:"displayName"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + Homepage string `json:"homepage,omitempty"` + Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png") + Types []ExtensionType `json:"type"` + Permissions ExtensionPermissions `json:"permissions"` + Settings []ExtensionSetting `json:"settings,omitempty"` + QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers + MinAppVersion string `json:"minAppVersion,omitempty"` + SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify + SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon) + SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior + URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling + TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching + PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks + Capabilities map[string]interface{} `json:"capabilities,omitempty"` // Extension capabilities (homeFeed, browseCategories, etc.) } // ManifestValidationError represents a validation error in the manifest diff --git a/go_backend/extension_runtime_utils.go b/go_backend/extension_runtime_utils.go index 37d86920..568b2366 100644 --- a/go_backend/extension_runtime_utils.go +++ b/go_backend/extension_runtime_utils.go @@ -12,6 +12,7 @@ import ( "encoding/json" "fmt" "strings" + "time" "github.com/dop251/goja" ) @@ -371,4 +372,24 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) { return vm.ToValue(buildFilenameFromTemplate(template, metadata)) }) + + // Expose getLocalTime - returns device local time info + obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value { + now := time.Now() + _, offsetSeconds := now.Zone() + offsetMinutes := offsetSeconds / 60 + + return vm.ToValue(map[string]interface{}{ + "year": now.Year(), + "month": int(now.Month()), + "day": now.Day(), + "hour": now.Hour(), + "minute": now.Minute(), + "second": now.Second(), + "weekday": int(now.Weekday()), + "offsetMinutes": -offsetMinutes, // JS convention: negative for east of UTC + "timezone": now.Location().String(), + "timestamp": now.Unix(), + }) + }) } diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c6a373d7..31538db2 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -605,6 +605,21 @@ import Gobackend // Import Go framework if let error = error { throw error } return nil + // Extension Home Feed API + case "getExtensionHomeFeed": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let response = GobackendGetExtensionHomeFeedJSON(extensionId, &error) + if let error = error { throw error } + return response + + case "getExtensionBrowseCategories": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let response = GobackendGetExtensionBrowseCategoriesJSON(extensionId, &error) + if let error = error { throw error } + return response + default: throw NSError( domain: "SpotiFLAC", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f450dcd6..e2c92ab4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -16,6 +16,7 @@ import 'app_localizations_ko.dart'; import 'app_localizations_nl.dart'; import 'app_localizations_pt.dart'; import 'app_localizations_ru.dart'; +import 'app_localizations_tr.dart'; import 'app_localizations_zh.dart'; // ignore_for_file: type=lint @@ -117,6 +118,7 @@ abstract class AppLocalizations { Locale('pt'), Locale('pt', 'PT'), Locale('ru'), + Locale('tr'), Locale('zh'), Locale('zh', 'CN'), Locale('zh', 'TW'), @@ -3811,6 +3813,7 @@ class _AppLocalizationsDelegate 'nl', 'pt', 'ru', + 'tr', 'zh', ].contains(locale.languageCode); @@ -3873,6 +3876,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) { return AppLocalizationsPt(); case 'ru': return AppLocalizationsRu(); + case 'tr': + return AppLocalizationsTr(); case 'zh': return AppLocalizationsZh(); } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart new file mode 100644 index 00000000..e94fd348 --- /dev/null +++ b/lib/l10n/app_localizations_tr.dart @@ -0,0 +1,2085 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Turkish (`tr`). +class AppLocalizationsTr extends AppLocalizations { + AppLocalizationsTr([String locale = 'tr']) : super(locale); + + @override + String get appName => 'SpotiFLAC'; + + @override + String get appDescription => + 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + + @override + String get navHome => 'Home'; + + @override + String get navHistory => 'History'; + + @override + String get navSettings => 'Settings'; + + @override + String get navStore => 'Store'; + + @override + String get homeTitle => 'Home'; + + @override + String get homeSearchHint => 'Paste Spotify URL or search...'; + + @override + String homeSearchHintExtension(String extensionName) { + return 'Search with $extensionName...'; + } + + @override + String get homeSubtitle => 'Paste a Spotify link or search by name'; + + @override + String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; + + @override + String get homeRecent => 'Recent'; + + @override + String get historyTitle => 'History'; + + @override + String historyDownloading(int count) { + return 'Downloading ($count)'; + } + + @override + String get historyDownloaded => 'Downloaded'; + + @override + String get historyFilterAll => 'All'; + + @override + String get historyFilterAlbums => 'Albums'; + + @override + String get historyFilterSingles => 'Singles'; + + @override + String historyTracksCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String historyAlbumsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get historyNoDownloads => 'No download history'; + + @override + String get historyNoDownloadsSubtitle => 'Downloaded tracks will appear here'; + + @override + String get historyNoAlbums => 'No album downloads'; + + @override + String get historyNoAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get historyNoSingles => 'No single downloads'; + + @override + String get historyNoSinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get historySearchHint => 'Search history...'; + + @override + String get settingsTitle => 'Settings'; + + @override + String get settingsDownload => 'Download'; + + @override + String get settingsAppearance => 'Appearance'; + + @override + String get settingsOptions => 'Options'; + + @override + String get settingsExtensions => 'Extensions'; + + @override + String get settingsAbout => 'About'; + + @override + String get downloadTitle => 'Download'; + + @override + String get downloadLocation => 'Download Location'; + + @override + String get downloadLocationSubtitle => 'Choose where to save files'; + + @override + String get downloadLocationDefault => 'Default location'; + + @override + String get downloadDefaultService => 'Default Service'; + + @override + String get downloadDefaultServiceSubtitle => 'Service used for downloads'; + + @override + String get downloadDefaultQuality => 'Default Quality'; + + @override + String get downloadAskQuality => 'Ask Quality Before Download'; + + @override + String get downloadAskQualitySubtitle => + 'Show quality picker for each download'; + + @override + String get downloadFilenameFormat => 'Filename Format'; + + @override + String get downloadFolderOrganization => 'Folder Organization'; + + @override + String get downloadSeparateSingles => 'Separate Singles'; + + @override + String get downloadSeparateSinglesSubtitle => + 'Put single tracks in a separate folder'; + + @override + String get qualityBest => 'Best Available'; + + @override + String get qualityFlac => 'FLAC'; + + @override + String get quality320 => '320 kbps'; + + @override + String get quality128 => '128 kbps'; + + @override + String get appearanceTitle => 'Appearance'; + + @override + String get appearanceTheme => 'Theme'; + + @override + String get appearanceThemeSystem => 'System'; + + @override + String get appearanceThemeLight => 'Light'; + + @override + String get appearanceThemeDark => 'Dark'; + + @override + String get appearanceDynamicColor => 'Dynamic Color'; + + @override + String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; + + @override + String get appearanceAccentColor => 'Accent Color'; + + @override + String get appearanceHistoryView => 'History View'; + + @override + String get appearanceHistoryViewList => 'List'; + + @override + String get appearanceHistoryViewGrid => 'Grid'; + + @override + String get optionsTitle => 'Options'; + + @override + String get optionsSearchSource => 'Search Source'; + + @override + String get optionsPrimaryProvider => 'Primary Provider'; + + @override + String get optionsPrimaryProviderSubtitle => + 'Service used when searching by track name.'; + + @override + String optionsUsingExtension(String extensionName) { + return 'Using extension: $extensionName'; + } + + @override + String get optionsSwitchBack => + 'Tap Deezer or Spotify to switch back from extension'; + + @override + String get optionsAutoFallback => 'Auto Fallback'; + + @override + String get optionsAutoFallbackSubtitle => + 'Try other services if download fails'; + + @override + String get optionsUseExtensionProviders => 'Use Extension Providers'; + + @override + String get optionsUseExtensionProvidersOn => 'Extensions will be tried first'; + + @override + String get optionsUseExtensionProvidersOff => 'Using built-in providers only'; + + @override + String get optionsEmbedLyrics => 'Embed Lyrics'; + + @override + String get optionsEmbedLyricsSubtitle => + 'Embed synced lyrics into FLAC files'; + + @override + String get optionsMaxQualityCover => 'Max Quality Cover'; + + @override + String get optionsMaxQualityCoverSubtitle => + 'Download highest resolution cover art'; + + @override + String get optionsConcurrentDownloads => 'Concurrent Downloads'; + + @override + String get optionsConcurrentSequential => 'Sequential (1 at a time)'; + + @override + String optionsConcurrentParallel(int count) { + return '$count parallel downloads'; + } + + @override + String get optionsConcurrentWarning => + 'Parallel downloads may trigger rate limiting'; + + @override + String get optionsExtensionStore => 'Extension Store'; + + @override + String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation'; + + @override + String get optionsCheckUpdates => 'Check for Updates'; + + @override + String get optionsCheckUpdatesSubtitle => + 'Notify when new version is available'; + + @override + String get optionsUpdateChannel => 'Update Channel'; + + @override + String get optionsUpdateChannelStable => 'Stable releases only'; + + @override + String get optionsUpdateChannelPreview => 'Get preview releases'; + + @override + String get optionsUpdateChannelWarning => + 'Preview may contain bugs or incomplete features'; + + @override + String get optionsClearHistory => 'Clear Download History'; + + @override + String get optionsClearHistorySubtitle => + 'Remove all downloaded tracks from history'; + + @override + String get optionsDetailedLogging => 'Detailed Logging'; + + @override + String get optionsDetailedLoggingOn => 'Detailed logs are being recorded'; + + @override + String get optionsDetailedLoggingOff => 'Enable for bug reports'; + + @override + String get optionsSpotifyCredentials => 'Spotify Credentials'; + + @override + String optionsSpotifyCredentialsConfigured(String clientId) { + return 'Client ID: $clientId...'; + } + + @override + String get optionsSpotifyCredentialsRequired => 'Required - tap to configure'; + + @override + String get optionsSpotifyWarning => + 'Spotify requires your own API credentials. Get them free from developer.spotify.com'; + + @override + String get extensionsTitle => 'Extensions'; + + @override + String get extensionsInstalled => 'Installed Extensions'; + + @override + String get extensionsNone => 'No extensions installed'; + + @override + String get extensionsNoneSubtitle => 'Install extensions from the Store tab'; + + @override + String get extensionsEnabled => 'Enabled'; + + @override + String get extensionsDisabled => 'Disabled'; + + @override + String extensionsVersion(String version) { + return 'Version $version'; + } + + @override + String extensionsAuthor(String author) { + return 'by $author'; + } + + @override + String get extensionsUninstall => 'Uninstall'; + + @override + String get extensionsSetAsSearch => 'Set as Search Provider'; + + @override + String get storeTitle => 'Extension Store'; + + @override + String get storeSearch => 'Search extensions...'; + + @override + String get storeInstall => 'Install'; + + @override + String get storeInstalled => 'Installed'; + + @override + String get storeUpdate => 'Update'; + + @override + String get aboutTitle => 'About'; + + @override + String get aboutContributors => 'Contributors'; + + @override + String get aboutMobileDeveloper => 'Mobile version developer'; + + @override + String get aboutOriginalCreator => 'Creator of the original SpotiFLAC'; + + @override + String get aboutLogoArtist => + 'The talented artist who created our beautiful app logo!'; + + @override + String get aboutTranslators => 'Translators'; + + @override + String get aboutSpecialThanks => 'Special Thanks'; + + @override + String get aboutLinks => 'Links'; + + @override + String get aboutMobileSource => 'Mobile source code'; + + @override + String get aboutPCSource => 'PC source code'; + + @override + String get aboutReportIssue => 'Report an issue'; + + @override + String get aboutReportIssueSubtitle => 'Report any problems you encounter'; + + @override + String get aboutFeatureRequest => 'Feature request'; + + @override + String get aboutFeatureRequestSubtitle => 'Suggest new features for the app'; + + @override + String get aboutTelegramChannel => 'Telegram Channel'; + + @override + String get aboutTelegramChannelSubtitle => 'Announcements and updates'; + + @override + String get aboutTelegramChat => 'Telegram Community'; + + @override + String get aboutTelegramChatSubtitle => 'Chat with other users'; + + @override + String get aboutSocial => 'Social'; + + @override + String get aboutSupport => 'Support'; + + @override + String get aboutBuyMeCoffee => 'Buy me a coffee'; + + @override + String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi'; + + @override + String get aboutApp => 'App'; + + @override + String get aboutVersion => 'Version'; + + @override + String get aboutBinimumDesc => + 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!'; + + @override + String get aboutSachinsenalDesc => + 'The original HiFi project creator. The foundation of Tidal integration!'; + + @override + String get aboutDoubleDouble => 'DoubleDouble'; + + @override + String get aboutDoubleDoubleDesc => + 'Amazing API for Amazon Music downloads. Thank you for making it free!'; + + @override + String get aboutDabMusic => 'DAB Music'; + + @override + String get aboutDabMusicDesc => + 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; + + @override + String get aboutAppDescription => + 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + + @override + String get albumTitle => 'Album'; + + @override + String albumTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String get albumDownloadAll => 'Download All'; + + @override + String get albumDownloadRemaining => 'Download Remaining'; + + @override + String get playlistTitle => 'Playlist'; + + @override + String get artistTitle => 'Artist'; + + @override + String get artistAlbums => 'Albums'; + + @override + String get artistSingles => 'Singles & EPs'; + + @override + String get artistCompilations => 'Compilations'; + + @override + String artistReleases(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count releases', + one: '1 release', + ); + return '$_temp0'; + } + + @override + String get artistPopular => 'Popular'; + + @override + String artistMonthlyListeners(String count) { + return '$count monthly listeners'; + } + + @override + String get trackMetadataTitle => 'Track Info'; + + @override + String get trackMetadataArtist => 'Artist'; + + @override + String get trackMetadataAlbum => 'Album'; + + @override + String get trackMetadataDuration => 'Duration'; + + @override + String get trackMetadataQuality => 'Quality'; + + @override + String get trackMetadataPath => 'File Path'; + + @override + String get trackMetadataDownloadedAt => 'Downloaded'; + + @override + String get trackMetadataService => 'Service'; + + @override + String get trackMetadataPlay => 'Play'; + + @override + String get trackMetadataShare => 'Share'; + + @override + String get trackMetadataDelete => 'Delete'; + + @override + String get trackMetadataRedownload => 'Re-download'; + + @override + String get trackMetadataOpenFolder => 'Open Folder'; + + @override + String get setupTitle => 'Welcome to SpotiFLAC'; + + @override + String get setupSubtitle => 'Let\'s get you started'; + + @override + String get setupStoragePermission => 'Storage Permission'; + + @override + String get setupStoragePermissionSubtitle => + 'Required to save downloaded files'; + + @override + String get setupStoragePermissionGranted => 'Permission granted'; + + @override + String get setupStoragePermissionDenied => 'Permission denied'; + + @override + String get setupGrantPermission => 'Grant Permission'; + + @override + String get setupDownloadLocation => 'Download Location'; + + @override + String get setupChooseFolder => 'Choose Folder'; + + @override + String get setupContinue => 'Continue'; + + @override + String get setupSkip => 'Skip for now'; + + @override + String get setupStorageAccessRequired => 'Storage Access Required'; + + @override + String get setupStorageAccessMessage => + 'SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.'; + + @override + String get setupStorageAccessMessageAndroid11 => + 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.'; + + @override + String get setupOpenSettings => 'Open Settings'; + + @override + String get setupPermissionDeniedMessage => + 'Permission denied. Please grant all permissions to continue.'; + + @override + String setupPermissionRequired(String permissionType) { + return '$permissionType Permission Required'; + } + + @override + String setupPermissionRequiredMessage(String permissionType) { + return '$permissionType permission is required for the best experience. You can change this later in Settings.'; + } + + @override + String get setupSelectDownloadFolder => 'Select Download Folder'; + + @override + String get setupUseDefaultFolder => 'Use Default Folder?'; + + @override + String get setupNoFolderSelected => + 'No folder selected. Would you like to use the default Music folder?'; + + @override + String get setupUseDefault => 'Use Default'; + + @override + String get setupDownloadLocationTitle => 'Download Location'; + + @override + String get setupDownloadLocationIosMessage => + 'On iOS, downloads are saved to the app\'s Documents folder. You can access them via the Files app.'; + + @override + String get setupAppDocumentsFolder => 'App Documents Folder'; + + @override + String get setupAppDocumentsFolderSubtitle => + 'Recommended - accessible via Files app'; + + @override + String get setupChooseFromFiles => 'Choose from Files'; + + @override + String get setupChooseFromFilesSubtitle => 'Select iCloud or other location'; + + @override + String get setupIosEmptyFolderWarning => + 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.'; + + @override + String get setupDownloadInFlac => 'Download Spotify tracks in FLAC'; + + @override + String get setupStepStorage => 'Storage'; + + @override + String get setupStepNotification => 'Notification'; + + @override + String get setupStepFolder => 'Folder'; + + @override + String get setupStepSpotify => 'Spotify'; + + @override + String get setupStepPermission => 'Permission'; + + @override + String get setupStorageGranted => 'Storage Permission Granted!'; + + @override + String get setupStorageRequired => 'Storage Permission Required'; + + @override + String get setupStorageDescription => + 'SpotiFLAC needs storage permission to save your downloaded music files.'; + + @override + String get setupNotificationGranted => 'Notification Permission Granted!'; + + @override + String get setupNotificationEnable => 'Enable Notifications'; + + @override + String get setupNotificationDescription => + 'Get notified when downloads complete or require attention.'; + + @override + String get setupFolderSelected => 'Download Folder Selected!'; + + @override + String get setupFolderChoose => 'Choose Download Folder'; + + @override + String get setupFolderDescription => + 'Select a folder where your downloaded music will be saved.'; + + @override + String get setupChangeFolder => 'Change Folder'; + + @override + String get setupSelectFolder => 'Select Folder'; + + @override + String get setupSpotifyApiOptional => 'Spotify API (Optional)'; + + @override + String get setupSpotifyApiDescription => + 'Add your Spotify API credentials for better search results and access to Spotify-exclusive content.'; + + @override + String get setupUseSpotifyApi => 'Use Spotify API'; + + @override + String get setupEnterCredentialsBelow => 'Enter your credentials below'; + + @override + String get setupUsingDeezer => 'Using Deezer (no account needed)'; + + @override + String get setupEnterClientId => 'Enter Spotify Client ID'; + + @override + String get setupEnterClientSecret => 'Enter Spotify Client Secret'; + + @override + String get setupGetFreeCredentials => + 'Get your free API credentials from the Spotify Developer Dashboard.'; + + @override + String get setupEnableNotifications => 'Enable Notifications'; + + @override + String get setupProceedToNextStep => 'You can now proceed to the next step.'; + + @override + String get setupNotificationProgressDescription => + 'You will receive download progress notifications.'; + + @override + String get setupNotificationBackgroundDescription => + 'Get notified about download progress and completion. This helps you track downloads when the app is in background.'; + + @override + String get setupSkipForNow => 'Skip for now'; + + @override + String get setupBack => 'Back'; + + @override + String get setupNext => 'Next'; + + @override + String get setupGetStarted => 'Get Started'; + + @override + String get setupSkipAndStart => 'Skip & Start'; + + @override + String get setupAllowAccessToManageFiles => + 'Please enable \"Allow access to manage all files\" in the next screen.'; + + @override + String get setupGetCredentialsFromSpotify => + 'Get credentials from developer.spotify.com'; + + @override + String get dialogCancel => 'Cancel'; + + @override + String get dialogOk => 'OK'; + + @override + String get dialogSave => 'Save'; + + @override + String get dialogDelete => 'Delete'; + + @override + String get dialogRetry => 'Retry'; + + @override + String get dialogClose => 'Close'; + + @override + String get dialogYes => 'Yes'; + + @override + String get dialogNo => 'No'; + + @override + String get dialogClear => 'Clear'; + + @override + String get dialogConfirm => 'Confirm'; + + @override + String get dialogDone => 'Done'; + + @override + String get dialogImport => 'Import'; + + @override + String get dialogDiscard => 'Discard'; + + @override + String get dialogRemove => 'Remove'; + + @override + String get dialogUninstall => 'Uninstall'; + + @override + String get dialogDiscardChanges => 'Discard Changes?'; + + @override + String get dialogUnsavedChanges => + 'You have unsaved changes. Do you want to discard them?'; + + @override + String get dialogDownloadFailed => 'Download Failed'; + + @override + String get dialogTrackLabel => 'Track:'; + + @override + String get dialogArtistLabel => 'Artist:'; + + @override + String get dialogErrorLabel => 'Error:'; + + @override + String get dialogClearAll => 'Clear All'; + + @override + String get dialogClearAllDownloads => + 'Are you sure you want to clear all downloads?'; + + @override + String get dialogRemoveFromDevice => 'Remove from device?'; + + @override + String get dialogRemoveExtension => 'Remove Extension'; + + @override + String get dialogRemoveExtensionMessage => + 'Are you sure you want to remove this extension? This cannot be undone.'; + + @override + String get dialogUninstallExtension => 'Uninstall Extension?'; + + @override + String dialogUninstallExtensionMessage(String extensionName) { + return 'Are you sure you want to remove $extensionName?'; + } + + @override + String get dialogClearHistoryTitle => 'Clear History'; + + @override + String get dialogClearHistoryMessage => + 'Are you sure you want to clear all download history? This cannot be undone.'; + + @override + String get dialogDeleteSelectedTitle => 'Delete Selected'; + + @override + String dialogDeleteSelectedMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0 from history?\n\nThis will also delete the files from storage.'; + } + + @override + String get dialogImportPlaylistTitle => 'Import Playlist'; + + @override + String dialogImportPlaylistMessage(int count) { + return 'Found $count tracks in CSV. Add them to download queue?'; + } + + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + + @override + String snackbarAddedToQueue(String trackName) { + return 'Added \"$trackName\" to queue'; + } + + @override + String snackbarAddedTracksToQueue(int count) { + return 'Added $count tracks to queue'; + } + + @override + String snackbarAlreadyDownloaded(String trackName) { + return '\"$trackName\" already downloaded'; + } + + @override + String get snackbarHistoryCleared => 'History cleared'; + + @override + String get snackbarCredentialsSaved => 'Credentials saved'; + + @override + String get snackbarCredentialsCleared => 'Credentials cleared'; + + @override + String snackbarDeletedTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Deleted $count $_temp0'; + } + + @override + String snackbarCannotOpenFile(String error) { + return 'Cannot open file: $error'; + } + + @override + String get snackbarFillAllFields => 'Please fill all fields'; + + @override + String get snackbarViewQueue => 'View Queue'; + + @override + String snackbarFailedToLoad(String error) { + return 'Failed to load: $error'; + } + + @override + String snackbarUrlCopied(String platform) { + return '$platform URL copied to clipboard'; + } + + @override + String get snackbarFileNotFound => 'File not found'; + + @override + String get snackbarSelectExtFile => 'Please select a .spotiflac-ext file'; + + @override + String get snackbarProviderPrioritySaved => 'Provider priority saved'; + + @override + String get snackbarMetadataProviderSaved => + 'Metadata provider priority saved'; + + @override + String snackbarExtensionInstalled(String extensionName) { + return '$extensionName installed.'; + } + + @override + String snackbarExtensionUpdated(String extensionName) { + return '$extensionName updated.'; + } + + @override + String get snackbarFailedToInstall => 'Failed to install extension'; + + @override + String get snackbarFailedToUpdate => 'Failed to update extension'; + + @override + String get errorRateLimited => 'Rate Limited'; + + @override + String get errorRateLimitedMessage => + 'Too many requests. Please wait a moment before searching again.'; + + @override + String errorFailedToLoad(String item) { + return 'Failed to load $item'; + } + + @override + String get errorNoTracksFound => 'No tracks found'; + + @override + String errorMissingExtensionSource(String item) { + return 'Cannot load $item: missing extension source'; + } + + @override + String get statusQueued => 'Queued'; + + @override + String get statusDownloading => 'Downloading'; + + @override + String get statusFinalizing => 'Finalizing'; + + @override + String get statusCompleted => 'Completed'; + + @override + String get statusFailed => 'Failed'; + + @override + String get statusSkipped => 'Skipped'; + + @override + String get statusPaused => 'Paused'; + + @override + String get actionPause => 'Pause'; + + @override + String get actionResume => 'Resume'; + + @override + String get actionCancel => 'Cancel'; + + @override + String get actionStop => 'Stop'; + + @override + String get actionSelect => 'Select'; + + @override + String get actionSelectAll => 'Select All'; + + @override + String get actionDeselect => 'Deselect'; + + @override + String get actionPaste => 'Paste'; + + @override + String get actionImportCsv => 'Import CSV'; + + @override + String get actionRemoveCredentials => 'Remove Credentials'; + + @override + String get actionSaveCredentials => 'Save Credentials'; + + @override + String selectionSelected(int count) { + return '$count selected'; + } + + @override + String get selectionAllSelected => 'All tracks selected'; + + @override + String get selectionTapToSelect => 'Tap tracks to select'; + + @override + String selectionDeleteTracks(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0'; + } + + @override + String get selectionSelectToDelete => 'Select tracks to delete'; + + @override + String progressFetchingMetadata(int current, int total) { + return 'Fetching metadata... $current/$total'; + } + + @override + String get progressReadingCsv => 'Reading CSV...'; + + @override + String get searchSongs => 'Songs'; + + @override + String get searchArtists => 'Artists'; + + @override + String get searchAlbums => 'Albums'; + + @override + String get searchPlaylists => 'Playlists'; + + @override + String get tooltipPlay => 'Play'; + + @override + String get tooltipCancel => 'Cancel'; + + @override + String get tooltipStop => 'Stop'; + + @override + String get tooltipRetry => 'Retry'; + + @override + String get tooltipRemove => 'Remove'; + + @override + String get tooltipClear => 'Clear'; + + @override + String get tooltipPaste => 'Paste'; + + @override + String get filenameFormat => 'Filename Format'; + + @override + String filenameFormatPreview(String preview) { + return 'Preview: $preview'; + } + + @override + String get filenameAvailablePlaceholders => 'Available placeholders:'; + + @override + String filenameHint(Object artist, Object title) { + return '$artist - $title'; + } + + @override + String get folderOrganization => 'Folder Organization'; + + @override + String get folderOrganizationNone => 'No organization'; + + @override + String get folderOrganizationByArtist => 'By Artist'; + + @override + String get folderOrganizationByAlbum => 'By Album'; + + @override + String get folderOrganizationByArtistAlbum => 'Artist/Album'; + + @override + String get folderOrganizationDescription => + 'Organize downloaded files into folders'; + + @override + String get folderOrganizationNoneSubtitle => 'All files in download folder'; + + @override + String get folderOrganizationByArtistSubtitle => + 'Separate folder for each artist'; + + @override + String get folderOrganizationByAlbumSubtitle => + 'Separate folder for each album'; + + @override + String get folderOrganizationByArtistAlbumSubtitle => + 'Nested folders for artist and album'; + + @override + String get updateAvailable => 'Update Available'; + + @override + String updateNewVersion(String version) { + return 'Version $version is available'; + } + + @override + String get updateDownload => 'Download'; + + @override + String get updateLater => 'Later'; + + @override + String get updateChangelog => 'Changelog'; + + @override + String get updateStartingDownload => 'Starting download...'; + + @override + String get updateDownloadFailed => 'Download failed'; + + @override + String get updateFailedMessage => 'Failed to download update'; + + @override + String get updateNewVersionReady => 'A new version is ready'; + + @override + String get updateCurrent => 'Current'; + + @override + String get updateNew => 'New'; + + @override + String get updateDownloading => 'Downloading...'; + + @override + String get updateWhatsNew => 'What\'s New'; + + @override + String get updateDownloadInstall => 'Download & Install'; + + @override + String get updateDontRemind => 'Don\'t remind'; + + @override + String get providerPriority => 'Provider Priority'; + + @override + String get providerPrioritySubtitle => 'Drag to reorder download providers'; + + @override + String get providerPriorityTitle => 'Provider Priority'; + + @override + String get providerPriorityDescription => + 'Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.'; + + @override + String get providerPriorityInfo => + 'If a track is not available on the first provider, the app will automatically try the next one.'; + + @override + String get providerBuiltIn => 'Built-in'; + + @override + String get providerExtension => 'Extension'; + + @override + String get metadataProviderPriority => 'Metadata Provider Priority'; + + @override + String get metadataProviderPrioritySubtitle => + 'Order used when fetching track metadata'; + + @override + String get metadataProviderPriorityTitle => 'Metadata Priority'; + + @override + String get metadataProviderPriorityDescription => + 'Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.'; + + @override + String get metadataProviderPriorityInfo => + 'Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.'; + + @override + String get metadataNoRateLimits => 'No rate limits'; + + @override + String get metadataMayRateLimit => 'May rate limit'; + + @override + String get logTitle => 'Logs'; + + @override + String get logCopy => 'Copy Logs'; + + @override + String get logClear => 'Clear Logs'; + + @override + String get logShare => 'Share Logs'; + + @override + String get logEmpty => 'No logs yet'; + + @override + String get logCopied => 'Logs copied to clipboard'; + + @override + String get logSearchHint => 'Search logs...'; + + @override + String get logFilterLevel => 'Level'; + + @override + String get logFilterSection => 'Filter'; + + @override + String get logShareLogs => 'Share logs'; + + @override + String get logClearLogs => 'Clear logs'; + + @override + String get logClearLogsTitle => 'Clear Logs'; + + @override + String get logClearLogsMessage => 'Are you sure you want to clear all logs?'; + + @override + String get logIspBlocking => 'ISP BLOCKING DETECTED'; + + @override + String get logRateLimited => 'RATE LIMITED'; + + @override + String get logNetworkError => 'NETWORK ERROR'; + + @override + String get logTrackNotFound => 'TRACK NOT FOUND'; + + @override + String get logFilterBySeverity => 'Filter logs by severity'; + + @override + String get logNoLogsYet => 'No logs yet'; + + @override + String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app'; + + @override + String get logIssueSummary => 'Issue Summary'; + + @override + String get logIspBlockingDescription => + 'Your ISP may be blocking access to download services'; + + @override + String get logIspBlockingSuggestion => + 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; + + @override + String get logRateLimitedDescription => 'Too many requests to the service'; + + @override + String get logRateLimitedSuggestion => + 'Wait a few minutes before trying again'; + + @override + String get logNetworkErrorDescription => 'Connection issues detected'; + + @override + String get logNetworkErrorSuggestion => 'Check your internet connection'; + + @override + String get logTrackNotFoundDescription => + 'Some tracks could not be found on download services'; + + @override + String get logTrackNotFoundSuggestion => + 'The track may not be available in lossless quality'; + + @override + String logTotalErrors(int count) { + return 'Total errors: $count'; + } + + @override + String logAffected(String domains) { + return 'Affected: $domains'; + } + + @override + String logEntriesFiltered(int count) { + return 'Entries ($count filtered)'; + } + + @override + String logEntries(int count) { + return 'Entries ($count)'; + } + + @override + String get credentialsTitle => 'Spotify Credentials'; + + @override + String get credentialsDescription => + 'Enter your Client ID and Secret to use your own Spotify application quota.'; + + @override + String get credentialsClientId => 'Client ID'; + + @override + String get credentialsClientIdHint => 'Paste Client ID'; + + @override + String get credentialsClientSecret => 'Client Secret'; + + @override + String get credentialsClientSecretHint => 'Paste Client Secret'; + + @override + String get channelStable => 'Stable'; + + @override + String get channelPreview => 'Preview'; + + @override + String get sectionSearchSource => 'Search Source'; + + @override + String get sectionDownload => 'Download'; + + @override + String get sectionPerformance => 'Performance'; + + @override + String get sectionApp => 'App'; + + @override + String get sectionData => 'Data'; + + @override + String get sectionDebug => 'Debug'; + + @override + String get sectionService => 'Service'; + + @override + String get sectionAudioQuality => 'Audio Quality'; + + @override + String get sectionFileSettings => 'File Settings'; + + @override + String get sectionLyrics => 'Lyrics'; + + @override + String get lyricsMode => 'Lyrics Mode'; + + @override + String get lyricsModeDescription => + 'Choose how lyrics are saved with your downloads'; + + @override + String get lyricsModeEmbed => 'Embed in file'; + + @override + String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; + + @override + String get lyricsModeExternal => 'External .lrc file'; + + @override + String get lyricsModeExternalSubtitle => + 'Separate .lrc file for players like Samsung Music'; + + @override + String get lyricsModeBoth => 'Both'; + + @override + String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; + + @override + String get sectionColor => 'Color'; + + @override + String get sectionTheme => 'Theme'; + + @override + String get sectionLayout => 'Layout'; + + @override + String get sectionLanguage => 'Language'; + + @override + String get appearanceLanguage => 'App Language'; + + @override + String get appearanceLanguageSubtitle => 'Choose your preferred language'; + + @override + String get settingsAppearanceSubtitle => 'Theme, colors, display'; + + @override + String get settingsDownloadSubtitle => 'Service, quality, filename format'; + + @override + String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates'; + + @override + String get settingsExtensionsSubtitle => 'Manage download providers'; + + @override + String get settingsLogsSubtitle => 'View app logs for debugging'; + + @override + String get loadingSharedLink => 'Loading shared link...'; + + @override + String get pressBackAgainToExit => 'Press back again to exit'; + + @override + String get tracksHeader => 'Tracks'; + + @override + String downloadAllCount(int count) { + return 'Download All ($count)'; + } + + @override + String tracksCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String get trackCopyFilePath => 'Copy file path'; + + @override + String get trackRemoveFromDevice => 'Remove from device'; + + @override + String get trackLoadLyrics => 'Load Lyrics'; + + @override + String get trackMetadata => 'Metadata'; + + @override + String get trackFileInfo => 'File Info'; + + @override + String get trackLyrics => 'Lyrics'; + + @override + String get trackFileNotFound => 'File not found'; + + @override + String get trackOpenInDeezer => 'Open in Deezer'; + + @override + String get trackOpenInSpotify => 'Open in Spotify'; + + @override + String get trackTrackName => 'Track name'; + + @override + String get trackArtist => 'Artist'; + + @override + String get trackAlbumArtist => 'Album artist'; + + @override + String get trackAlbum => 'Album'; + + @override + String get trackTrackNumber => 'Track number'; + + @override + String get trackDiscNumber => 'Disc number'; + + @override + String get trackDuration => 'Duration'; + + @override + String get trackAudioQuality => 'Audio quality'; + + @override + String get trackReleaseDate => 'Release date'; + + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + + @override + String get trackDownloaded => 'Downloaded'; + + @override + String get trackCopyLyrics => 'Copy lyrics'; + + @override + String get trackLyricsNotAvailable => 'Lyrics not available for this track'; + + @override + String get trackLyricsTimeout => 'Request timed out. Try again later.'; + + @override + String get trackLyricsLoadFailed => 'Failed to load lyrics'; + + @override + String get trackCopiedToClipboard => 'Copied to clipboard'; + + @override + String get trackDeleteConfirmTitle => 'Remove from device?'; + + @override + String get trackDeleteConfirmMessage => + 'This will permanently delete the downloaded file and remove it from your history.'; + + @override + String trackCannotOpen(String message) { + return 'Cannot open: $message'; + } + + @override + String get dateToday => 'Today'; + + @override + String get dateYesterday => 'Yesterday'; + + @override + String dateDaysAgo(int count) { + return '$count days ago'; + } + + @override + String dateWeeksAgo(int count) { + return '$count weeks ago'; + } + + @override + String dateMonthsAgo(int count) { + return '$count months ago'; + } + + @override + String get concurrentSequential => 'Sequential'; + + @override + String get concurrentParallel2 => '2 Parallel'; + + @override + String get concurrentParallel3 => '3 Parallel'; + + @override + String get tapToSeeError => 'Tap to see error details'; + + @override + String get storeFilterAll => 'All'; + + @override + String get storeFilterMetadata => 'Metadata'; + + @override + String get storeFilterDownload => 'Download'; + + @override + String get storeFilterUtility => 'Utility'; + + @override + String get storeFilterLyrics => 'Lyrics'; + + @override + String get storeFilterIntegration => 'Integration'; + + @override + String get storeClearFilters => 'Clear filters'; + + @override + String get storeNoResults => 'No extensions found'; + + @override + String get extensionProviderPriority => 'Provider Priority'; + + @override + String get extensionInstallButton => 'Install Extension'; + + @override + String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; + + @override + String get extensionDefaultProviderSubtitle => 'Use built-in search'; + + @override + String get extensionAuthor => 'Author'; + + @override + String get extensionId => 'ID'; + + @override + String get extensionError => 'Error'; + + @override + String get extensionCapabilities => 'Capabilities'; + + @override + String get extensionMetadataProvider => 'Metadata Provider'; + + @override + String get extensionDownloadProvider => 'Download Provider'; + + @override + String get extensionLyricsProvider => 'Lyrics Provider'; + + @override + String get extensionUrlHandler => 'URL Handler'; + + @override + String get extensionQualityOptions => 'Quality Options'; + + @override + String get extensionPostProcessingHooks => 'Post-Processing Hooks'; + + @override + String get extensionPermissions => 'Permissions'; + + @override + String get extensionSettings => 'Settings'; + + @override + String get extensionRemoveButton => 'Remove Extension'; + + @override + String get extensionUpdated => 'Updated'; + + @override + String get extensionMinAppVersion => 'Min App Version'; + + @override + String get extensionCustomTrackMatching => 'Custom Track Matching'; + + @override + String get extensionPostProcessing => 'Post-Processing'; + + @override + String extensionHooksAvailable(int count) { + return '$count hook(s) available'; + } + + @override + String extensionPatternsCount(int count) { + return '$count pattern(s)'; + } + + @override + String extensionStrategy(String strategy) { + return 'Strategy: $strategy'; + } + + @override + String get extensionsProviderPrioritySection => 'Provider Priority'; + + @override + String get extensionsInstalledSection => 'Installed Extensions'; + + @override + String get extensionsNoExtensions => 'No extensions installed'; + + @override + String get extensionsNoExtensionsSubtitle => + 'Install .spotiflac-ext files to add new providers'; + + @override + String get extensionsInstallButton => 'Install Extension'; + + @override + String get extensionsInfoTip => + 'Extensions can add new metadata and download providers. Only install extensions from trusted sources.'; + + @override + String get extensionsInstalledSuccess => 'Extension installed successfully'; + + @override + String get extensionsDownloadPriority => 'Download Priority'; + + @override + String get extensionsDownloadPrioritySubtitle => 'Set download service order'; + + @override + String get extensionsNoDownloadProvider => + 'No extensions with download provider'; + + @override + String get extensionsMetadataPriority => 'Metadata Priority'; + + @override + String get extensionsMetadataPrioritySubtitle => + 'Set search & metadata source order'; + + @override + String get extensionsNoMetadataProvider => + 'No extensions with metadata provider'; + + @override + String get extensionsSearchProvider => 'Search Provider'; + + @override + String get extensionsNoCustomSearch => 'No extensions with custom search'; + + @override + String get extensionsSearchProviderDescription => + 'Choose which service to use for searching tracks'; + + @override + String get extensionsCustomSearch => 'Custom search'; + + @override + String get extensionsErrorLoading => 'Error loading extension'; + + @override + String get qualityFlacLossless => 'FLAC Lossless'; + + @override + String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz'; + + @override + String get qualityHiResFlac => 'Hi-Res FLAC'; + + @override + String get qualityHiResFlacSubtitle => '24-bit / up to 96kHz'; + + @override + String get qualityHiResFlacMax => 'Hi-Res FLAC Max'; + + @override + String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; + + @override + String get qualityMp3 => 'MP3'; + + @override + String get qualityMp3Subtitle => '320kbps (converted from FLAC)'; + + @override + String get enableMp3Option => 'Enable MP3 Option'; + + @override + String get enableMp3OptionSubtitleOn => 'MP3 quality option is available'; + + @override + String get enableMp3OptionSubtitleOff => + 'Downloads FLAC then converts to 320kbps MP3'; + + @override + String get qualityNote => + 'Actual quality depends on track availability from the service'; + + @override + String get downloadAskBeforeDownload => 'Ask Before Download'; + + @override + String get downloadDirectory => 'Download Directory'; + + @override + String get downloadSeparateSinglesFolder => 'Separate Singles Folder'; + + @override + String get downloadAlbumFolderStructure => 'Album Folder Structure'; + + @override + String get downloadSaveFormat => 'Save Format'; + + @override + String get downloadSelectService => 'Select Service'; + + @override + String get downloadSelectQuality => 'Select Quality'; + + @override + String get downloadFrom => 'Download From'; + + @override + String get downloadDefaultQualityLabel => 'Default Quality'; + + @override + String get downloadBestAvailable => 'Best available'; + + @override + String get folderNone => 'None'; + + @override + String get folderNoneSubtitle => 'Save all files directly to download folder'; + + @override + String get folderArtist => 'Artist'; + + @override + String get folderArtistSubtitle => 'Artist Name/filename'; + + @override + String get folderAlbum => 'Album'; + + @override + String get folderAlbumSubtitle => 'Album Name/filename'; + + @override + String get folderArtistAlbum => 'Artist/Album'; + + @override + String get folderArtistAlbumSubtitle => 'Artist Name/Album Name/filename'; + + @override + String get serviceTidal => 'Tidal'; + + @override + String get serviceQobuz => 'Qobuz'; + + @override + String get serviceAmazon => 'Amazon'; + + @override + String get serviceDeezer => 'Deezer'; + + @override + String get serviceSpotify => 'Spotify'; + + @override + String get appearanceAmoledDark => 'AMOLED Dark'; + + @override + String get appearanceAmoledDarkSubtitle => 'Pure black background'; + + @override + String get appearanceChooseAccentColor => 'Choose Accent Color'; + + @override + String get appearanceChooseTheme => 'Theme Mode'; + + @override + String get queueTitle => 'Download Queue'; + + @override + String get queueClearAll => 'Clear All'; + + @override + String get queueClearAllMessage => + 'Are you sure you want to clear all downloads?'; + + @override + String get queueEmpty => 'No downloads in queue'; + + @override + String get queueEmptySubtitle => 'Add tracks from the home screen'; + + @override + String get queueClearCompleted => 'Clear completed'; + + @override + String get queueDownloadFailed => 'Download Failed'; + + @override + String get queueTrackLabel => 'Track:'; + + @override + String get queueArtistLabel => 'Artist:'; + + @override + String get queueErrorLabel => 'Error:'; + + @override + String get queueUnknownError => 'Unknown error'; + + @override + String get albumFolderArtistAlbum => 'Artist / Album'; + + @override + String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/'; + + @override + String get albumFolderArtistYearAlbum => 'Artist / [Year] Album'; + + @override + String get albumFolderArtistYearAlbumSubtitle => + 'Albums/Artist Name/[2005] Album Name/'; + + @override + String get albumFolderAlbumOnly => 'Album Only'; + + @override + String get albumFolderAlbumOnlySubtitle => 'Albums/Album Name/'; + + @override + String get albumFolderYearAlbum => '[Year] Album'; + + @override + String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; + + @override + String get downloadedAlbumDeleteSelected => 'Delete Selected'; + + @override + String downloadedAlbumDeleteMessage(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.'; + } + + @override + String get downloadedAlbumTracksHeader => 'Tracks'; + + @override + String downloadedAlbumDownloadedCount(int count) { + return '$count downloaded'; + } + + @override + String downloadedAlbumSelectedCount(int count) { + return '$count selected'; + } + + @override + String get downloadedAlbumAllSelected => 'All tracks selected'; + + @override + String get downloadedAlbumTapToSelect => 'Tap tracks to select'; + + @override + String downloadedAlbumDeleteCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'tracks', + one: 'track', + ); + return 'Delete $count $_temp0'; + } + + @override + String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; + + @override + String downloadedAlbumDiscHeader(int discNumber) { + return 'Disc $discNumber'; + } + + @override + String get utilityFunctions => 'Utility Functions'; + + @override + String get recentTypeArtist => 'Artist'; + + @override + String get recentTypeAlbum => 'Album'; + + @override + String get recentTypeSong => 'Song'; + + @override + String get recentTypePlaylist => 'Playlist'; + + @override + String recentPlaylistInfo(String name) { + return 'Playlist: $name'; + } + + @override + String errorGeneric(String message) { + return 'Error: $message'; + } +} diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb new file mode 100644 index 00000000..79d419e9 --- /dev/null +++ b/lib/l10n/arb/app_tr.arb @@ -0,0 +1,7 @@ +{ + "@@locale": "tr", + "@@last_modified": "2026-01-21", + + "appName": "SpotiFLAC", + "@appName": {"description": "App name - DO NOT TRANSLATE"} +} diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart new file mode 100644 index 00000000..14a238cb --- /dev/null +++ b/lib/providers/explore_provider.dart @@ -0,0 +1,220 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; + +final _log = AppLogger('ExploreProvider'); + +/// Represents an item in a Spotify home section +class ExploreItem { + final String id; + final String uri; + final String type; // track, album, playlist, artist, station + final String name; + final String artists; + final String? description; + final String? coverUrl; + final String? providerId; + final String? albumId; + final String? albumName; + + const ExploreItem({ + required this.id, + required this.uri, + required this.type, + required this.name, + required this.artists, + this.description, + this.coverUrl, + this.providerId, + this.albumId, + this.albumName, + }); + + factory ExploreItem.fromJson(Map json) { + return ExploreItem( + id: json['id'] as String? ?? '', + uri: json['uri'] as String? ?? '', + type: json['type'] as String? ?? 'track', + name: json['name'] as String? ?? '', + artists: json['artists'] as String? ?? '', + description: json['description'] as String?, + coverUrl: json['cover_url'] as String?, + providerId: json['provider_id'] as String?, + albumId: json['album_id'] as String?, + albumName: json['album_name'] as String?, + ); + } +} + +/// Represents a section in Spotify home feed +class ExploreSection { + final String uri; + final String title; + final List items; + + const ExploreSection({ + required this.uri, + required this.title, + required this.items, + }); + + factory ExploreSection.fromJson(Map json) { + final itemsList = json['items'] as List? ?? []; + return ExploreSection( + uri: json['uri'] as String? ?? '', + title: json['title'] as String? ?? '', + items: itemsList + .map((item) => ExploreItem.fromJson(item as Map)) + .toList(), + ); + } +} + +/// State for explore/home feed +class ExploreState { + final bool isLoading; + final String? error; + final String? greeting; + final List sections; + final DateTime? lastFetched; + + const ExploreState({ + this.isLoading = false, + this.error, + this.greeting, + this.sections = const [], + this.lastFetched, + }); + + bool get hasContent => sections.isNotEmpty; + + ExploreState copyWith({ + bool? isLoading, + String? error, + String? greeting, + List? sections, + DateTime? lastFetched, + }) { + return ExploreState( + isLoading: isLoading ?? this.isLoading, + error: error, + greeting: greeting ?? this.greeting, + sections: sections ?? this.sections, + lastFetched: lastFetched ?? this.lastFetched, + ); + } +} + +/// Provider for explore/home feed state +class ExploreNotifier extends Notifier { + @override + ExploreState build() { + return const ExploreState(); + } + + /// Fetch home feed from spotify-web extension + Future fetchHomeFeed({bool forceRefresh = false}) async { + _log.i('fetchHomeFeed called, forceRefresh=$forceRefresh'); + + // Don't refetch if we have data and it's less than 5 minutes old + if (!forceRefresh && + state.hasContent && + state.lastFetched != null && + DateTime.now().difference(state.lastFetched!).inMinutes < 5) { + _log.d('Using cached home feed'); + return; + } + + state = state.copyWith(isLoading: true, error: null); + + try { + // Find any extension with homeFeed capability + final extState = ref.read(extensionProvider); + _log.d('Extensions count: ${extState.extensions.length}'); + + // Look for extensions with homeFeed capability (prefer spotify-web, then ytmusic) + final homeFeedExtensions = extState.extensions.where( + (e) => e.enabled && e.hasHomeFeed, + ).toList(); + + if (homeFeedExtensions.isEmpty) { + _log.w('No extension with homeFeed capability found'); + state = state.copyWith( + isLoading: false, + error: 'No extension with home feed support enabled', + ); + return; + } + + // Prefer spotify-web if available, otherwise use first available + var targetExt = homeFeedExtensions.firstWhere( + (e) => e.id == 'spotify-web', + orElse: () => homeFeedExtensions.first, + ); + + _log.i('Fetching home feed from ${targetExt.id}...'); + final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id); + + _log.d('getExtensionHomeFeed result: $result'); + + if (result == null) { + state = state.copyWith( + isLoading: false, + error: 'Failed to fetch home feed', + ); + return; + } + + final success = result['success'] as bool? ?? false; + if (!success) { + final error = result['error'] as String? ?? 'Unknown error'; + state = state.copyWith( + isLoading: false, + error: error, + ); + return; + } + + final greeting = result['greeting'] as String?; + final sectionsData = result['sections'] as List? ?? []; + + final sections = sectionsData + .map((s) => ExploreSection.fromJson(s as Map)) + .toList(); + + _log.i('Fetched ${sections.length} sections'); + + // Debug: log first section items + if (sections.isNotEmpty && sections.first.items.isNotEmpty) { + final firstItem = sections.first.items.first; + _log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}'); + } + + state = ExploreState( + isLoading: false, + greeting: greeting, + sections: sections, + lastFetched: DateTime.now(), + ); + } catch (e, stack) { + _log.e('Error fetching home feed: $e', e, stack); + state = state.copyWith( + isLoading: false, + error: e.toString(), + ); + } + } + + /// Clear cached data + void clear() { + state = const ExploreState(); + } + + /// Refresh home feed + Future refresh() => fetchHomeFeed(forceRefresh: true); +} + +final exploreProvider = NotifierProvider(() { + return ExploreNotifier(); +}); diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 6f74d25b..a4c3d25e 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -26,6 +26,7 @@ class Extension { final URLHandler? urlHandler; final TrackMatching? trackMatching; final PostProcessing? postProcessing; + final Map capabilities; // Extension capabilities (homeFeed, browseCategories, etc.) const Extension({ required this.id, @@ -48,6 +49,7 @@ class Extension { this.urlHandler, this.trackMatching, this.postProcessing, + this.capabilities = const {}, }); factory Extension.fromJson(Map json) { @@ -84,6 +86,7 @@ class Extension { postProcessing: json['post_processing'] != null ? PostProcessing.fromJson(json['post_processing'] as Map) : null, + capabilities: (json['capabilities'] as Map?) ?? const {}, ); } @@ -108,6 +111,7 @@ class Extension { URLHandler? urlHandler, TrackMatching? trackMatching, PostProcessing? postProcessing, + Map? capabilities, }) { return Extension( id: id ?? this.id, @@ -130,6 +134,7 @@ class Extension { urlHandler: urlHandler ?? this.urlHandler, trackMatching: trackMatching ?? this.trackMatching, postProcessing: postProcessing ?? this.postProcessing, + capabilities: capabilities ?? this.capabilities, ); } @@ -137,6 +142,8 @@ class Extension { bool get hasURLHandler => urlHandler?.enabled ?? false; bool get hasCustomMatching => trackMatching?.customMatching ?? false; bool get hasPostProcessing => postProcessing?.enabled ?? false; + bool get hasHomeFeed => capabilities['homeFeed'] == true; + bool get hasBrowseCategories => capabilities['browseCategories'] == true; } class SearchBehavior { diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index af5413a3..d1006315 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -12,6 +12,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/recent_access_provider.dart'; +import 'package:spotiflac_android/providers/explore_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; @@ -59,6 +60,19 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient _searchFocusNode.addListener(_onSearchFocusChanged); } + void _fetchExploreIfNeeded() { + final extState = ref.read(extensionProvider); + final exploreState = ref.read(exploreProvider); + // Check if any extension with homeFeed capability is enabled + final hasHomeFeedExtension = extState.extensions.any( + (e) => e.enabled && e.hasHomeFeed, + ); + // Fetch if any homeFeed extension is enabled and we don't have content yet + if (hasHomeFeedExtension && !exploreState.hasContent && !exploreState.isLoading) { + ref.read(exploreProvider.notifier).fetchHomeFeed(); + } + } + @override void dispose() { _liveSearchDebounce?.cancel(); @@ -420,6 +434,15 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } }); + // Listen for extension state changes to trigger explore fetch + ref.listen(extensionProvider.select((s) => s.isInitialized), (previous, next) { + if (next == true && previous != true) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _fetchExploreIfNeeded(); + }); + } + }); + final tracks = ref.watch(trackProvider.select((s) => s.tracks)); final searchArtists = ref.watch(trackProvider.select((s) => s.searchArtists)); final isLoading = ref.watch(trackProvider.select((s) => s.isLoading)); @@ -429,6 +452,12 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ref.watch(extensionProvider.select((s) => s.isInitialized)); ref.watch(extensionProvider.select((s) => s.extensions)); + // Explore state + final exploreState = ref.watch(exploreProvider); + final hasHomeFeedExtension = ref.watch(extensionProvider.select((s) => + s.extensions.any((e) => e.enabled && e.hasHomeFeed) + )); + final colorScheme = Theme.of(context).colorScheme; final hasActualResults = tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty); final isShowingRecentAccess = ref.watch(trackProvider.select((s) => s.isShowingRecentAccess)); @@ -441,6 +470,9 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final hasRecentItems = recentAccessItems.isNotEmpty || historyItems.isNotEmpty; final showRecentAccess = isShowingRecentAccess && hasRecentItems && !hasActualResults && !isLoading; + // Show explore only when no search results and not showing recent access + final showExplore = !hasActualResults && !isLoading && !showRecentAccess && hasHomeFeedExtension && exploreState.hasContent; + if (hasActualResults && isShowingRecentAccess) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) ref.read(trackProvider.notifier).setShowingRecentAccess(false); @@ -455,9 +487,12 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient }, behavior: HitTestBehavior.translucent, child: Scaffold( - body: CustomScrollView( - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - slivers: [ + body: RefreshIndicator( + onRefresh: () => ref.read(exploreProvider.notifier).refresh(), + notificationPredicate: (notification) => showExplore, + child: CustomScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + slivers: [ SliverAppBar( expandedHeight: 120 + topPadding, collapsedHeight: kToolbarHeight, @@ -492,7 +527,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient child: AnimatedSize( duration: const Duration(milliseconds: 250), curve: Curves.easeOut, - child: hasResults + child: (hasResults || showExplore) ? const SizedBox.shrink() : Column( children: [ @@ -541,7 +576,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.fromLTRB(16, hasResults ? 8 : 32, 16, hasResults ? 8 : 16), + padding: EdgeInsets.fromLTRB(16, (hasResults || showExplore) ? 8 : 32, 16, (hasResults || showExplore) ? 8 : 16), child: _buildSearchBar(colorScheme), ), ), @@ -559,7 +594,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient child: AnimatedSize( duration: const Duration(milliseconds: 250), curve: Curves.easeOut, - child: (hasResults || showRecentAccess) + child: (hasResults || showRecentAccess || showExplore) ? const SizedBox.shrink() : Column( children: [ @@ -584,6 +619,19 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ), + // Explore sections (Spotify Home Feed) + if (showExplore) + ..._buildExploreSections(exploreState, colorScheme), + + // Loading indicator for explore + if (hasHomeFeedExtension && !hasActualResults && !isLoading && exploreState.isLoading) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ), + ), + ..._buildSearchResults( tracks: tracks, searchArtists: searchArtists, @@ -594,6 +642,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ], ), + ), // Close RefreshIndicator ), // Close GestureDetector ); } @@ -670,6 +719,382 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } + List _buildExploreSections(ExploreState exploreState, ColorScheme colorScheme) { + final slivers = []; + + // Greeting (pull-to-refresh handles refresh) + if (exploreState.greeting != null && exploreState.greeting!.isNotEmpty) { + slivers.add( + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + exploreState.greeting!, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ); + } + + // Build each section + for (final section in exploreState.sections) { + slivers.add( + SliverToBoxAdapter( + child: _buildExploreSection(section, colorScheme), + ), + ); + } + + // Add some bottom padding + slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 16))); + + return slivers; + } + + Widget _buildExploreSection(ExploreSection section, ColorScheme colorScheme) { + // Check if this is a YT Music "Quick picks" style section (vertical list) + final isYTMusicQuickPicks = section.items.isNotEmpty && + section.items.first.providerId == 'ytmusic-spotiflac' && + section.items.every((item) => item.type == 'track'); + + if (isYTMusicQuickPicks) { + return _buildYTMusicQuickPicksSection(section, colorScheme); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: Text( + section.title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + SizedBox( + height: 175, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: section.items.length, + itemBuilder: (context, index) { + final item = section.items[index]; + return _buildExploreItem(item, colorScheme); + }, + ), + ), + ], + ); + } + + /// Build YT Music "Quick picks" style swipeable pages section + Widget _buildYTMusicQuickPicksSection(ExploreSection section, ColorScheme colorScheme) { + const itemsPerPage = 5; + final totalPages = (section.items.length / itemsPerPage).ceil(); + + return _QuickPicksPageView( + section: section, + colorScheme: colorScheme, + itemsPerPage: itemsPerPage, + totalPages: totalPages, + onItemTap: _navigateToExploreItem, + onItemMenu: _showTrackBottomSheet, + ); + } + + Widget _buildExploreItem(ExploreItem item, ColorScheme colorScheme) { + final isArtist = item.type == 'artist'; + + return GestureDetector( + onTap: () => _navigateToExploreItem(item), + child: SizedBox( + width: 120, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Column( + crossAxisAlignment: isArtist ? CrossAxisAlignment.center : CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(isArtist ? 60 : 8), + child: item.coverUrl != null && item.coverUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: item.coverUrl!, + width: 120, + height: 120, + fit: BoxFit.cover, + memCacheWidth: 240, + memCacheHeight: 240, + cacheManager: CoverCacheManager.instance, + errorWidget: (context, url, error) => Container( + width: 120, + height: 120, + color: colorScheme.surfaceContainerHighest, + child: Icon( + _getIconForType(item.type), + color: colorScheme.onSurfaceVariant, + size: 36, + ), + ), + ) + : Container( + width: 120, + height: 120, + color: colorScheme.surfaceContainerHighest, + child: Icon( + _getIconForType(item.type), + color: colorScheme.onSurfaceVariant, + size: 36, + ), + ), + ), + const SizedBox(height: 8), + Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: isArtist ? TextAlign.center : TextAlign.start, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + color: colorScheme.onSurface, + ), + ), + if (item.artists.isNotEmpty && !isArtist) + Text( + item.artists, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: 11, + ), + ), + ], + ), + ), + ), + ); + } + + IconData _getIconForType(String type) { + switch (type) { + case 'track': + return Icons.music_note; + case 'album': + return Icons.album; + case 'playlist': + return Icons.playlist_play; + case 'artist': + return Icons.person; + case 'station': + return Icons.radio; + default: + return Icons.music_note; + } + } + + void _navigateToExploreItem(ExploreItem item) async { + final extensionId = item.providerId ?? 'spotify-web'; + + switch (item.type) { + case 'track': + // Show bottom sheet with track info and download option + _showTrackBottomSheet(item); + case 'album': + Navigator.push(context, MaterialPageRoute( + builder: (context) => ExtensionAlbumScreen( + extensionId: extensionId, + albumId: item.id, + albumName: item.name, + coverUrl: item.coverUrl, + ), + )); + case 'playlist': + Navigator.push(context, MaterialPageRoute( + builder: (context) => ExtensionPlaylistScreen( + extensionId: extensionId, + playlistId: item.id, + playlistName: item.name, + coverUrl: item.coverUrl, + ), + )); + case 'artist': + Navigator.push(context, MaterialPageRoute( + builder: (context) => ExtensionArtistScreen( + extensionId: extensionId, + artistId: item.id, + artistName: item.name, + coverUrl: item.coverUrl, + ), + )); + default: + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${item.type}: ${item.name}')), + ); + } + } + + void _showTrackBottomSheet(ExploreItem item) { + final colorScheme = Theme.of(context).colorScheme; + + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + margin: const EdgeInsets.only(top: 12), + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + // Track info + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: item.coverUrl != null && item.coverUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: item.coverUrl!, + width: 64, + height: 64, + fit: BoxFit.cover, + memCacheWidth: 128, + cacheManager: CoverCacheManager.instance, + ) + : Container( + width: 64, + height: 64, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + item.artists, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + const Divider(height: 1), + // Actions + ListTile( + leading: Icon(Icons.download, color: colorScheme.primary), + title: Text(context.l10n.downloadTitle), + onTap: () { + Navigator.pop(context); + _downloadExploreTrack(item); + }, + ), + ListTile( + leading: Icon(Icons.album, color: colorScheme.onSurfaceVariant), + title: const Text('Go to Album'), + onTap: () { + Navigator.pop(context); + // Navigate to album - we'll use the track ID to search + _navigateToTrackAlbum(item); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + Future _downloadExploreTrack(ExploreItem item) async { + final settings = ref.read(settingsProvider); + + // Create a Track object from ExploreItem + // Pass spotify ID as ISRC so enrichment can look it up via SongLink/Deezer + final track = Track( + id: item.id, + name: item.name, + artistName: item.artists, + albumName: item.albumName ?? '', + duration: 0, + trackNumber: 1, + discNumber: 1, + isrc: item.id, // Pass Spotify ID - enrichment will detect and lookup real ISRC + releaseDate: null, + coverUrl: item.coverUrl, + source: item.providerId ?? 'spotify-web', + ); + + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + context, + trackName: track.name, + artistName: track.artistName, + coverUrl: track.coverUrl, + onSelect: (quality, service) { + ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), + ); + }, + ); + } else { + ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), + ); + } + } + + Future _navigateToTrackAlbum(ExploreItem item) async { + if (item.albumId != null && item.albumId!.isNotEmpty) { + Navigator.push(context, MaterialPageRoute( + builder: (context) => ExtensionAlbumScreen( + extensionId: item.providerId ?? 'spotify-web', + albumId: item.albumId!, + albumName: item.albumName ?? 'Album', + coverUrl: item.coverUrl, + ), + )); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Album info not available')), + ); + } + } + Widget _buildRecentAccess( List items, List historyItems, @@ -2422,3 +2847,188 @@ class _ExtensionArtistScreenState extends ConsumerState { ); } } + +/// Swipeable Quick Picks widget with page indicator +class _QuickPicksPageView extends StatefulWidget { + final ExploreSection section; + final ColorScheme colorScheme; + final int itemsPerPage; + final int totalPages; + final void Function(ExploreItem) onItemTap; + final void Function(ExploreItem) onItemMenu; + + const _QuickPicksPageView({ + required this.section, + required this.colorScheme, + required this.itemsPerPage, + required this.totalPages, + required this.onItemTap, + required this.onItemMenu, + }); + + @override + State<_QuickPicksPageView> createState() => _QuickPicksPageViewState(); +} + +class _QuickPicksPageViewState extends State<_QuickPicksPageView> { + int _currentPage = 0; + late PageController _pageController; + + @override + void initState() { + super.initState(); + _pageController = PageController(); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + widget.section.title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + // Swipeable pages of tracks + SizedBox( + height: widget.itemsPerPage * 64.0, + child: PageView.builder( + controller: _pageController, + itemCount: widget.totalPages, + onPageChanged: (page) { + setState(() => _currentPage = page); + }, + itemBuilder: (context, pageIndex) { + final startIndex = pageIndex * widget.itemsPerPage; + final endIndex = (startIndex + widget.itemsPerPage).clamp(0, widget.section.items.length); + final pageItems = widget.section.items.sublist(startIndex, endIndex); + + return Column( + children: pageItems.map((item) => _buildQuickPickItem(item)).toList(), + ); + }, + ), + ), + // Page indicator dots + if (widget.totalPages > 1) + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(widget.totalPages, (index) { + final isActive = index == _currentPage; + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: isActive ? 8 : 6, + height: isActive ? 8 : 6, + margin: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive + ? widget.colorScheme.primary + : widget.colorScheme.onSurfaceVariant.withOpacity(0.3), + ), + ); + }), + ), + ), + ], + ); + } + + Widget _buildQuickPickItem(ExploreItem item) { + return InkWell( + onTap: () => widget.onItemTap(item), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + // Album art thumbnail + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: item.coverUrl != null && item.coverUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: item.coverUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + memCacheWidth: 96, + memCacheHeight: 96, + cacheManager: CoverCacheManager.instance, + errorWidget: (context, url, error) => Container( + width: 48, + height: 48, + color: widget.colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: widget.colorScheme.onSurfaceVariant, + size: 24, + ), + ), + ) + : Container( + width: 48, + height: 48, + color: widget.colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: widget.colorScheme.onSurfaceVariant, + size: 24, + ), + ), + ), + const SizedBox(width: 12), + // Title and artist + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: widget.colorScheme.onSurface, + ), + ), + if (item.artists.isNotEmpty) + Text( + item.artists, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: widget.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + // Menu button + IconButton( + icon: Icon( + Icons.more_vert, + color: widget.colorScheme.onSurfaceVariant, + size: 20, + ), + onPressed: () => widget.onItemMenu(item), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 92ca5ff0..ad8c8029 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -709,6 +709,7 @@ static const _allLanguages = [ ('pt', 'Português', Icons.language), ('pt_PT', 'Português (Brasil)', Icons.language), ('ru', 'Русский', Icons.language), + ('tr', 'Türkçe', Icons.language), ('zh', '简体中文', Icons.language), ('zh_CN', '简体中文 (中国)', Icons.language), ('zh_TW', '繁體中文', Icons.language), diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index f0895bcd..a310b130 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -794,6 +794,34 @@ class PlatformBridge { } } + /// Get extension home feed + static Future?> getExtensionHomeFeed(String extensionId) async { + try { + final result = await _channel.invokeMethod('getExtensionHomeFeed', { + 'extension_id': extensionId, + }); + if (result == null || result == '') return null; + return jsonDecode(result as String) as Map; + } catch (e) { + _log.e('getExtensionHomeFeed failed: $e'); + return null; + } + } + + /// Get extension browse categories + static Future?> getExtensionBrowseCategories(String extensionId) async { + try { + final result = await _channel.invokeMethod('getExtensionBrowseCategories', { + 'extension_id': extensionId, + }); + if (result == null || result == '') return null; + return jsonDecode(result as String) as Map; + } catch (e) { + _log.e('getExtensionBrowseCategories failed: $e'); + return null; + } + } + static Future> runPostProcessing( String filePath, { From 7a17de49b2bc6e4c5a58b8baf3a5f9090f6977c9 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 21 Jan 2026 09:16:11 +0700 Subject: [PATCH 06/19] fix: add duration_ms to home feed items and bump version to 3.2.0 - Add duration_ms field to ExploreItem model - Parse duration_ms from spotify-web and ytmusic home feed responses - Update _downloadExploreTrack to use item.durationMs - Fixes track duration showing 0:00 in metadata screen after download - Bump version to 3.2.0+63 --- go_backend/exports.go | 60 +++------- lib/constants/app_info.dart | 4 +- lib/providers/explore_provider.dart | 36 +++--- lib/screens/home_tab.dart | 173 +++++++++++++--------------- pubspec.yaml | 2 +- pubspec_ios.yaml | 2 +- 6 files changed, 124 insertions(+), 153 deletions(-) diff --git a/go_backend/exports.go b/go_backend/exports.go index 6c718a52..b485ca90 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -2083,8 +2083,7 @@ func ClearStoreCacheJSON() error { return nil } -// GetExtensionHomeFeedJSON calls getHomeFeed on any extension that supports it -func GetExtensionHomeFeedJSON(extensionID string) (string, error) { +func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Duration) (string, error) { manager := GetExtensionManager() ext, err := manager.GetExtension(extensionID) if err != nil { @@ -2097,22 +2096,22 @@ func GetExtensionHomeFeedJSON(extensionID string) (string, error) { provider := NewExtensionProviderWrapper(ext) - script := ` + script := fmt.Sprintf(` (function() { - if (typeof extension !== 'undefined' && typeof extension.getHomeFeed === 'function') { - return extension.getHomeFeed(); + if (typeof extension !== 'undefined' && typeof extension.%s === 'function') { + return extension.%s(); } return null; })() - ` + `, functionName, functionName) - result, err := RunWithTimeoutAndRecover(provider.vm, script, 60*time.Second) + result, err := RunWithTimeoutAndRecover(provider.vm, script, timeout) if err != nil { - return "", fmt.Errorf("getHomeFeed failed: %w", err) + return "", fmt.Errorf("%s failed: %w", functionName, err) } if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return "", fmt.Errorf("getHomeFeed returned null") + return "", fmt.Errorf("%s returned null", functionName) } exported := result.Export() @@ -2124,43 +2123,12 @@ func GetExtensionHomeFeedJSON(extensionID string) (string, error) { return string(jsonBytes), nil } +// GetExtensionHomeFeedJSON calls getHomeFeed on any extension that supports it +func GetExtensionHomeFeedJSON(extensionID string) (string, error) { + return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second) +} + // GetExtensionBrowseCategoriesJSON calls getBrowseCategories on any extension that supports it func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) { - manager := GetExtensionManager() - ext, err := manager.GetExtension(extensionID) - if err != nil { - return "", err - } - - if !ext.Enabled { - return "", fmt.Errorf("extension '%s' is disabled", extensionID) - } - - provider := NewExtensionProviderWrapper(ext) - - script := ` - (function() { - if (typeof extension !== 'undefined' && typeof extension.getBrowseCategories === 'function') { - return extension.getBrowseCategories(); - } - return null; - })() - ` - - result, err := RunWithTimeoutAndRecover(provider.vm, script, 30*time.Second) - if err != nil { - return "", fmt.Errorf("getBrowseCategories failed: %w", err) - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return "", fmt.Errorf("getBrowseCategories returned null") - } - - exported := result.Export() - jsonBytes, err := json.Marshal(exported) - if err != nil { - return "", fmt.Errorf("failed to marshal result: %w", err) - } - - return string(jsonBytes), nil + return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second) } diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 66e8a243..d300dca4 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.1.3'; - static const String buildNumber = '62'; +static const String version = '3.2.0'; + static const String buildNumber = '63'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart index 14a238cb..63cf137c 100644 --- a/lib/providers/explore_provider.dart +++ b/lib/providers/explore_provider.dart @@ -17,6 +17,7 @@ class ExploreItem { final String? providerId; final String? albumId; final String? albumName; + final int durationMs; const ExploreItem({ required this.id, @@ -29,6 +30,7 @@ class ExploreItem { this.providerId, this.albumId, this.albumName, + this.durationMs = 0, }); factory ExploreItem.fromJson(Map json) { @@ -43,6 +45,7 @@ class ExploreItem { providerId: json['provider_id'] as String?, albumId: json['album_id'] as String?, albumName: json['album_name'] as String?, + durationMs: json['duration_ms'] as int? ?? 0, ); } } @@ -125,6 +128,11 @@ class ExploreNotifier extends Notifier { _log.d('Using cached home feed'); return; } + + if (state.isLoading) { + _log.d('Home feed fetch already in progress'); + return; + } state = state.copyWith(isLoading: true, error: null); @@ -133,12 +141,21 @@ class ExploreNotifier extends Notifier { final extState = ref.read(extensionProvider); _log.d('Extensions count: ${extState.extensions.length}'); - // Look for extensions with homeFeed capability (prefer spotify-web, then ytmusic) - final homeFeedExtensions = extState.extensions.where( - (e) => e.enabled && e.hasHomeFeed, - ).toList(); + // Look for extensions with homeFeed capability (prefer spotify-web) + Extension? targetExt; + for (final extension in extState.extensions) { + if (!extension.enabled || !extension.hasHomeFeed) { + continue; + } + if (targetExt == null || extension.id == 'spotify-web') { + targetExt = extension; + if (extension.id == 'spotify-web') { + break; + } + } + } - if (homeFeedExtensions.isEmpty) { + if (targetExt == null) { _log.w('No extension with homeFeed capability found'); state = state.copyWith( isLoading: false, @@ -147,16 +164,8 @@ class ExploreNotifier extends Notifier { return; } - // Prefer spotify-web if available, otherwise use first available - var targetExt = homeFeedExtensions.firstWhere( - (e) => e.id == 'spotify-web', - orElse: () => homeFeedExtensions.first, - ); - _log.i('Fetching home feed from ${targetExt.id}...'); final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id); - - _log.d('getExtensionHomeFeed result: $result'); if (result == null) { state = state.copyWith( @@ -167,6 +176,7 @@ class ExploreNotifier extends Notifier { } final success = result['success'] as bool? ?? false; + _log.d('getExtensionHomeFeed success=$success'); if (!success) { final error = result['error'] as String? ?? 'Unknown error'; state = state.copyWith( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index d1006315..3f57539f 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -34,6 +34,8 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient bool _isTyping = false; final FocusNode _searchFocusNode = FocusNode(); String? _lastSearchQuery; + late final ProviderSubscription _trackStateSub; + late final ProviderSubscription _extensionInitSub; /// Debounce timer for live search (extension-only feature) Timer? _liveSearchDebounce; @@ -58,16 +60,32 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient super.initState(); _urlController.addListener(_onSearchChanged); _searchFocusNode.addListener(_onSearchFocusChanged); + + _trackStateSub = ref.listenManual(trackProvider, (previous, next) { + _onTrackStateChanged(previous, next); + if (previous != null && previous.isLoading && !next.isLoading && next.error == null) { + _navigateToDetailIfNeeded(); + } + }); + + _extensionInitSub = ref.listenManual( + extensionProvider.select((s) => s.isInitialized), + (previous, next) { + if (next == true && previous != true) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _fetchExploreIfNeeded(); + }); + } + }, + ); } void _fetchExploreIfNeeded() { final extState = ref.read(extensionProvider); final exploreState = ref.read(exploreProvider); - // Check if any extension with homeFeed capability is enabled final hasHomeFeedExtension = extState.extensions.any( (e) => e.enabled && e.hasHomeFeed, ); - // Fetch if any homeFeed extension is enabled and we don't have content yet if (hasHomeFeedExtension && !exploreState.hasContent && !exploreState.isLoading) { ref.read(exploreProvider.notifier).fetchHomeFeed(); } @@ -76,6 +94,8 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient @override void dispose() { _liveSearchDebounce?.cancel(); + _trackStateSub.close(); + _extensionInitSub.close(); _urlController.removeListener(_onSearchChanged); _searchFocusNode.removeListener(_onSearchFocusChanged); _urlController.dispose(); @@ -123,14 +143,10 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } else if (text.isEmpty && _isTyping) { setState(() => _isTyping = false); _liveSearchDebounce?.cancel(); - // Don't clear provider here - it causes focus issues - // Provider will be cleared when user explicitly clears or navigates away return; } - // Live search - only for extensions if (_isLiveSearchEnabled() && text.length >= _minLiveSearchChars) { - // Skip if it's a URL (let user press enter for URLs) if (text.startsWith('http') || text.startsWith('spotify:')) return; _liveSearchDebounce?.cancel(); @@ -156,7 +172,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } finally { _isLiveSearchInProgress = false; - // Check if there's a pending query that was queued while we were searching final pending = _pendingLiveSearchQuery; _pendingLiveSearchQuery = null; @@ -386,7 +401,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient }, ); } else { - // Use default settings without quality picker final confirmed = await showDialog( context: this.context, builder: (dialogCtx) => AlertDialog( @@ -427,32 +441,12 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient Widget build(BuildContext context) { super.build(context); - ref.listen(trackProvider, (previous, next) { - _onTrackStateChanged(previous, next); - if (previous != null && previous.isLoading && !next.isLoading && next.error == null) { - _navigateToDetailIfNeeded(); - } - }); - - // Listen for extension state changes to trigger explore fetch - ref.listen(extensionProvider.select((s) => s.isInitialized), (previous, next) { - if (next == true && previous != true) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _fetchExploreIfNeeded(); - }); - } - }); - final tracks = ref.watch(trackProvider.select((s) => s.tracks)); final searchArtists = ref.watch(trackProvider.select((s) => s.searchArtists)); final isLoading = ref.watch(trackProvider.select((s) => s.isLoading)); final error = ref.watch(trackProvider.select((s) => s.error)); final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore)); - ref.watch(extensionProvider.select((s) => s.isInitialized)); - ref.watch(extensionProvider.select((s) => s.extensions)); - - // Explore state final exploreState = ref.watch(exploreProvider); final hasHomeFeedExtension = ref.watch(extensionProvider.select((s) => s.extensions.any((e) => e.enabled && e.hasHomeFeed) @@ -462,15 +456,15 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final hasActualResults = tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty); final isShowingRecentAccess = ref.watch(trackProvider.select((s) => s.isShowingRecentAccess)); final hasResults = isShowingRecentAccess || hasActualResults || isLoading; - final screenHeight = MediaQuery.of(context).size.height; - final topPadding = MediaQuery.of(context).padding.top; + final mediaQuery = MediaQuery.of(context); + final screenHeight = mediaQuery.size.height; + final topPadding = mediaQuery.padding.top; final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items)); final recentAccessItems = ref.watch(recentAccessProvider.select((s) => s.items)); final hasRecentItems = recentAccessItems.isNotEmpty || historyItems.isNotEmpty; final showRecentAccess = isShowingRecentAccess && hasRecentItems && !hasActualResults && !isLoading; - // Show explore only when no search results and not showing recent access final showExplore = !hasActualResults && !isLoading && !showRecentAccess && hasHomeFeedExtension && exploreState.hasContent; if (hasActualResults && isShowingRecentAccess) { @@ -619,11 +613,9 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ), - // Explore sections (Spotify Home Feed) if (showExplore) ..._buildExploreSections(exploreState, colorScheme), - // Loading indicator for explore if (hasHomeFeedExtension && !hasActualResults && !isLoading && exploreState.isLoading) const SliverToBoxAdapter( child: Padding( @@ -720,45 +712,44 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } List _buildExploreSections(ExploreState exploreState, ColorScheme colorScheme) { - final slivers = []; + final greeting = exploreState.greeting; + final hasGreeting = greeting != null && greeting.isNotEmpty; + final sections = exploreState.sections; + final sectionOffset = hasGreeting ? 1 : 0; + final totalCount = sections.length + sectionOffset + 1; // + bottom padding - // Greeting (pull-to-refresh handles refresh) - if (exploreState.greeting != null && exploreState.greeting!.isNotEmpty) { - slivers.add( - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text( - exploreState.greeting!, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), + return [ + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (hasGreeting && index == 0) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + greeting!, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ); + } + + final sectionIndex = index - sectionOffset; + if (sectionIndex < sections.length) { + return _buildExploreSection(sections[sectionIndex], colorScheme); + } + + // Bottom padding + return const SizedBox(height: 16); + }, + childCount: totalCount, ), - ); - } - - // Build each section - for (final section in exploreState.sections) { - slivers.add( - SliverToBoxAdapter( - child: _buildExploreSection(section, colorScheme), - ), - ); - } - - // Add some bottom padding - slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 16))); - - return slivers; + ), + ]; } Widget _buildExploreSection(ExploreSection section, ColorScheme colorScheme) { - // Check if this is a YT Music "Quick picks" style section (vertical list) - final isYTMusicQuickPicks = section.items.isNotEmpty && - section.items.first.providerId == 'ytmusic-spotiflac' && - section.items.every((item) => item.type == 'track'); + final isYTMusicQuickPicks = _isYTMusicQuickPicksSection(section); if (isYTMusicQuickPicks) { return _buildYTMusicQuickPicksSection(section, colorScheme); @@ -792,6 +783,19 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } + bool _isYTMusicQuickPicksSection(ExploreSection section) { + if (section.items.isEmpty) return false; + if (section.items.first.providerId != 'ytmusic-spotiflac') return false; + + for (final item in section.items) { + if (item.type != 'track') { + return false; + } + } + + return true; + } + /// Build YT Music "Quick picks" style swipeable pages section Widget _buildYTMusicQuickPicksSection(ExploreSection section, ColorScheme colorScheme) { const itemsPerPage = 5; @@ -902,8 +906,8 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient switch (item.type) { case 'track': - // Show bottom sheet with track info and download option _showTrackBottomSheet(item); + return; case 'album': Navigator.push(context, MaterialPageRoute( builder: (context) => ExtensionAlbumScreen( @@ -913,6 +917,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient coverUrl: item.coverUrl, ), )); + return; case 'playlist': Navigator.push(context, MaterialPageRoute( builder: (context) => ExtensionPlaylistScreen( @@ -922,6 +927,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient coverUrl: item.coverUrl, ), )); + return; case 'artist': Navigator.push(context, MaterialPageRoute( builder: (context) => ExtensionArtistScreen( @@ -931,10 +937,12 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient coverUrl: item.coverUrl, ), )); + return; default: ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('${item.type}: ${item.name}')), ); + return; } } @@ -951,7 +959,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Handle bar Container( margin: const EdgeInsets.only(top: 12), width: 40, @@ -961,7 +968,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient borderRadius: BorderRadius.circular(2), ), ), - // Track info Padding( padding: const EdgeInsets.all(16), child: Row( @@ -1013,7 +1019,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ), const Divider(height: 1), - // Actions ListTile( leading: Icon(Icons.download, color: colorScheme.primary), title: Text(context.l10n.downloadTitle), @@ -1027,7 +1032,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient title: const Text('Go to Album'), onTap: () { Navigator.pop(context); - // Navigate to album - we'll use the track ID to search _navigateToTrackAlbum(item); }, ), @@ -1041,17 +1045,15 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient Future _downloadExploreTrack(ExploreItem item) async { final settings = ref.read(settingsProvider); - // Create a Track object from ExploreItem - // Pass spotify ID as ISRC so enrichment can look it up via SongLink/Deezer final track = Track( id: item.id, name: item.name, artistName: item.artists, albumName: item.albumName ?? '', - duration: 0, + duration: item.durationMs ~/ 1000, trackNumber: 1, discNumber: 1, - isrc: item.id, // Pass Spotify ID - enrichment will detect and lookup real ISRC + isrc: item.id, releaseDate: null, coverUrl: item.coverUrl, source: item.providerId ?? 'spotify-web', @@ -1100,7 +1102,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient List historyItems, ColorScheme colorScheme, ) { - // Group download history by album final albumGroups = >{}; for (final h in historyItems) { final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty) @@ -1161,7 +1162,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient return true; }).take(10).toList(); - // Check if there are hidden downloads final hasHiddenDownloads = hiddenIds.isNotEmpty; return Padding( @@ -1959,7 +1959,6 @@ class _SearchProviderDropdown extends ConsumerWidget { currentExt = searchProviders.where((e) => e.id == currentProvider).firstOrNull; } - // Determine display icon IconData displayIcon = Icons.search; String? iconPath; if (currentExt != null) { @@ -2037,7 +2036,6 @@ class _SearchProviderDropdown extends ConsumerWidget { ), ), if (searchProviders.isNotEmpty) const PopupMenuDivider(), - // Extension providers ...searchProviders.map((ext) => PopupMenuItem( value: ext.id, child: Row( @@ -2900,7 +2898,6 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> { ), ), ), - // Swipeable pages of tracks SizedBox( height: widget.itemsPerPage * 64.0, child: PageView.builder( @@ -2920,7 +2917,6 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> { }, ), ), - // Page indicator dots if (widget.totalPages > 1) Padding( padding: const EdgeInsets.only(top: 8, bottom: 4), @@ -2950,12 +2946,11 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> { Widget _buildQuickPickItem(ExploreItem item) { return InkWell( onTap: () => widget.onItemTap(item), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - // Album art thumbnail - ClipRRect( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + ClipRRect( borderRadius: BorderRadius.circular(4), child: item.coverUrl != null && item.coverUrl!.isNotEmpty ? CachedNetworkImage( @@ -2989,7 +2984,6 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> { ), ), const SizedBox(width: 12), - // Title and artist Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -3015,7 +3009,6 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> { ], ), ), - // Menu button IconButton( icon: Icon( Icons.more_vert, diff --git a/pubspec.yaml b/pubspec.yaml index 18ea5cab..ea2ae88e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.1.3+62 +version: 3.2.0+63 environment: sdk: ^3.10.0 diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index dea7148b..2ace0a56 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.1.3+62 +version: 3.2.0+63 environment: sdk: ^3.10.0 From b899b54bb816d79f96bffeea9b7cb9720a9f17f3 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 21 Jan 2026 09:56:58 +0700 Subject: [PATCH 07/19] perf: migrate history to SQLite and optimize palette extraction - Add SQLite database for download history with O(1) indexed lookups - Add in-memory Map indexes for O(1) getBySpotifyId/getByIsrc - Automatic migration from SharedPreferences on first launch - Fix PaletteService to use PaletteGenerator (isolate approach didn't work) - Use small image size (64x64) and limited colors (8) for speed - Add caching to avoid re-extraction - All screens now use consistent PaletteService - Update CHANGELOG with all v3.2.0 changes --- CHANGELOG.md | 39 +++ lib/providers/download_queue_provider.dart | 203 ++++++------- lib/screens/album_screen.dart | 18 +- lib/screens/downloaded_album_screen.dart | 31 +- lib/screens/playlist_screen.dart | 18 +- lib/screens/track_metadata_screen.dart | 36 ++- lib/services/history_database.dart | 326 +++++++++++++++++++++ lib/services/palette_service.dart | 59 ++++ pubspec.lock | 2 +- pubspec.yaml | 1 + pubspec_ios.yaml | 1 + 11 files changed, 557 insertions(+), 177 deletions(-) create mode 100644 lib/services/history_database.dart create mode 100644 lib/services/palette_service.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 495d9f1e..f3a7abc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,19 @@ - Uses Go's `time.Now()` for accurate device timezone detection - Solves Goja JS engine's `getTimezoneOffset()` returning 0 issue +- **SQLite Database for Download History**: Migrated from SharedPreferences to SQLite + - New `HistoryDatabase` service with proper schema and indexes + - O(1) lookups by `spotify_id` and `isrc` (was O(n) linear search) + - Non-blocking writes - UI stays responsive during saves + - Automatic one-time migration from SharedPreferences on first launch + - No storage size limits (was ~1MB with SharedPreferences) + - Database schema with indexes: `idx_spotify_id`, `idx_isrc`, `idx_downloaded_at`, `idx_album` + +- **Track Duration in Home Feed Items**: Home feed tracks now include duration + - Added `duration_ms` field to `ExploreItem` model + - Parsed from spotify-web and ytmusic home feed responses + - Fixes track duration showing "0:00" in metadata screen after download from home feed + ### Fixed - **YT Music Greeting Time**: Fixed "Good night" showing in the morning @@ -42,16 +55,24 @@ - Now uses `gobackend.getLocalTime().timezone` or offset mapping - Ensures personalized content is based on correct user timezone +- **Home Feed Track Duration**: Fixed duration showing 0:00 when downloading from home feed + - spotify-web and ytmusic extensions now include `duration_ms` in home feed items + - `ExploreItem` model now has `durationMs` field + - `_downloadExploreTrack()` uses `item.durationMs` instead of hardcoded 0 +- **Explore Item Navigation**: Prevents fallthrough so tapping a track/album/playlist/artist only triggers its intended action + ### Extensions - **spotify-web Extension**: Updated to v1.8.0 - Added `capabilities: { homeFeed: true, browseCategories: true }` to manifest - `fetchHomeFeed()` now uses `gobackend.getLocalTime()` for timezone detection + - Added `duration_ms` to home feed track items - Removed reliance on Goja's broken `getTimezoneOffset()` and `Intl.DateTimeFormat()` - **ytmusic-spotiflac Extension**: Updated to v1.6.0 - Added `capabilities: { homeFeed: true }` to manifest - `getTimeBasedGreeting()` now uses `gobackend.getLocalTime().hour` directly + - Added `duration_ms` parsing from subtitle runs in home feed items - Simplified greeting logic - no more manual UTC offset calculations ### Technical @@ -65,12 +86,30 @@ - File: `lib/providers/explore_provider.dart` - Finds extensions with `hasHomeFeed` capability - Prefers spotify-web if available, falls back to first available + - Added `durationMs` field to `ExploreItem` model +- **Explore Provider**: Single-pass home feed extension selection (prefers spotify-web) and guard against parallel fetches +- **Go Backend Extensions**: Consolidates `getHomeFeed`/`getBrowseCategories` execution into a shared helper - **Flutter Home Tab**: Refactored explore sections rendering - File: `lib/screens/home_tab.dart` - Added `RefreshIndicator` wrapper with `notificationPredicate` for conditional refresh - Added `_buildYTMusicQuickPicksSection()` for special YT Music format - Added `_QuickPicksPageView` StatefulWidget for swipeable track pages + - `_downloadExploreTrack()` now uses `item.durationMs` + - Uses a single `SliverList` for Explore sections to reduce sliver count + - Moves provider listeners to `initState` with `listenManual` + - Early-exit loop for YT Music Quick Picks detection + - Removes redundant provider watches and reuses `MediaQuery` values + +### Performance + +- **Download History Database**: Migrated from JSON/SharedPreferences to SQLite + - File: `lib/services/history_database.dart` + - Load time: O(query) instead of O(parse entire JSON) + - Lookup by spotify_id/isrc: O(1) with index instead of O(n) linear search + - Save single item: O(1) INSERT instead of O(n) serialize entire list + - Delete single item: O(1) DELETE instead of O(n) serialize entire list + - Memory: Only loaded items in memory, not entire JSON string ## [3.1.3] - 2026-01-19 diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index e4be0292..b95c05bb 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -13,6 +13,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/notification_service.dart'; +import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('DownloadQueue'); @@ -130,15 +131,36 @@ class DownloadHistoryItem { class DownloadHistoryState { final List items; final Set _downloadedSpotifyIds; + final Map _bySpotifyId; + final Map _byIsrc; DownloadHistoryState({this.items = const []}) : _downloadedSpotifyIds = items .where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty) .map((item) => item.spotifyId!) - .toSet(); + .toSet(), + _bySpotifyId = Map.fromEntries( + items + .where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty) + .map((item) => MapEntry(item.spotifyId!, item)), + ), + _byIsrc = Map.fromEntries( + items + .where((item) => item.isrc != null && item.isrc!.isNotEmpty) + .map((item) => MapEntry(item.isrc!, item)), + ); + /// O(1) check if spotify_id exists bool isDownloaded(String spotifyId) => _downloadedSpotifyIds.contains(spotifyId); + + /// O(1) lookup by spotify_id + DownloadHistoryItem? getBySpotifyId(String spotifyId) => + _bySpotifyId[spotifyId]; + + /// O(1) lookup by ISRC + DownloadHistoryItem? getByIsrc(String isrc) => + _byIsrc[isrc]; DownloadHistoryState copyWith({List? items}) { return DownloadHistoryState(items: items ?? this.items); @@ -146,130 +168,58 @@ class DownloadHistoryState { } class DownloadHistoryNotifier extends Notifier { - static const _storageKey = 'download_history'; - final Future _prefs = SharedPreferences.getInstance(); + final HistoryDatabase _db = HistoryDatabase.instance; bool _isLoaded = false; @override DownloadHistoryState build() { - _loadFromStorageSync(); + _loadFromDatabaseSync(); return DownloadHistoryState(); } /// Synchronously schedule load - ensures it runs before any UI renders - void _loadFromStorageSync() { + void _loadFromDatabaseSync() { if (_isLoaded) return; Future.microtask(() async { - await _loadFromStorage(); + await _loadFromDatabase(); _isLoaded = true; }); } - Future _loadFromStorage() async { + Future _loadFromDatabase() async { try { - final prefs = await _prefs; - final jsonStr = prefs.getString(_storageKey); - if (jsonStr != null && jsonStr.isNotEmpty) { - final List jsonList = jsonDecode(jsonStr); - final items = jsonList - .map((e) => DownloadHistoryItem.fromJson(e as Map)) - .toList(); - - final deduplicatedItems = _deduplicateHistory(items); - - state = state.copyWith(items: deduplicatedItems); - _historyLog.i('Loaded ${deduplicatedItems.length} items from storage (original: ${items.length})'); - - if (deduplicatedItems.length < items.length) { - _historyLog.i('Removed ${items.length - deduplicatedItems.length} duplicate entries'); - await _saveToStorage(); - } - } else { - _historyLog.d('No history found in storage'); - } - } catch (e) { - _historyLog.e('Failed to load history: $e'); - } - } - - /// Keeps the most recent entry (first occurrence since list is sorted by date desc) - List _deduplicateHistory(List items) { - final seen = {}; // key -> index of first occurrence - final result = []; - - for (int i = 0; i < items.length; i++) { - final item = items[i]; - String? key; - - if (item.spotifyId != null && item.spotifyId!.isNotEmpty) { - if (item.spotifyId!.startsWith('deezer:')) { - key = 'deezer:${item.spotifyId!.substring(7)}'; - } else { - key = 'spotify:${item.spotifyId}'; - } - } else if (item.isrc != null && item.isrc!.isNotEmpty) { - key = 'isrc:${item.isrc}'; + final migrated = await _db.migrateFromSharedPreferences(); + if (migrated) { + _historyLog.i('Migrated history from SharedPreferences to SQLite'); } - if (key != null) { - if (!seen.containsKey(key)) { - seen[key] = result.length; - result.add(item); - } else { - _historyLog.d('Skipping duplicate: ${item.trackName} (key: $key)'); - } - } else { - result.add(item); - } - } - - return result; - } - - Future _saveToStorage() async { - try { - final prefs = await _prefs; - final jsonList = state.items.map((e) => e.toJson()).toList(); - await prefs.setString(_storageKey, jsonEncode(jsonList)); - _historyLog.d('Saved ${state.items.length} items to storage'); - } catch (e) { - _historyLog.e('Failed to save history: $e'); + final jsonList = await _db.getAll(); + final items = jsonList + .map((e) => DownloadHistoryItem.fromJson(e)) + .toList(); + + state = state.copyWith(items: items); + _historyLog.i('Loaded ${items.length} items from SQLite database'); + } catch (e, stack) { + _historyLog.e('Failed to load history from database: $e', e, stack); } } Future reloadFromStorage() async { - await _loadFromStorage(); + await _loadFromDatabase(); } void addToHistory(DownloadHistoryItem item) { - final existingIndex = state.items.indexWhere((existing) { - if (item.spotifyId != null && - item.spotifyId!.isNotEmpty && - existing.spotifyId == item.spotifyId) { - return true; - } - - if (item.spotifyId != null && item.spotifyId!.startsWith('deezer:') && - existing.spotifyId != null && existing.spotifyId!.startsWith('deezer:')) { - final itemDeezerId = item.spotifyId!.substring(7); - final existingDeezerId = existing.spotifyId!.substring(7); - if (itemDeezerId == existingDeezerId) { - return true; - } - } - - if (item.isrc != null && - item.isrc!.isNotEmpty && - existing.isrc == item.isrc) { - return true; - } - return false; - }); + DownloadHistoryItem? existing; + if (item.spotifyId != null && item.spotifyId!.isNotEmpty) { + existing = state.getBySpotifyId(item.spotifyId!); + } + if (existing == null && item.isrc != null && item.isrc!.isNotEmpty) { + existing = state.getByIsrc(item.isrc!); + } - if (existingIndex >= 0) { - final updatedItems = [...state.items]; - updatedItems[existingIndex] = item; - updatedItems.removeAt(existingIndex); + if (existing != null) { + final updatedItems = state.items.where((i) => i.id != existing!.id).toList(); updatedItems.insert(0, item); state = state.copyWith(items: updatedItems); _historyLog.d('Updated existing history entry: ${item.trackName}'); @@ -277,31 +227,60 @@ class DownloadHistoryNotifier extends Notifier { state = state.copyWith(items: [item, ...state.items]); _historyLog.d('Added new history entry: ${item.trackName}'); } - _saveToStorage(); + + _db.upsert(item.toJson()).catchError((e) { + _historyLog.e('Failed to save to database: $e'); + }); } void removeFromHistory(String id) { state = state.copyWith( items: state.items.where((item) => item.id != id).toList(), ); - _saveToStorage(); + _db.deleteById(id).catchError((e) { + _historyLog.e('Failed to delete from database: $e'); + }); } void removeBySpotifyId(String spotifyId) { state = state.copyWith( items: state.items.where((item) => item.spotifyId != spotifyId).toList(), ); - _saveToStorage(); + _db.deleteBySpotifyId(spotifyId).catchError((e) { + _historyLog.e('Failed to delete from database: $e'); + }); _historyLog.d('Removed item with spotifyId: $spotifyId'); } DownloadHistoryItem? getBySpotifyId(String spotifyId) { - return state.items.where((item) => item.spotifyId == spotifyId).firstOrNull; + return state.getBySpotifyId(spotifyId); + } + + /// O(1) lookup by ISRC + DownloadHistoryItem? getByIsrc(String isrc) { + return state.getByIsrc(isrc); + } + + /// Async version with database lookup (for cases where in-memory might be stale) + Future getBySpotifyIdAsync(String spotifyId) async { + final inMemory = state.getBySpotifyId(spotifyId); + if (inMemory != null) return inMemory; + + final json = await _db.getBySpotifyId(spotifyId); + if (json == null) return null; + return DownloadHistoryItem.fromJson(json); } void clearHistory() { state = DownloadHistoryState(); - _saveToStorage(); + _db.clearAll().catchError((e) { + _historyLog.e('Failed to clear database: $e'); + }); + } + + /// Get database stats for debugging + Future getDatabaseCount() async { + return await _db.getCount(); } } @@ -790,7 +769,7 @@ class DownloadQueueNotifier extends Notifier { String _sanitizeFolderName(String name) { return name .replaceAll(_invalidFolderChars, '_') - .replaceAll(_trailingDotsRegex, '') // Remove trailing dots + .replaceAll(_trailingDotsRegex, '') .trim(); } @@ -1067,8 +1046,8 @@ class DownloadQueueNotifier extends Notifier { /// Same logic as Go backend cover.go String _upgradeToMaxQualityCover(String coverUrl) { - const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small) - const spotifySize640 = 'ab67616d0000b273'; // 640x640 (medium) + const spotifySize300 = 'ab67616d00001e02'; + const spotifySize640 = 'ab67616d0000b273'; const spotifySizeMax = 'ab67616d000082c1'; var result = coverUrl; @@ -1667,7 +1646,6 @@ class DownloadQueueNotifier extends Notifier { deezerTrackId = trackToDownload.availability!.deezerId; } - // If no deezerTrackId but we have ISRC, try to find track via ISRC if (deezerTrackId == null && trackToDownload.isrc != null && trackToDownload.isrc!.isNotEmpty) { try { _log.d('No Deezer ID, searching by ISRC: ${trackToDownload.isrc}'); @@ -1772,9 +1750,8 @@ class DownloadQueueNotifier extends Notifier { trackNumber: trackToDownload.trackNumber ?? 1, discNumber: trackToDownload.discNumber ?? 1, releaseDate: trackToDownload.releaseDate, - itemId: item.id, // Pass item ID for progress tracking - durationMs: - trackToDownload.duration, // Duration in ms for verification + itemId: item.id, + durationMs: trackToDownload.duration, ); } @@ -1814,7 +1791,7 @@ class DownloadQueueNotifier extends Notifier { final actualBitDepth = result['actual_bit_depth'] as int?; final actualSampleRate = result['actual_sample_rate'] as int?; - String actualQuality = quality; // Default to requested quality + String actualQuality = quality; if (actualBitDepth != null && actualBitDepth > 0) { // Format: "24-bit/96kHz" or "16-bit/44.1kHz" diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 86db03de..34c36a6f 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:palette_generator/palette_generator.dart'; +import 'package:spotiflac_android/services/palette_service.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; @@ -105,19 +105,9 @@ class _AlbumScreenState extends ConsumerState { Future _extractDominantColor() async { if (widget.coverUrl == null) return; - try { - final paletteGenerator = await PaletteGenerator.fromImageProvider( - CachedNetworkImageProvider(widget.coverUrl!), - maximumColorCount: 16, - ); - if (mounted) { - setState(() { - _dominantColor = paletteGenerator.dominantColor?.color ?? - paletteGenerator.vibrantColor?.color ?? - paletteGenerator.mutedColor?.color; - }); - } - } catch (_) { + final color = await PaletteService.instance.extractDominantColor(widget.coverUrl); + if (mounted && color != null) { + setState(() => _dominantColor = color); } } diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index a2ae3f42..0dca2c86 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:palette_generator/palette_generator.dart'; import 'package:open_filex/open_filex.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/services/palette_service.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; @@ -59,24 +59,23 @@ class _DownloadedAlbumScreenState extends ConsumerState { Future _extractDominantColor() async { if (widget.coverUrl == null || widget.coverUrl!.isEmpty) return; - // Only use network images for palette extraction - final isNetworkUrl = widget.coverUrl!.startsWith('http://') || - widget.coverUrl!.startsWith('https://'); - if (!isNetworkUrl) return; - - try { - final paletteGenerator = await PaletteGenerator.fromImageProvider( - CachedNetworkImageProvider(widget.coverUrl!), - maximumColorCount: 16, - ); - if (mounted) { + // Check cache first (instant) + final cached = PaletteService.instance.getCached(widget.coverUrl); + if (cached != null) { + if (mounted && cached != _dominantColor) { setState(() { - _dominantColor = paletteGenerator.dominantColor?.color ?? - paletteGenerator.vibrantColor?.color ?? - paletteGenerator.mutedColor?.color; + _dominantColor = cached; }); } - } catch (_) { + return; + } + + // Extract in isolate (non-blocking) + final color = await PaletteService.instance.extractDominantColor(widget.coverUrl); + if (mounted && color != null && color != _dominantColor) { + setState(() { + _dominantColor = color; + }); } } diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index e64c8daf..51b2f969 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:palette_generator/palette_generator.dart'; +import 'package:spotiflac_android/services/palette_service.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; @@ -55,19 +55,9 @@ class _PlaylistScreenState extends ConsumerState { Future _extractDominantColor() async { if (widget.coverUrl == null) return; - try { - final paletteGenerator = await PaletteGenerator.fromImageProvider( - CachedNetworkImageProvider(widget.coverUrl!), - maximumColorCount: 16, - ); - if (mounted) { - setState(() { - _dominantColor = paletteGenerator.dominantColor?.color ?? - paletteGenerator.vibrantColor?.color ?? - paletteGenerator.mutedColor?.color; - }); - } - } catch (_) { + final color = await PaletteService.instance.extractDominantColor(widget.coverUrl); + if (mounted && color != null) { + setState(() => _dominantColor = color); } } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index caaa6343..30f07f47 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:palette_generator/palette_generator.dart'; import 'package:open_filex/open_filex.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/services/palette_service.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:share_plus/share_plus.dart'; @@ -61,7 +61,10 @@ class _TrackMetadataScreenState extends ConsumerState { super.initState(); _scrollController.addListener(_onScroll); _checkFile(); - _extractDominantColor(); + // Delay palette extraction to avoid jitter during initial build + WidgetsBinding.instance.addPostFrameCallback((_) { + _extractDominantColor(); + }); } @override @@ -80,25 +83,20 @@ class _TrackMetadataScreenState extends ConsumerState { Future _extractDominantColor() async { final coverUrl = widget.item.coverUrl; - if (coverUrl == null || coverUrl.isEmpty) return; - if (!coverUrl.startsWith('http://') && !coverUrl.startsWith('https://')) { + + // Check cache first + final cachedColor = PaletteService.instance.getCached(coverUrl); + if (cachedColor != null) { + if (mounted && cachedColor != _dominantColor) { + setState(() => _dominantColor = cachedColor); + } return; } - try { - final paletteGenerator = await PaletteGenerator.fromImageProvider( - CachedNetworkImageProvider(coverUrl), - size: const Size(128, 128), - maximumColorCount: 12, - ); - final nextColor = paletteGenerator.dominantColor?.color ?? - paletteGenerator.vibrantColor?.color ?? - paletteGenerator.mutedColor?.color; - if (mounted && nextColor != _dominantColor) { - setState(() { - _dominantColor = nextColor; - }); - } - } catch (_) { + + // Extract using PaletteService (runs in isolate) + final color = await PaletteService.instance.extractDominantColor(coverUrl); + if (mounted && color != null && color != _dominantColor) { + setState(() => _dominantColor = color); } } diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart new file mode 100644 index 00000000..fc2df58e --- /dev/null +++ b/lib/services/history_database.dart @@ -0,0 +1,326 @@ +import 'dart:convert'; +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('HistoryDatabase'); + +/// SQLite database service for download history +/// Provides O(1) lookups by spotify_id and isrc with proper indexing +class HistoryDatabase { + static final HistoryDatabase instance = HistoryDatabase._init(); + static Database? _database; + + HistoryDatabase._init(); + + Future get database async { + if (_database != null) return _database!; + _database = await _initDB('history.db'); + return _database!; + } + + Future _initDB(String fileName) async { + final dbPath = await getApplicationDocumentsDirectory(); + final path = join(dbPath.path, fileName); + + _log.i('Initializing database at: $path'); + + return await openDatabase( + path, + version: 1, + onCreate: _createDB, + onUpgrade: _upgradeDB, + ); + } + + Future _createDB(Database db, int version) async { + _log.i('Creating database schema v$version'); + + await db.execute(''' + CREATE TABLE history ( + id TEXT PRIMARY KEY, + track_name TEXT NOT NULL, + artist_name TEXT NOT NULL, + album_name TEXT NOT NULL, + album_artist TEXT, + cover_url TEXT, + file_path TEXT NOT NULL, + service TEXT NOT NULL, + downloaded_at TEXT NOT NULL, + isrc TEXT, + spotify_id TEXT, + track_number INTEGER, + disc_number INTEGER, + duration INTEGER, + release_date TEXT, + quality TEXT, + bit_depth INTEGER, + sample_rate INTEGER, + genre TEXT, + label TEXT, + copyright TEXT + ) + '''); + + // Indexes for fast lookups + await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)'); + await db.execute('CREATE INDEX idx_isrc ON history(isrc)'); + await db.execute('CREATE INDEX idx_downloaded_at ON history(downloaded_at DESC)'); + await db.execute('CREATE INDEX idx_album ON history(album_name, album_artist)'); + + _log.i('Database schema created with indexes'); + } + + Future _upgradeDB(Database db, int oldVersion, int newVersion) async { + _log.i('Upgrading database from v$oldVersion to v$newVersion'); + // Future migrations go here + } + + /// Migrate data from SharedPreferences to SQLite + /// Returns true if migration was performed, false if already migrated + Future migrateFromSharedPreferences() async { + final prefs = await SharedPreferences.getInstance(); + final migrationKey = 'history_migrated_to_sqlite'; + + if (prefs.getBool(migrationKey) == true) { + _log.d('Already migrated to SQLite'); + return false; + } + + final jsonStr = prefs.getString('download_history'); + if (jsonStr == null || jsonStr.isEmpty) { + _log.d('No SharedPreferences history to migrate'); + await prefs.setBool(migrationKey, true); + return false; + } + + try { + final List jsonList = jsonDecode(jsonStr); + _log.i('Migrating ${jsonList.length} items from SharedPreferences to SQLite'); + + final db = await database; + final batch = db.batch(); + + for (final json in jsonList) { + final map = json as Map; + batch.insert( + 'history', + _jsonToDbRow(map), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + await batch.commit(noResult: true); + + // Mark as migrated but keep old data for safety + await prefs.setBool(migrationKey, true); + _log.i('Migration complete: ${jsonList.length} items'); + + return true; + } catch (e, stack) { + _log.e('Migration failed: $e', e, stack); + return false; + } + } + + /// Convert JSON format (camelCase) to DB row (snake_case) + Map _jsonToDbRow(Map json) { + return { + 'id': json['id'], + 'track_name': json['trackName'], + 'artist_name': json['artistName'], + 'album_name': json['albumName'], + 'album_artist': json['albumArtist'], + 'cover_url': json['coverUrl'], + 'file_path': json['filePath'], + 'service': json['service'], + 'downloaded_at': json['downloadedAt'], + 'isrc': json['isrc'], + 'spotify_id': json['spotifyId'], + 'track_number': json['trackNumber'], + 'disc_number': json['discNumber'], + 'duration': json['duration'], + 'release_date': json['releaseDate'], + 'quality': json['quality'], + 'bit_depth': json['bitDepth'], + 'sample_rate': json['sampleRate'], + 'genre': json['genre'], + 'label': json['label'], + 'copyright': json['copyright'], + }; + } + + /// Convert DB row (snake_case) to JSON format (camelCase) + Map _dbRowToJson(Map row) { + return { + 'id': row['id'], + 'trackName': row['track_name'], + 'artistName': row['artist_name'], + 'albumName': row['album_name'], + 'albumArtist': row['album_artist'], + 'coverUrl': row['cover_url'], + 'filePath': row['file_path'], + 'service': row['service'], + 'downloadedAt': row['downloaded_at'], + 'isrc': row['isrc'], + 'spotifyId': row['spotify_id'], + 'trackNumber': row['track_number'], + 'discNumber': row['disc_number'], + 'duration': row['duration'], + 'releaseDate': row['release_date'], + 'quality': row['quality'], + 'bitDepth': row['bit_depth'], + 'sampleRate': row['sample_rate'], + 'genre': row['genre'], + 'label': row['label'], + 'copyright': row['copyright'], + }; + } + + // ==================== CRUD Operations ==================== + + /// Insert or update a history item + Future upsert(Map json) async { + final db = await database; + await db.insert( + 'history', + _jsonToDbRow(json), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + /// Get all history items ordered by download date (newest first) + Future>> getAll({int? limit, int? offset}) async { + final db = await database; + final rows = await db.query( + 'history', + orderBy: 'downloaded_at DESC', + limit: limit, + offset: offset, + ); + return rows.map(_dbRowToJson).toList(); + } + + /// Get item by ID + Future?> getById(String id) async { + final db = await database; + final rows = await db.query( + 'history', + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + if (rows.isEmpty) return null; + return _dbRowToJson(rows.first); + } + + /// Get item by Spotify ID - O(1) with index + Future?> getBySpotifyId(String spotifyId) async { + final db = await database; + final rows = await db.query( + 'history', + where: 'spotify_id = ?', + whereArgs: [spotifyId], + limit: 1, + ); + if (rows.isEmpty) return null; + return _dbRowToJson(rows.first); + } + + /// Get item by ISRC - O(1) with index + Future?> getByIsrc(String isrc) async { + final db = await database; + final rows = await db.query( + 'history', + where: 'isrc = ?', + whereArgs: [isrc], + limit: 1, + ); + if (rows.isEmpty) return null; + return _dbRowToJson(rows.first); + } + + /// Check if spotify_id exists - O(1) with index + Future existsBySpotifyId(String spotifyId) async { + final db = await database; + final result = await db.rawQuery( + 'SELECT 1 FROM history WHERE spotify_id = ? LIMIT 1', + [spotifyId], + ); + return result.isNotEmpty; + } + + /// Get all spotify_ids as Set for fast in-memory lookup + Future> getAllSpotifyIds() async { + final db = await database; + final rows = await db.rawQuery( + 'SELECT spotify_id FROM history WHERE spotify_id IS NOT NULL AND spotify_id != ""' + ); + return rows.map((r) => r['spotify_id'] as String).toSet(); + } + + /// Delete by ID + Future deleteById(String id) async { + final db = await database; + await db.delete('history', where: 'id = ?', whereArgs: [id]); + } + + /// Delete by Spotify ID + Future deleteBySpotifyId(String spotifyId) async { + final db = await database; + await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]); + } + + /// Clear all history + Future clearAll() async { + final db = await database; + await db.delete('history'); + _log.i('Cleared all history'); + } + + /// Get total count + Future getCount() async { + final db = await database; + final result = await db.rawQuery('SELECT COUNT(*) as count FROM history'); + return Sqflite.firstIntValue(result) ?? 0; + } + + /// Find existing item by spotify_id or isrc (for deduplication) + Future?> findExisting({ + String? spotifyId, + String? isrc, + }) async { + if (spotifyId != null && spotifyId.isNotEmpty) { + final bySpotify = await getBySpotifyId(spotifyId); + if (bySpotify != null) return bySpotify; + + // Check for deezer: prefix matching + if (spotifyId.startsWith('deezer:')) { + final deezerId = spotifyId.substring(7); + final db = await database; + final rows = await db.query( + 'history', + where: 'spotify_id LIKE ?', + whereArgs: ['deezer:$deezerId'], + limit: 1, + ); + if (rows.isNotEmpty) return _dbRowToJson(rows.first); + } + } + + if (isrc != null && isrc.isNotEmpty) { + return await getByIsrc(isrc); + } + + return null; + } + + /// Close database + Future close() async { + final db = await database; + await db.close(); + _database = null; + } +} diff --git a/lib/services/palette_service.dart b/lib/services/palette_service.dart new file mode 100644 index 00000000..41efeee1 --- /dev/null +++ b/lib/services/palette_service.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:palette_generator/palette_generator.dart'; + +/// Service for extracting dominant colors from images +/// Uses caching to avoid re-extraction and small image size for speed +class PaletteService { + static final PaletteService instance = PaletteService._(); + PaletteService._(); + + /// Cache for already computed colors + final Map _colorCache = {}; + + /// Extract dominant color from a network image URL + /// Uses small image size and limited colors for speed + Future extractDominantColor(String? imageUrl) async { + if (imageUrl == null || imageUrl.isEmpty) return null; + if (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://')) { + return null; + } + + // Check cache first + if (_colorCache.containsKey(imageUrl)) { + return _colorCache[imageUrl]; + } + + try { + final paletteGenerator = await PaletteGenerator.fromImageProvider( + CachedNetworkImageProvider(imageUrl), + size: const Size(64, 64), // Small size for speed + maximumColorCount: 8, // Fewer colors for speed + ); + + final color = paletteGenerator.dominantColor?.color ?? + paletteGenerator.vibrantColor?.color ?? + paletteGenerator.mutedColor?.color; + + if (color != null) { + _colorCache[imageUrl] = color; + } + + return color; + } catch (e) { + debugPrint('PaletteService error: $e'); + return null; + } + } + + /// Clear the color cache + void clearCache() { + _colorCache.clear(); + } + + /// Get cached color without computing + Color? getCached(String? imageUrl) { + if (imageUrl == null) return null; + return _colorCache[imageUrl]; + } +} diff --git a/pubspec.lock b/pubspec.lock index 5233bceb..13c08c36 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1027,7 +1027,7 @@ packages: source: hosted version: "1.10.1" sqflite: - dependency: transitive + dependency: "direct main" description: name: sqflite sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 diff --git a/pubspec.yaml b/pubspec.yaml index ea2ae88e..5c2fddef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: shared_preferences: ^2.5.3 path_provider: ^2.1.5 path: ^1.9.0 + sqflite: ^2.4.1 # HTTP & Network http: ^1.6.0 diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index 2ace0a56..4e806acf 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -26,6 +26,7 @@ dependencies: shared_preferences: ^2.5.3 path_provider: ^2.1.5 path: ^1.9.0 + sqflite: ^2.4.1 # HTTP & Network http: ^1.6.0 From ac3d51e2cd03bac72b381cdf002ee1359044f37a Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 21 Jan 2026 10:26:35 +0700 Subject: [PATCH 08/19] feat: add discography download with album selection support - Download entire artist discography, albums only, or singles only - Album selection mode with multi-select and batch download - Progress dialog while fetching tracks from albums - Skip already downloaded tracks (checks history) - Works with Spotify, Deezer, and Extensions - Add 18 localization strings for discography feature --- lib/l10n/app_localizations.dart | 102 +++++ lib/l10n/app_localizations_de.dart | 66 +++ lib/l10n/app_localizations_en.dart | 66 +++ lib/l10n/app_localizations_es.dart | 66 +++ lib/l10n/app_localizations_fr.dart | 66 +++ lib/l10n/app_localizations_hi.dart | 66 +++ lib/l10n/app_localizations_id.dart | 66 +++ lib/l10n/app_localizations_ja.dart | 66 +++ lib/l10n/app_localizations_ko.dart | 66 +++ lib/l10n/app_localizations_nl.dart | 66 +++ lib/l10n/app_localizations_pt.dart | 66 +++ lib/l10n/app_localizations_ru.dart | 66 +++ lib/l10n/app_localizations_tr.dart | 66 +++ lib/l10n/app_localizations_zh.dart | 66 +++ lib/l10n/arb/app_en.arb | 77 +++- lib/l10n/arb/app_id.arb | 20 +- lib/screens/artist_screen.dart | 680 ++++++++++++++++++++++++++++- lib/services/palette_service.dart | 5 +- 18 files changed, 1730 insertions(+), 12 deletions(-) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e2c92ab4..630a53e5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3789,6 +3789,108 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Error: {message}'** String errorGeneric(String message); + + /// Button - download artist discography + /// + /// In en, this message translates to: + /// **'Download Discography'** + String get discographyDownload; + + /// Option - download entire discography + /// + /// In en, this message translates to: + /// **'Download All'** + String get discographyDownloadAll; + + /// Subtitle showing total tracks and albums + /// + /// In en, this message translates to: + /// **'{count} tracks from {albumCount} releases'** + String discographyDownloadAllSubtitle(int count, int albumCount); + + /// Option - download only albums + /// + /// In en, this message translates to: + /// **'Albums Only'** + String get discographyAlbumsOnly; + + /// Subtitle showing album tracks count + /// + /// In en, this message translates to: + /// **'{count} tracks from {albumCount} albums'** + String discographyAlbumsOnlySubtitle(int count, int albumCount); + + /// Option - download only singles + /// + /// In en, this message translates to: + /// **'Singles & EPs Only'** + String get discographySinglesOnly; + + /// Subtitle showing singles tracks count + /// + /// In en, this message translates to: + /// **'{count} tracks from {albumCount} singles'** + String discographySinglesOnlySubtitle(int count, int albumCount); + + /// Option - manually select albums to download + /// + /// In en, this message translates to: + /// **'Select Albums...'** + String get discographySelectAlbums; + + /// Subtitle for select albums option + /// + /// In en, this message translates to: + /// **'Choose specific albums or singles'** + String get discographySelectAlbumsSubtitle; + + /// Progress - fetching album tracks + /// + /// In en, this message translates to: + /// **'Fetching tracks...'** + String get discographyFetchingTracks; + + /// Progress - fetching specific album + /// + /// In en, this message translates to: + /// **'Fetching {current} of {total}...'** + String discographyFetchingAlbum(int current, int total); + + /// Selection count badge + /// + /// In en, this message translates to: + /// **'{count} selected'** + String discographySelectedCount(int count); + + /// Button - download selected albums + /// + /// In en, this message translates to: + /// **'Download Selected'** + String get discographyDownloadSelected; + + /// Snackbar - tracks added from discography + /// + /// In en, this message translates to: + /// **'Added {count} tracks to queue'** + String discographyAddedToQueue(int count); + + /// Snackbar - with skipped tracks count + /// + /// In en, this message translates to: + /// **'{added} added, {skipped} already downloaded'** + String discographySkippedDownloaded(int added, int skipped); + + /// Error - no albums found for artist + /// + /// In en, this message translates to: + /// **'No albums available'** + String get discographyNoAlbums; + + /// Error - some albums failed to load + /// + /// In en, this message translates to: + /// **'Failed to fetch some albums'** + String get discographyFailedToFetch; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index daa68606..705153fc 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2095,4 +2095,70 @@ class AppLocalizationsDe extends AppLocalizations { String errorGeneric(String message) { return 'Error: $message'; } + + @override + String get discographyDownload => 'Download Discography'; + + @override + String get discographyDownloadAll => 'Download All'; + + @override + String discographyDownloadAllSubtitle(int count, int albumCount) { + return '$count tracks from $albumCount releases'; + } + + @override + String get discographyAlbumsOnly => 'Albums Only'; + + @override + String discographyAlbumsOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount albums'; + } + + @override + String get discographySinglesOnly => 'Singles & EPs Only'; + + @override + String discographySinglesOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount singles'; + } + + @override + String get discographySelectAlbums => 'Select Albums...'; + + @override + String get discographySelectAlbumsSubtitle => + 'Choose specific albums or singles'; + + @override + String get discographyFetchingTracks => 'Fetching tracks...'; + + @override + String discographyFetchingAlbum(int current, int total) { + return 'Fetching $current of $total...'; + } + + @override + String discographySelectedCount(int count) { + return '$count selected'; + } + + @override + String get discographyDownloadSelected => 'Download Selected'; + + @override + String discographyAddedToQueue(int count) { + return 'Added $count tracks to queue'; + } + + @override + String discographySkippedDownloaded(int added, int skipped) { + return '$added added, $skipped already downloaded'; + } + + @override + String get discographyNoAlbums => 'No albums available'; + + @override + String get discographyFailedToFetch => 'Failed to fetch some albums'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 03986a38..3a06bdd2 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2082,4 +2082,70 @@ class AppLocalizationsEn extends AppLocalizations { String errorGeneric(String message) { return 'Error: $message'; } + + @override + String get discographyDownload => 'Download Discography'; + + @override + String get discographyDownloadAll => 'Download All'; + + @override + String discographyDownloadAllSubtitle(int count, int albumCount) { + return '$count tracks from $albumCount releases'; + } + + @override + String get discographyAlbumsOnly => 'Albums Only'; + + @override + String discographyAlbumsOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount albums'; + } + + @override + String get discographySinglesOnly => 'Singles & EPs Only'; + + @override + String discographySinglesOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount singles'; + } + + @override + String get discographySelectAlbums => 'Select Albums...'; + + @override + String get discographySelectAlbumsSubtitle => + 'Choose specific albums or singles'; + + @override + String get discographyFetchingTracks => 'Fetching tracks...'; + + @override + String discographyFetchingAlbum(int current, int total) { + return 'Fetching $current of $total...'; + } + + @override + String discographySelectedCount(int count) { + return '$count selected'; + } + + @override + String get discographyDownloadSelected => 'Download Selected'; + + @override + String discographyAddedToQueue(int count) { + return 'Added $count tracks to queue'; + } + + @override + String discographySkippedDownloaded(int added, int skipped) { + return '$added added, $skipped already downloaded'; + } + + @override + String get discographyNoAlbums => 'No albums available'; + + @override + String get discographyFailedToFetch => 'Failed to fetch some albums'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index ea726e59..cf725903 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2082,6 +2082,72 @@ class AppLocalizationsEs extends AppLocalizations { String errorGeneric(String message) { return 'Error: $message'; } + + @override + String get discographyDownload => 'Download Discography'; + + @override + String get discographyDownloadAll => 'Download All'; + + @override + String discographyDownloadAllSubtitle(int count, int albumCount) { + return '$count tracks from $albumCount releases'; + } + + @override + String get discographyAlbumsOnly => 'Albums Only'; + + @override + String discographyAlbumsOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount albums'; + } + + @override + String get discographySinglesOnly => 'Singles & EPs Only'; + + @override + String discographySinglesOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount singles'; + } + + @override + String get discographySelectAlbums => 'Select Albums...'; + + @override + String get discographySelectAlbumsSubtitle => + 'Choose specific albums or singles'; + + @override + String get discographyFetchingTracks => 'Fetching tracks...'; + + @override + String discographyFetchingAlbum(int current, int total) { + return 'Fetching $current of $total...'; + } + + @override + String discographySelectedCount(int count) { + return '$count selected'; + } + + @override + String get discographyDownloadSelected => 'Download Selected'; + + @override + String discographyAddedToQueue(int count) { + return 'Added $count tracks to queue'; + } + + @override + String discographySkippedDownloaded(int added, int skipped) { + return '$added added, $skipped already downloaded'; + } + + @override + String get discographyNoAlbums => 'No albums available'; + + @override + String get discographyFailedToFetch => 'Failed to fetch some albums'; } /// The translations for Spanish Castilian, as used in Spain (`es_ES`). diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 16ba8c0a..69816a74 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2082,4 +2082,70 @@ class AppLocalizationsFr extends AppLocalizations { String errorGeneric(String message) { return 'Error: $message'; } + + @override + String get discographyDownload => 'Download Discography'; + + @override + String get discographyDownloadAll => 'Download All'; + + @override + String discographyDownloadAllSubtitle(int count, int albumCount) { + return '$count tracks from $albumCount releases'; + } + + @override + String get discographyAlbumsOnly => 'Albums Only'; + + @override + String discographyAlbumsOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount albums'; + } + + @override + String get discographySinglesOnly => 'Singles & EPs Only'; + + @override + String discographySinglesOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount singles'; + } + + @override + String get discographySelectAlbums => 'Select Albums...'; + + @override + String get discographySelectAlbumsSubtitle => + 'Choose specific albums or singles'; + + @override + String get discographyFetchingTracks => 'Fetching tracks...'; + + @override + String discographyFetchingAlbum(int current, int total) { + return 'Fetching $current of $total...'; + } + + @override + String discographySelectedCount(int count) { + return '$count selected'; + } + + @override + String get discographyDownloadSelected => 'Download Selected'; + + @override + String discographyAddedToQueue(int count) { + return 'Added $count tracks to queue'; + } + + @override + String discographySkippedDownloaded(int added, int skipped) { + return '$added added, $skipped already downloaded'; + } + + @override + String get discographyNoAlbums => 'No albums available'; + + @override + String get discographyFailedToFetch => 'Failed to fetch some albums'; } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index dc99086e..cfdf09bc 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2082,4 +2082,70 @@ class AppLocalizationsHi extends AppLocalizations { String errorGeneric(String message) { return 'Error: $message'; } + + @override + String get discographyDownload => 'Download Discography'; + + @override + String get discographyDownloadAll => 'Download All'; + + @override + String discographyDownloadAllSubtitle(int count, int albumCount) { + return '$count tracks from $albumCount releases'; + } + + @override + String get discographyAlbumsOnly => 'Albums Only'; + + @override + String discographyAlbumsOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount albums'; + } + + @override + String get discographySinglesOnly => 'Singles & EPs Only'; + + @override + String discographySinglesOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount singles'; + } + + @override + String get discographySelectAlbums => 'Select Albums...'; + + @override + String get discographySelectAlbumsSubtitle => + 'Choose specific albums or singles'; + + @override + String get discographyFetchingTracks => 'Fetching tracks...'; + + @override + String discographyFetchingAlbum(int current, int total) { + return 'Fetching $current of $total...'; + } + + @override + String discographySelectedCount(int count) { + return '$count selected'; + } + + @override + String get discographyDownloadSelected => 'Download Selected'; + + @override + String discographyAddedToQueue(int count) { + return 'Added $count tracks to queue'; + } + + @override + String discographySkippedDownloaded(int added, int skipped) { + return '$added added, $skipped already downloaded'; + } + + @override + String get discographyNoAlbums => 'No albums available'; + + @override + String get discographyFailedToFetch => 'Failed to fetch some albums'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index eaeb3a27..155aa52b 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2095,4 +2095,70 @@ class AppLocalizationsId extends AppLocalizations { String errorGeneric(String message) { return 'Error: $message'; } + + @override + String get discographyDownload => 'Unduh Diskografi'; + + @override + String get discographyDownloadAll => 'Unduh Semua'; + + @override + String discographyDownloadAllSubtitle(int count, int albumCount) { + return '$count lagu dari $albumCount rilis'; + } + + @override + String get discographyAlbumsOnly => 'Album Saja'; + + @override + String discographyAlbumsOnlySubtitle(int count, int albumCount) { + return '$count lagu dari $albumCount album'; + } + + @override + String get discographySinglesOnly => 'Single & EP Saja'; + + @override + String discographySinglesOnlySubtitle(int count, int albumCount) { + return '$count lagu dari $albumCount single'; + } + + @override + String get discographySelectAlbums => 'Pilih Album...'; + + @override + String get discographySelectAlbumsSubtitle => + 'Pilih album atau single tertentu'; + + @override + String get discographyFetchingTracks => 'Mengambil lagu...'; + + @override + String discographyFetchingAlbum(int current, int total) { + return 'Mengambil $current dari $total...'; + } + + @override + String discographySelectedCount(int count) { + return '$count dipilih'; + } + + @override + String get discographyDownloadSelected => 'Unduh yang Dipilih'; + + @override + String discographyAddedToQueue(int count) { + return 'Menambahkan $count lagu ke antrian'; + } + + @override + String discographySkippedDownloaded(int added, int skipped) { + return '$added ditambahkan, $skipped sudah diunduh'; + } + + @override + String get discographyNoAlbums => 'Tidak ada album tersedia'; + + @override + String get discographyFailedToFetch => 'Gagal mengambil beberapa album'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 8e945c25..e7822eb9 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2082,4 +2082,70 @@ class AppLocalizationsJa extends AppLocalizations { String errorGeneric(String message) { return 'Error: $message'; } + + @override + String get discographyDownload => 'Download Discography'; + + @override + String get discographyDownloadAll => 'Download All'; + + @override + String discographyDownloadAllSubtitle(int count, int albumCount) { + return '$count tracks from $albumCount releases'; + } + + @override + String get discographyAlbumsOnly => 'Albums Only'; + + @override + String discographyAlbumsOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount albums'; + } + + @override + String get discographySinglesOnly => 'Singles & EPs Only'; + + @override + String discographySinglesOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount singles'; + } + + @override + String get discographySelectAlbums => 'Select Albums...'; + + @override + String get discographySelectAlbumsSubtitle => + 'Choose specific albums or singles'; + + @override + String get discographyFetchingTracks => 'Fetching tracks...'; + + @override + String discographyFetchingAlbum(int current, int total) { + return 'Fetching $current of $total...'; + } + + @override + String discographySelectedCount(int count) { + return '$count selected'; + } + + @override + String get discographyDownloadSelected => 'Download Selected'; + + @override + String discographyAddedToQueue(int count) { + return 'Added $count tracks to queue'; + } + + @override + String discographySkippedDownloaded(int added, int skipped) { + return '$added added, $skipped already downloaded'; + } + + @override + String get discographyNoAlbums => 'No albums available'; + + @override + String get discographyFailedToFetch => 'Failed to fetch some albums'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index d2dd57a8..68d7a880 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2082,4 +2082,70 @@ class AppLocalizationsKo extends AppLocalizations { String errorGeneric(String message) { return 'Error: $message'; } + + @override + String get discographyDownload => 'Download Discography'; + + @override + String get discographyDownloadAll => 'Download All'; + + @override + String discographyDownloadAllSubtitle(int count, int albumCount) { + return '$count tracks from $albumCount releases'; + } + + @override + String get discographyAlbumsOnly => 'Albums Only'; + + @override + String discographyAlbumsOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount albums'; + } + + @override + String get discographySinglesOnly => 'Singles & EPs Only'; + + @override + String discographySinglesOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount singles'; + } + + @override + String get discographySelectAlbums => 'Select Albums...'; + + @override + String get discographySelectAlbumsSubtitle => + 'Choose specific albums or singles'; + + @override + String get discographyFetchingTracks => 'Fetching tracks...'; + + @override + String discographyFetchingAlbum(int current, int total) { + return 'Fetching $current of $total...'; + } + + @override + String discographySelectedCount(int count) { + return '$count selected'; + } + + @override + String get discographyDownloadSelected => 'Download Selected'; + + @override + String discographyAddedToQueue(int count) { + return 'Added $count tracks to queue'; + } + + @override + String discographySkippedDownloaded(int added, int skipped) { + return '$added added, $skipped already downloaded'; + } + + @override + String get discographyNoAlbums => 'No albums available'; + + @override + String get discographyFailedToFetch => 'Failed to fetch some albums'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 64409a58..4cb2dec6 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2082,4 +2082,70 @@ class AppLocalizationsNl extends AppLocalizations { String errorGeneric(String message) { return 'Error: $message'; } + + @override + String get discographyDownload => 'Download Discography'; + + @override + String get discographyDownloadAll => 'Download All'; + + @override + String discographyDownloadAllSubtitle(int count, int albumCount) { + return '$count tracks from $albumCount releases'; + } + + @override + String get discographyAlbumsOnly => 'Albums Only'; + + @override + String discographyAlbumsOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount albums'; + } + + @override + String get discographySinglesOnly => 'Singles & EPs Only'; + + @override + String discographySinglesOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount singles'; + } + + @override + String get discographySelectAlbums => 'Select Albums...'; + + @override + String get discographySelectAlbumsSubtitle => + 'Choose specific albums or singles'; + + @override + String get discographyFetchingTracks => 'Fetching tracks...'; + + @override + String discographyFetchingAlbum(int current, int total) { + return 'Fetching $current of $total...'; + } + + @override + String discographySelectedCount(int count) { + return '$count selected'; + } + + @override + String get discographyDownloadSelected => 'Download Selected'; + + @override + String discographyAddedToQueue(int count) { + return 'Added $count tracks to queue'; + } + + @override + String discographySkippedDownloaded(int added, int skipped) { + return '$added added, $skipped already downloaded'; + } + + @override + String get discographyNoAlbums => 'No albums available'; + + @override + String get discographyFailedToFetch => 'Failed to fetch some albums'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 49bbccd1..dd0f80e1 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2082,6 +2082,72 @@ class AppLocalizationsPt extends AppLocalizations { String errorGeneric(String message) { return 'Error: $message'; } + + @override + String get discographyDownload => 'Download Discography'; + + @override + String get discographyDownloadAll => 'Download All'; + + @override + String discographyDownloadAllSubtitle(int count, int albumCount) { + return '$count tracks from $albumCount releases'; + } + + @override + String get discographyAlbumsOnly => 'Albums Only'; + + @override + String discographyAlbumsOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount albums'; + } + + @override + String get discographySinglesOnly => 'Singles & EPs Only'; + + @override + String discographySinglesOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount singles'; + } + + @override + String get discographySelectAlbums => 'Select Albums...'; + + @override + String get discographySelectAlbumsSubtitle => + 'Choose specific albums or singles'; + + @override + String get discographyFetchingTracks => 'Fetching tracks...'; + + @override + String discographyFetchingAlbum(int current, int total) { + return 'Fetching $current of $total...'; + } + + @override + String discographySelectedCount(int count) { + return '$count selected'; + } + + @override + String get discographyDownloadSelected => 'Download Selected'; + + @override + String discographyAddedToQueue(int count) { + return 'Added $count tracks to queue'; + } + + @override + String discographySkippedDownloaded(int added, int skipped) { + return '$added added, $skipped already downloaded'; + } + + @override + String get discographyNoAlbums => 'No albums available'; + + @override + String get discographyFailedToFetch => 'Failed to fetch some albums'; } /// The translations for Portuguese, as used in Portugal (`pt_PT`). diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 22caac4c..04a05e2d 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2127,4 +2127,70 @@ class AppLocalizationsRu extends AppLocalizations { String errorGeneric(String message) { return 'Ошибка: $message'; } + + @override + String get discographyDownload => 'Download Discography'; + + @override + String get discographyDownloadAll => 'Download All'; + + @override + String discographyDownloadAllSubtitle(int count, int albumCount) { + return '$count tracks from $albumCount releases'; + } + + @override + String get discographyAlbumsOnly => 'Albums Only'; + + @override + String discographyAlbumsOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount albums'; + } + + @override + String get discographySinglesOnly => 'Singles & EPs Only'; + + @override + String discographySinglesOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount singles'; + } + + @override + String get discographySelectAlbums => 'Select Albums...'; + + @override + String get discographySelectAlbumsSubtitle => + 'Choose specific albums or singles'; + + @override + String get discographyFetchingTracks => 'Fetching tracks...'; + + @override + String discographyFetchingAlbum(int current, int total) { + return 'Fetching $current of $total...'; + } + + @override + String discographySelectedCount(int count) { + return '$count selected'; + } + + @override + String get discographyDownloadSelected => 'Download Selected'; + + @override + String discographyAddedToQueue(int count) { + return 'Added $count tracks to queue'; + } + + @override + String discographySkippedDownloaded(int added, int skipped) { + return '$added added, $skipped already downloaded'; + } + + @override + String get discographyNoAlbums => 'No albums available'; + + @override + String get discographyFailedToFetch => 'Failed to fetch some albums'; } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index e94fd348..9b150fbf 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2082,4 +2082,70 @@ class AppLocalizationsTr extends AppLocalizations { String errorGeneric(String message) { return 'Error: $message'; } + + @override + String get discographyDownload => 'Download Discography'; + + @override + String get discographyDownloadAll => 'Download All'; + + @override + String discographyDownloadAllSubtitle(int count, int albumCount) { + return '$count tracks from $albumCount releases'; + } + + @override + String get discographyAlbumsOnly => 'Albums Only'; + + @override + String discographyAlbumsOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount albums'; + } + + @override + String get discographySinglesOnly => 'Singles & EPs Only'; + + @override + String discographySinglesOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount singles'; + } + + @override + String get discographySelectAlbums => 'Select Albums...'; + + @override + String get discographySelectAlbumsSubtitle => + 'Choose specific albums or singles'; + + @override + String get discographyFetchingTracks => 'Fetching tracks...'; + + @override + String discographyFetchingAlbum(int current, int total) { + return 'Fetching $current of $total...'; + } + + @override + String discographySelectedCount(int count) { + return '$count selected'; + } + + @override + String get discographyDownloadSelected => 'Download Selected'; + + @override + String discographyAddedToQueue(int count) { + return 'Added $count tracks to queue'; + } + + @override + String discographySkippedDownloaded(int added, int skipped) { + return '$added added, $skipped already downloaded'; + } + + @override + String get discographyNoAlbums => 'No albums available'; + + @override + String get discographyFailedToFetch => 'Failed to fetch some albums'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 5c41a232..f3e36581 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2082,6 +2082,72 @@ class AppLocalizationsZh extends AppLocalizations { String errorGeneric(String message) { return 'Error: $message'; } + + @override + String get discographyDownload => 'Download Discography'; + + @override + String get discographyDownloadAll => 'Download All'; + + @override + String discographyDownloadAllSubtitle(int count, int albumCount) { + return '$count tracks from $albumCount releases'; + } + + @override + String get discographyAlbumsOnly => 'Albums Only'; + + @override + String discographyAlbumsOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount albums'; + } + + @override + String get discographySinglesOnly => 'Singles & EPs Only'; + + @override + String discographySinglesOnlySubtitle(int count, int albumCount) { + return '$count tracks from $albumCount singles'; + } + + @override + String get discographySelectAlbums => 'Select Albums...'; + + @override + String get discographySelectAlbumsSubtitle => + 'Choose specific albums or singles'; + + @override + String get discographyFetchingTracks => 'Fetching tracks...'; + + @override + String discographyFetchingAlbum(int current, int total) { + return 'Fetching $current of $total...'; + } + + @override + String discographySelectedCount(int count) { + return '$count selected'; + } + + @override + String get discographyDownloadSelected => 'Download Selected'; + + @override + String discographyAddedToQueue(int count) { + return 'Added $count tracks to queue'; + } + + @override + String discographySkippedDownloaded(int added, int skipped) { + return '$added added, $skipped already downloaded'; + } + + @override + String get discographyNoAlbums => 'No albums available'; + + @override + String get discographyFailedToFetch => 'Failed to fetch some albums'; } /// The translations for Chinese, as used in China (`zh_CN`). diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 4b3388cb..1d467f08 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1549,5 +1549,80 @@ "placeholders": { "message": {"type": "String", "description": "Error message"} } - } + }, + + "discographyDownload": "Download Discography", + "@discographyDownload": {"description": "Button - download artist discography"}, + "discographyDownloadAll": "Download All", + "@discographyDownloadAll": {"description": "Option - download entire discography"}, + "discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases", + "@discographyDownloadAllSubtitle": { + "description": "Subtitle showing total tracks and albums", + "placeholders": { + "count": {"type": "int"}, + "albumCount": {"type": "int"} + } + }, + "discographyAlbumsOnly": "Albums Only", + "@discographyAlbumsOnly": {"description": "Option - download only albums"}, + "discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums", + "@discographyAlbumsOnlySubtitle": { + "description": "Subtitle showing album tracks count", + "placeholders": { + "count": {"type": "int"}, + "albumCount": {"type": "int"} + } + }, + "discographySinglesOnly": "Singles & EPs Only", + "@discographySinglesOnly": {"description": "Option - download only singles"}, + "discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles", + "@discographySinglesOnlySubtitle": { + "description": "Subtitle showing singles tracks count", + "placeholders": { + "count": {"type": "int"}, + "albumCount": {"type": "int"} + } + }, + "discographySelectAlbums": "Select Albums...", + "@discographySelectAlbums": {"description": "Option - manually select albums to download"}, + "discographySelectAlbumsSubtitle": "Choose specific albums or singles", + "@discographySelectAlbumsSubtitle": {"description": "Subtitle for select albums option"}, + "discographyFetchingTracks": "Fetching tracks...", + "@discographyFetchingTracks": {"description": "Progress - fetching album tracks"}, + "discographyFetchingAlbum": "Fetching {current} of {total}...", + "@discographyFetchingAlbum": { + "description": "Progress - fetching specific album", + "placeholders": { + "current": {"type": "int"}, + "total": {"type": "int"} + } + }, + "discographySelectedCount": "{count} selected", + "@discographySelectedCount": { + "description": "Selection count badge", + "placeholders": { + "count": {"type": "int"} + } + }, + "discographyDownloadSelected": "Download Selected", + "@discographyDownloadSelected": {"description": "Button - download selected albums"}, + "discographyAddedToQueue": "Added {count} tracks to queue", + "@discographyAddedToQueue": { + "description": "Snackbar - tracks added from discography", + "placeholders": { + "count": {"type": "int"} + } + }, + "discographySkippedDownloaded": "{added} added, {skipped} already downloaded", + "@discographySkippedDownloaded": { + "description": "Snackbar - with skipped tracks count", + "placeholders": { + "added": {"type": "int"}, + "skipped": {"type": "int"} + } + }, + "discographyNoAlbums": "No albums available", + "@discographyNoAlbums": {"description": "Error - no albums found for artist"}, + "discographyFailedToFetch": "Failed to fetch some albums", + "@discographyFailedToFetch": {"description": "Error - some albums failed to load"} } diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 5c97f0de..da495241 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -683,5 +683,23 @@ "recentTypePlaylist": "Playlist", "recentPlaylistInfo": "Playlist: {name}", - "errorGeneric": "Error: {message}" + "errorGeneric": "Error: {message}", + + "discographyDownload": "Unduh Diskografi", + "discographyDownloadAll": "Unduh Semua", + "discographyDownloadAllSubtitle": "{count} lagu dari {albumCount} rilis", + "discographyAlbumsOnly": "Album Saja", + "discographyAlbumsOnlySubtitle": "{count} lagu dari {albumCount} album", + "discographySinglesOnly": "Single & EP Saja", + "discographySinglesOnlySubtitle": "{count} lagu dari {albumCount} single", + "discographySelectAlbums": "Pilih Album...", + "discographySelectAlbumsSubtitle": "Pilih album atau single tertentu", + "discographyFetchingTracks": "Mengambil lagu...", + "discographyFetchingAlbum": "Mengambil {current} dari {total}...", + "discographySelectedCount": "{count} dipilih", + "discographyDownloadSelected": "Unduh yang Dipilih", + "discographyAddedToQueue": "Menambahkan {count} lagu ke antrian", + "discographySkippedDownloaded": "{added} ditambahkan, {skipped} sudah diunduh", + "discographyNoAlbums": "Tidak ada album tersedia", + "discographyFailedToFetch": "Gagal mengambil beberapa album" } diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 5dda70bf..32e8d784 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:intl/intl.dart'; @@ -14,6 +15,7 @@ import 'package:spotiflac_android/providers/recent_access_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen; +import 'package:spotiflac_android/widgets/download_service_picker.dart'; /// Simple in-memory cache for artist data class _ArtistCache { @@ -100,6 +102,11 @@ class _ArtistScreenState extends ConsumerState { bool _showTitleInAppBar = false; final ScrollController _scrollController = ScrollController(); + // Selection mode state + bool _isSelectionMode = false; + final Set _selectedAlbumIds = {}; + bool _isFetchingDiscography = false; + @override void initState() { super.initState(); @@ -278,11 +285,22 @@ class _ArtistScreenState extends ConsumerState { final singles = albums.where((a) => a.albumType == 'single').toList(); final compilations = albums.where((a) => a.albumType == 'compilation').toList(); -return Scaffold( - body: CustomScrollView( + final hasDiscography = !_isLoadingDiscography && _error == null && albums.isNotEmpty; + +return PopScope( + canPop: !_isSelectionMode, + onPopInvokedWithResult: (didPop, result) { + if (!didPop && _isSelectionMode) { + _exitSelectionMode(); + } + }, + child: Scaffold( + body: Stack( + children: [ + CustomScrollView( controller: _scrollController, slivers: [ - _buildHeader(context, colorScheme), + _buildHeader(context, colorScheme, albums: albums, hasDiscography: hasDiscography), if (_isLoadingDiscography) const SliverToBoxAdapter(child: Padding( padding: EdgeInsets.all(32), @@ -303,13 +321,442 @@ return Scaffold( if (compilations.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistCompilations, compilations, colorScheme)), ], - const SliverToBoxAdapter(child: SizedBox(height: 32)), + // Add padding at bottom for selection bar + SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)), ], + ), + // Selection action bar + if (_isSelectionMode) + _buildSelectionBar(context, colorScheme, albums), + ], + ), ), ); } - Widget _buildHeader(BuildContext context, ColorScheme colorScheme) { + void _exitSelectionMode() { + HapticFeedback.lightImpact(); + setState(() { + _isSelectionMode = false; + _selectedAlbumIds.clear(); + }); + } + + void _enterSelectionMode(String albumId) { + HapticFeedback.mediumImpact(); + setState(() { + _isSelectionMode = true; + _selectedAlbumIds.add(albumId); + }); + } + + void _toggleAlbumSelection(String albumId) { + HapticFeedback.selectionClick(); + setState(() { + if (_selectedAlbumIds.contains(albumId)) { + _selectedAlbumIds.remove(albumId); + if (_selectedAlbumIds.isEmpty) { + _isSelectionMode = false; + } + } else { + _selectedAlbumIds.add(albumId); + } + }); + } + + void _selectAll(List albums) { + setState(() { + _selectedAlbumIds.addAll(albums.map((a) => a.id)); + }); + } + + void _deselectAll() { + setState(() { + _selectedAlbumIds.clear(); + }); + } + + Widget _buildSelectionBar(BuildContext context, ColorScheme colorScheme, List allAlbums) { + final allSelected = _selectedAlbumIds.length == allAlbums.length; + final selectedCount = _selectedAlbumIds.length; + final selectedAlbums = allAlbums.where((a) => _selectedAlbumIds.contains(a.id)).toList(); + final totalTracks = selectedAlbums.fold(0, (sum, a) => sum + a.totalTracks); + + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // Close button + IconButton( + onPressed: _exitSelectionMode, + icon: const Icon(Icons.close), + tooltip: context.l10n.dialogCancel, + ), + const SizedBox(width: 8), + // Selection info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.l10n.discographySelectedCount(selectedCount), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (selectedCount > 0) + Text( + context.l10n.tracksCount(totalTracks), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + // Select all / Deselect button + TextButton( + onPressed: allSelected ? _deselectAll : () => _selectAll(allAlbums), + child: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll), + ), + const SizedBox(width: 8), + // Download button + FilledButton.icon( + onPressed: selectedCount > 0 ? () => _downloadSelectedAlbums(context, selectedAlbums) : null, + icon: const Icon(Icons.download, size: 18), + label: Text(context.l10n.discographyDownloadSelected), + ), + ], + ), + ), + ), + ), + ); + } + + void _showDiscographyOptions(BuildContext context, ColorScheme colorScheme, List albums) { + final albumsOnly = albums.where((a) => a.albumType == 'album').toList(); + final singles = albums.where((a) => a.albumType == 'single').toList(); + + final totalTracks = albums.fold(0, (sum, a) => sum + a.totalTracks); + final albumTracks = albumsOnly.fold(0, (sum, a) => sum + a.totalTracks); + final singleTracks = singles.fold(0, (sum, a) => sum + a.totalTracks); + + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (context) => SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + // Title + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Row( + children: [ + Icon(Icons.download, color: colorScheme.primary), + const SizedBox(width: 12), + Text( + context.l10n.discographyDownload, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const Divider(height: 1), + // Options + if (albums.isNotEmpty) + _DiscographyOptionTile( + icon: Icons.library_music, + title: context.l10n.discographyDownloadAll, + subtitle: context.l10n.discographyDownloadAllSubtitle(totalTracks, albums.length), + onTap: () { + Navigator.pop(context); + _downloadAlbums(context, albums); + }, + ), + if (albumsOnly.isNotEmpty) + _DiscographyOptionTile( + icon: Icons.album, + title: context.l10n.discographyAlbumsOnly, + subtitle: context.l10n.discographyAlbumsOnlySubtitle(albumTracks, albumsOnly.length), + onTap: () { + Navigator.pop(context); + _downloadAlbums(context, albumsOnly); + }, + ), + if (singles.isNotEmpty) + _DiscographyOptionTile( + icon: Icons.music_note, + title: context.l10n.discographySinglesOnly, + subtitle: context.l10n.discographySinglesOnlySubtitle(singleTracks, singles.length), + onTap: () { + Navigator.pop(context); + _downloadAlbums(context, singles); + }, + ), + _DiscographyOptionTile( + icon: Icons.checklist, + title: context.l10n.discographySelectAlbums, + subtitle: context.l10n.discographySelectAlbumsSubtitle, + onTap: () { + Navigator.pop(context); + _enterSelectionMode(albums.first.id); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ); + } + + Future _downloadAlbums(BuildContext context, List albums) async { + final settings = ref.read(settingsProvider); + + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + context, + onSelect: (quality, service) { + _fetchAndQueueAlbums(context, albums, service, quality); + }, + ); + } else { + _fetchAndQueueAlbums(context, albums, settings.defaultService, null); + } + } + + Future _downloadSelectedAlbums(BuildContext context, List albums) async { + _exitSelectionMode(); + await _downloadAlbums(context, albums); + } + + Future _fetchAndQueueAlbums( + BuildContext context, + List albums, + String service, + String? qualityOverride, + ) async { + if (_isFetchingDiscography) return; + + setState(() => _isFetchingDiscography = true); + + // Show progress dialog + if (!context.mounted) return; + + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => _FetchingProgressDialog( + totalAlbums: albums.length, + onCancel: () { + setState(() => _isFetchingDiscography = false); + Navigator.pop(ctx); + }, + ), + ); + + final allTracks = []; + int fetchedCount = 0; + int failedCount = 0; + + // Fetch tracks from each album + for (final album in albums) { + if (!_isFetchingDiscography) break; // Cancelled + + try { + final tracks = await _fetchAlbumTracks(album); + allTracks.addAll(tracks); + } catch (e) { + failedCount++; + } + + fetchedCount++; + + // Update progress dialog + if (context.mounted) { + _FetchingProgressDialog.updateProgress(context, fetchedCount, albums.length); + } + } + + setState(() => _isFetchingDiscography = false); + + // Close progress dialog + if (context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } + + // Show warning if some albums failed + if (failedCount > 0 && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.discographyFailedToFetch)), + ); + } + + if (allTracks.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.discographyNoAlbums)), + ); + } + return; + } + + // Check which tracks are already downloaded + final historyState = ref.read(downloadHistoryProvider); + final tracksToQueue = []; + int skippedCount = 0; + + for (final track in allTracks) { + final isDownloaded = historyState.isDownloaded(track.id) || + (track.isrc != null && historyState.getByIsrc(track.isrc!) != null); + + if (!isDownloaded) { + tracksToQueue.add(track); + } else { + skippedCount++; + } + } + + if (tracksToQueue.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.discographySkippedDownloaded(0, skippedCount)), + ), + ); + } + return; + } + + // Add to queue + ref.read(downloadQueueProvider.notifier).addMultipleToQueue( + tracksToQueue, + service, + qualityOverride: qualityOverride, + ); + + // Show success message + if (context.mounted) { + final message = skippedCount > 0 + ? context.l10n.discographySkippedDownloaded(tracksToQueue.length, skippedCount) + : context.l10n.discographyAddedToQueue(tracksToQueue.length); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + action: SnackBarAction( + label: context.l10n.snackbarViewQueue, + onPressed: () { + // Navigate to queue tab (index 1) + // This will be handled by the navigation system + }, + ), + ), + ); + } + } + + Future> _fetchAlbumTracks(ArtistAlbum album) async { + if (album.providerId != null && album.providerId!.isNotEmpty) { + // Extension album + final result = await PlatformBridge.getAlbumWithExtension(album.providerId!, album.id); + if (result != null && result['tracks'] != null) { + final tracksList = result['tracks'] as List; + return tracksList.map((t) => _parseTrack(t as Map)).toList(); + } + } else if (album.id.startsWith('deezer:')) { + // Deezer album + final deezerId = album.id.replaceFirst('deezer:', ''); + final metadata = await PlatformBridge.getDeezerMetadata('album', deezerId); + if (metadata['tracks'] != null) { + final tracksList = metadata['tracks'] as List; + return tracksList.map((t) => _parseTrackFromDeezer(t as Map, album)).toList(); + } + } else { + // Spotify album + final url = 'https://open.spotify.com/album/${album.id}'; + final result = await PlatformBridge.handleURLWithExtension(url); + if (result != null && result['tracks'] != null) { + final tracksList = result['tracks'] as List; + return tracksList.map((t) => _parseTrack(t as Map)).toList(); + } + + // Fallback to direct Spotify metadata + final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); + if (metadata['tracks'] != null) { + final tracksList = metadata['tracks'] as List; + return tracksList.map((t) => _parseTrack(t as Map)).toList(); + } + } + return []; + } + + Track _parseTrackFromDeezer(Map data, ArtistAlbum album) { + int durationMs = 0; + final durationValue = data['duration']; + if (durationValue is int) { + durationMs = durationValue * 1000; // Deezer returns seconds + } else if (durationValue is double) { + durationMs = (durationValue * 1000).toInt(); + } + + return Track( + id: 'deezer:${data['id']}', + name: (data['title'] ?? data['name'] ?? '').toString(), + artistName: (data['artist']?['name'] ?? data['artist'] ?? widget.artistName).toString(), + albumName: album.name, + albumArtist: widget.artistName, + coverUrl: album.coverUrl, + isrc: data['isrc']?.toString(), + duration: (durationMs / 1000).round(), + trackNumber: data['track_position'] as int? ?? data['track_number'] as int?, + discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?, + releaseDate: album.releaseDate, + albumType: album.albumType, + ); + } + + Widget _buildHeader(BuildContext context, ColorScheme colorScheme, { + required List albums, + required bool hasDiscography, + }) { String? imageUrl = _headerImageUrl; if (imageUrl == null || imageUrl.isEmpty) { imageUrl = widget.headerImageUrl; @@ -330,7 +777,7 @@ return Scaffold( } return SliverAppBar( - expandedHeight: 380, + expandedHeight: hasDiscography ? 420 : 380, pinned: true, stretch: true, backgroundColor: colorScheme.surface, @@ -429,6 +876,26 @@ if (hasValidImage) ), ), ], + // Download Discography button + if (hasDiscography && !_isSelectionMode) ...[ + const SizedBox(height: 12), + SizedBox( + height: 40, + child: FilledButton.icon( + onPressed: () => _showDiscographyOptions(context, colorScheme, albums), + icon: const Icon(Icons.download, size: 18), + label: Text(context.l10n.discographyDownload), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + padding: const EdgeInsets.symmetric(horizontal: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + ), + ), + ], ], ), ), @@ -739,14 +1206,29 @@ if (hasValidImage) } Widget _buildAlbumCard(ArtistAlbum album, ColorScheme colorScheme) { + final isSelected = _selectedAlbumIds.contains(album.id); + return GestureDetector( - onTap: () => _navigateToAlbum(album), + onTap: () { + if (_isSelectionMode) { + _toggleAlbumSelection(album.id); + } else { + _navigateToAlbum(album); + } + }, + onLongPress: () { + if (!_isSelectionMode) { + _enterSelectionMode(album.id); + } + }, child: Container( width: 140, margin: const EdgeInsets.symmetric(horizontal: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Stack( + children: [ ClipRRect( borderRadius: BorderRadius.circular(8), child: album.coverUrl != null @@ -775,6 +1257,50 @@ if (hasValidImage) color: colorScheme.surfaceContainerHighest, child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40), ), + ), + // Selection overlay + if (_isSelectionMode) + Positioned.fill( + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.3) + : Colors.black.withValues(alpha: 0.1), + border: isSelected + ? Border.all(color: colorScheme.primary, width: 3) + : null, + ), + ), + ), + // Checkbox + if (_isSelectionMode) + Positioned( + top: 8, + right: 8, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 28, + height: 28, + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary + : colorScheme.surface.withValues(alpha: 0.9), + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), + ), + child: isSelected + ? Icon(Icons.check, color: colorScheme.onPrimary, size: 18) + : null, + ), + ), + ], ), const SizedBox(height: 8), Text( @@ -886,3 +1412,143 @@ if (hasValidImage) ); } } + +/// Option tile for discography download bottom sheet +class _DiscographyOptionTile extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + const _DiscographyOptionTile({ + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + leading: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 24), + ), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: Text( + subtitle, + style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12), + ), + trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant), + onTap: onTap, + ); + } +} + +/// Progress dialog shown while fetching album tracks +class _FetchingProgressDialog extends StatefulWidget { + final int totalAlbums; + final VoidCallback onCancel; + + const _FetchingProgressDialog({ + required this.totalAlbums, + required this.onCancel, + }); + + // Static method to update progress from outside + static void updateProgress(BuildContext context, int current, int total) { + final state = context.findAncestorStateOfType<_FetchingProgressDialogState>(); + state?._updateProgress(current, total); + } + + @override + State<_FetchingProgressDialog> createState() => _FetchingProgressDialogState(); +} + +class _FetchingProgressDialogState extends State<_FetchingProgressDialog> { + int _current = 0; + int _total = 0; + + @override + void initState() { + super.initState(); + _total = widget.totalAlbums; + } + + void _updateProgress(int current, int total) { + if (mounted) { + setState(() { + _current = current; + _total = total; + }); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final progress = _total > 0 ? _current / _total : 0.0; + + return AlertDialog( + backgroundColor: colorScheme.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + SizedBox( + width: 64, + height: 64, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator( + value: progress > 0 ? progress : null, + strokeWidth: 4, + backgroundColor: colorScheme.surfaceContainerHighest, + ), + Icon(Icons.library_music, color: colorScheme.primary, size: 24), + ], + ), + ), + const SizedBox(height: 20), + Text( + context.l10n.discographyFetchingTracks, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + context.l10n.discographyFetchingAlbum(_current, _total), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + // Progress bar + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progress > 0 ? progress : null, + backgroundColor: colorScheme.surfaceContainerHighest, + minHeight: 6, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: widget.onCancel, + child: Text(context.l10n.dialogCancel), + ), + ], + ); + } +} diff --git a/lib/services/palette_service.dart b/lib/services/palette_service.dart index 41efeee1..4c5ab61c 100644 --- a/lib/services/palette_service.dart +++ b/lib/services/palette_service.dart @@ -19,7 +19,6 @@ class PaletteService { return null; } - // Check cache first if (_colorCache.containsKey(imageUrl)) { return _colorCache[imageUrl]; } @@ -27,8 +26,8 @@ class PaletteService { try { final paletteGenerator = await PaletteGenerator.fromImageProvider( CachedNetworkImageProvider(imageUrl), - size: const Size(64, 64), // Small size for speed - maximumColorCount: 8, // Fewer colors for speed + size: const Size(64, 64), + maximumColorCount: 8, ); final color = paletteGenerator.dominantColor?.color ?? From 7844bd2f429c7e5058025ef3e8607a3f05568888 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 21 Jan 2026 10:28:17 +0700 Subject: [PATCH 09/19] docs: add discography download to changelog as highly requested feature --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3a7abc4..9fe42006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,17 @@ - Parsed from spotify-web and ytmusic home feed responses - Fixes track duration showing "0:00" in metadata screen after download from home feed +- **Discography Download** (Highly Requested): Download entire artist discography with album selection + - New "Download Discography" button in artist header + - Bottom sheet options: Download All, Albums Only, Singles & EPs Only, or Select Albums + - Album selection mode with multi-select checkboxes on album cards + - Long press on album card to enter selection mode + - Selection bar with Select All/Deselect All and Download Selected actions + - Progress dialog showing current/total albums while fetching tracks + - Automatically skips already downloaded tracks (checks history database) + - Works with Spotify, Deezer, and Extension-based artists + - Back button exits selection mode instead of navigating away + ### Fixed - **YT Music Greeting Time**: Fixed "Good night" showing in the morning @@ -75,6 +86,18 @@ - Added `duration_ms` parsing from subtitle runs in home feed items - Simplified greeting logic - no more manual UTC offset calculations +### Localization + +- Added 18 new strings for discography download feature: + - `discographyDownload`, `discographyDownloadAll`, `discographyDownloadAllSubtitle` + - `discographyAlbumsOnly`, `discographyAlbumsOnlySubtitle` + - `discographySinglesOnly`, `discographySinglesOnlySubtitle` + - `discographySelectAlbums`, `discographySelectAlbumsSubtitle` + - `discographyFetchingTracks`, `discographyFetchingAlbum` + - `discographySelectedCount`, `discographyDownloadSelected` + - `discographyAddedToQueue`, `discographySkippedDownloaded` + - `discographyNoAlbums`, `discographyFailedToFetch` + ### Technical - **Go Backend**: Added `getLocalTime` function to `RegisterGoBackendAPIs()` From f2f8ca4528c4fd4e6369bfd976c0c4d5a9a4f96f Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 21 Jan 2026 12:07:50 +0700 Subject: [PATCH 10/19] feat: artist navigation from album, UI improvements, concise changelog - Add tappable artist name in album screen to navigate to artist page - Show track number instead of cover image in album track list - Add release date badge next to track count on album screen - Modernize Download All buttons with rounded corners (borderRadius: 24) - Add downloaded indicator for recent items (primary colored subtitle) - Condense v3.2.0 changelog and add note about concise format - Fix withOpacity deprecation and unnecessary null assertion in home_tab - Go backend: add artist_id support for Spotify, Deezer, and extensions --- CHANGELOG.md | 1822 +---------------------------- go_backend/deezer.go | 1 + go_backend/exports.go | 1 + go_backend/extension_providers.go | 1 + go_backend/spotify.go | 9 + lib/l10n/arb/app_es_ES.arb | 20 +- lib/l10n/arb/app_pt_PT.arb | 12 +- lib/screens/album_screen.dart | 162 ++- lib/screens/home_tab.dart | 23 +- lib/screens/playlist_screen.dart | 9 +- 10 files changed, 244 insertions(+), 1816 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe42006..73750406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,137 +2,41 @@ ## [3.2.0] - 2026-01-22 +> **Note:** Starting from v3.2.0, changelogs will be concise. + +### Highlights + +- **Discography Download** (Highly Requested): Download entire artist discography with album selection mode +- **Home Feed / Explore**: Personalized sections from spotify-web and ytmusic extensions +- **SQLite History Database**: O(1) lookups, non-blocking writes + ### Added -- **Home Feed / Explore Feature**: Personalized home feed sections on the home screen - - Works with any extension that has `homeFeed` capability - - Displays sections like "Trending Songs", "Quick Picks", "New Releases", etc. - - Each item shows thumbnail, name, artist, and supports navigation to album/artist/playlist - - Prefers spotify-web if available, otherwise uses first available homeFeed extension +- Discography download with options: All, Albums Only, Singles Only, or Select Albums +- Artist navigation from album screen (tap artist name) +- Home feed sections with pull-to-refresh +- YT Music Quick Picks swipeable UI +- `gobackend.getLocalTime()` API for extensions +- Track duration in home feed items +- Release date badge in album info card -- **Extension Capabilities System**: Extensions can now declare capabilities in manifest - - New `capabilities` field in extension manifest (e.g., `{ "homeFeed": true, "browseCategories": true }`) - - `Extension` model now has `hasHomeFeed` and `hasBrowseCategories` getters - - Capabilities are parsed from Go backend and exposed to Flutter +### Improved -- **YT Music Quick Picks UI**: Special vertical swipeable format for YT Music track sections - - Detects YT Music track-only sections and renders them differently - - PageView with 5 tracks per page, swipeable left/right - - Animated page indicator dots (active = primary color, inactive = gray) - - Each item shows: 48x48 thumbnail, track name, artist, 3-dot menu button - -- **Pull-to-Refresh on Home Feed**: Swipe down to refresh explore sections - - Replaced refresh button next to greeting with pull-to-refresh gesture - - Only active when explore sections are displayed - - Cleaner UI with greeting text only (no refresh icon) - -- **`gobackend.getLocalTime()` API for Extensions**: New utility function to get accurate device local time - - Returns: `{ year, month, day, hour, minute, second, weekday, offsetMinutes, timezone, timestamp }` - - Uses Go's `time.Now()` for accurate device timezone detection - - Solves Goja JS engine's `getTimezoneOffset()` returning 0 issue - -- **SQLite Database for Download History**: Migrated from SharedPreferences to SQLite - - New `HistoryDatabase` service with proper schema and indexes - - O(1) lookups by `spotify_id` and `isrc` (was O(n) linear search) - - Non-blocking writes - UI stays responsive during saves - - Automatic one-time migration from SharedPreferences on first launch - - No storage size limits (was ~1MB with SharedPreferences) - - Database schema with indexes: `idx_spotify_id`, `idx_isrc`, `idx_downloaded_at`, `idx_album` - -- **Track Duration in Home Feed Items**: Home feed tracks now include duration - - Added `duration_ms` field to `ExploreItem` model - - Parsed from spotify-web and ytmusic home feed responses - - Fixes track duration showing "0:00" in metadata screen after download from home feed - -- **Discography Download** (Highly Requested): Download entire artist discography with album selection - - New "Download Discography" button in artist header - - Bottom sheet options: Download All, Albums Only, Singles & EPs Only, or Select Albums - - Album selection mode with multi-select checkboxes on album cards - - Long press on album card to enter selection mode - - Selection bar with Select All/Deselect All and Download Selected actions - - Progress dialog showing current/total albums while fetching tracks - - Automatically skips already downloaded tracks (checks history database) - - Works with Spotify, Deezer, and Extension-based artists - - Back button exits selection mode instead of navigating away +- Album track list shows track number instead of cover image +- Download buttons with more rounded corners +- Downloaded songs in Recent show primary-colored subtitle ### Fixed -- **YT Music Greeting Time**: Fixed "Good night" showing in the morning - - Root cause: Goja JS engine returns `getTimezoneOffset() = 0` instead of actual offset - - Now uses `gobackend.getLocalTime().hour` for accurate local hour - - Greeting correctly shows "Good morning/afternoon/evening/night" based on device time - -- **Spotify Home Feed Timezone**: Fixed timezone detection for Spotify API calls - - Now uses `gobackend.getLocalTime().timezone` or offset mapping - - Ensures personalized content is based on correct user timezone - -- **Home Feed Track Duration**: Fixed duration showing 0:00 when downloading from home feed - - spotify-web and ytmusic extensions now include `duration_ms` in home feed items - - `ExploreItem` model now has `durationMs` field - - `_downloadExploreTrack()` uses `item.durationMs` instead of hardcoded 0 -- **Explore Item Navigation**: Prevents fallthrough so tapping a track/album/playlist/artist only triggers its intended action +- Home feed timezone detection +- Track duration 0:00 when downloading from home feed ### Extensions -- **spotify-web Extension**: Updated to v1.8.0 - - Added `capabilities: { homeFeed: true, browseCategories: true }` to manifest - - `fetchHomeFeed()` now uses `gobackend.getLocalTime()` for timezone detection - - Added `duration_ms` to home feed track items - - Removed reliance on Goja's broken `getTimezoneOffset()` and `Intl.DateTimeFormat()` +- spotify-web v1.8.1: Home feed, artist_id support +- ytmusic v1.6.1: Home feed, artist_id support -- **ytmusic-spotiflac Extension**: Updated to v1.6.0 - - Added `capabilities: { homeFeed: true }` to manifest - - `getTimeBasedGreeting()` now uses `gobackend.getLocalTime().hour` directly - - Added `duration_ms` parsing from subtitle runs in home feed items - - Simplified greeting logic - no more manual UTC offset calculations - -### Localization - -- Added 18 new strings for discography download feature: - - `discographyDownload`, `discographyDownloadAll`, `discographyDownloadAllSubtitle` - - `discographyAlbumsOnly`, `discographyAlbumsOnlySubtitle` - - `discographySinglesOnly`, `discographySinglesOnlySubtitle` - - `discographySelectAlbums`, `discographySelectAlbumsSubtitle` - - `discographyFetchingTracks`, `discographyFetchingAlbum` - - `discographySelectedCount`, `discographyDownloadSelected` - - `discographyAddedToQueue`, `discographySkippedDownloaded` - - `discographyNoAlbums`, `discographyFailedToFetch` - -### Technical - -- **Go Backend**: Added `getLocalTime` function to `RegisterGoBackendAPIs()` - - File: `go_backend/extension_runtime_utils.go` - - Returns device local time with timezone info via `time.Now()` - - Offset follows JS convention (negative for east of UTC) - -- **Flutter Explore Provider**: Updated extension selection logic - - File: `lib/providers/explore_provider.dart` - - Finds extensions with `hasHomeFeed` capability - - Prefers spotify-web if available, falls back to first available - - Added `durationMs` field to `ExploreItem` model -- **Explore Provider**: Single-pass home feed extension selection (prefers spotify-web) and guard against parallel fetches -- **Go Backend Extensions**: Consolidates `getHomeFeed`/`getBrowseCategories` execution into a shared helper - -- **Flutter Home Tab**: Refactored explore sections rendering - - File: `lib/screens/home_tab.dart` - - Added `RefreshIndicator` wrapper with `notificationPredicate` for conditional refresh - - Added `_buildYTMusicQuickPicksSection()` for special YT Music format - - Added `_QuickPicksPageView` StatefulWidget for swipeable track pages - - `_downloadExploreTrack()` now uses `item.durationMs` - - Uses a single `SliverList` for Explore sections to reduce sliver count - - Moves provider listeners to `initState` with `listenManual` - - Early-exit loop for YT Music Quick Picks detection - - Removes redundant provider watches and reuses `MediaQuery` values - -### Performance - -- **Download History Database**: Migrated from JSON/SharedPreferences to SQLite - - File: `lib/services/history_database.dart` - - Load time: O(query) instead of O(parse entire JSON) - - Lookup by spotify_id/isrc: O(1) with index instead of O(n) linear search - - Save single item: O(1) INSERT instead of O(n) serialize entire list - - Delete single item: O(1) DELETE instead of O(n) serialize entire list - - Memory: Only loaded items in memory, not entire JSON string +--- ## [3.1.3] - 2026-01-19 @@ -440,131 +344,22 @@ ### Added - **Recent Access History**: Quick access to recently visited content when tapping the search bar - - Shows recently visited artists, albums, playlists, and downloaded tracks - - Merged view combining navigation history and download history - - Tap to quickly navigate back to previously accessed content - - X button to remove individual items from history - - "Clear All" button to clear entire history - - Persists across app restarts (stored in SharedPreferences) - - Max 20 items stored, sorted by most recent - - Multi-language support (Artist/Album/Song/Playlist labels localized) - -- **Artist Screen Redesign** - - Full-width header image (380px) with gradient overlay - - Artist name displayed at bottom of header with text shadow - - Monthly listeners count display (formatted with compact notation) - - "Popular" section showing top 5 tracks with download status indicators - - Dynamic download button states (queued, downloading, completed) - - Header image and top tracks fetched from extension metadata - - Image alignment set to top-center to show faces properly - -- **Extension Store Update Badge**: Badge indicator on Store tab icon showing number of available updates - - Users can see extension updates are available without opening Store tab - - Badge shows count of extensions with updates - -- **Extension Compatibility Warning**: Warning badge for extensions requiring newer app version - - Extensions with `minAppVersion` higher than current app show warning label - - Label displays "Requires vX.X.X+" to encourage users to upgrade - - Users can still install the extension (not blocked) - -- **Year in Album Folder Name** ([#50](https://github.com/zarzet/SpotiFLAC-Mobile/issues/50)): New album folder structure options with release year - - - `Artist / [Year] Album`: Albums/Coldplay/[2005] X&Y/ - - `[Year] Album Only`: Albums/[2005] X&Y/ - - Year extracted from release date metadata - - Matches desktop SpotiFLAC folder structure - -- **Extension Album/Playlist/Artist Support**: Extensions can now return albums, playlists, and artists in search results - - - Search results now properly separated into Albums, Playlists, Artists, and Songs sections - - Albums, playlists, and artists show chevron icon (navigate to detail) instead of download button - - Tap album/playlist to view track list and download - - Tap artist to view their albums/discography - - New `getAlbum()`, `getPlaylist()`, and `getArtist()` extension functions - - New `ExtensionAlbumScreen`, `ExtensionPlaylistScreen`, and `ExtensionArtistScreen` for fetching content from extensions - - YouTube Music extension updated with album/playlist/artist support - -- **Odesli (song.link) Integration for YouTube Music Extension** - - New `enrichTrack()` function to fetch ISRC and external service links - - Uses Odesli API to convert YouTube Music tracks to Deezer/Tidal/Qobuz - - Enables built-in service fallback for high-quality audio downloads - - Extension version updated to 1.4.0 with `api.song.link` and `odesli.io` network permissions -- **Download Cancel**: Canceling a download now stops in-flight built-in provider downloads (Tidal/Qobuz/Amazon) and clears backend progress tracking. +- **Artist Screen Redesign**: Full-width header, monthly listeners, top tracks section +- **Extension Store Update Badge**: Badge indicator showing available extension updates +- **Extension Compatibility Warning**: Warning for extensions requiring newer app version +- **Year in Album Folder Name**: New folder structure options with release year +- **Extension Album/Playlist/Artist Support**: Extensions can now return collections in search +- **Odesli Integration**: YouTube Music extension can now match tracks to Deezer/Tidal/Qobuz +- **Download Cancel**: Properly stops in-flight downloads ### Changed -- **Search Bar Behavior**: Tapping search bar now immediately moves it to top position - - Logo and subtitle hide when search bar is focused - - Recent access history appears in the content area below - - More space for recent items, not blocked by keyboard +- Search bar behavior improved with recent access history ### Fixed -- Fixed search source chips still referencing removed badge props. -- Fixed extension artist album metadata to preserve provider IDs and cover URLs for correct navigation. -- Fixed extension playlist fetch to populate provider IDs and reject disabled extensions. -- Fixed extension collection screens calling setState after dispose during async loads. -- Fixed URL handler responses to include provider IDs for extension albums and artists. -- Fixed YTMusic extension not extracting album name and duration from search results. - - Album name is now extracted from flexColumns/subtitle when linked to album browseId. - - Duration is now extracted from fixedColumns/flexColumns in addition to existing sources. -- Fixed "Separate Singles" setting not working ([#54](https://github.com/zarzet/SpotiFLAC-Mobile/issues/54)) - singles were going to Albums folder. - - Root cause: `albumType` was not being extracted from Deezer API during metadata enrichment. - - Deezer track responses now correctly include `album_type` (single/ep/album/compilation). - - Track creation now preserves `albumType` and `source` fields throughout download flow. -- Fixed PageView overscroll at edges (BouncingScrollPhysics → ClampingScrollPhysics) -- Fixed settings item highlight on swipe (highlightColor: Colors.transparent) -- Fixed extension duplicate load error (skip silently instead of throwing error) -- Fixed keyboard appearing when swiping between tabs (unfocus on page change) -- Removed "Free"/"API Key" badges from search source selector -- Fixed cancel action briefly resuming downloads in the queue UI after ~1 second. -- Fixed cancelled downloads being marked as failed when the backend returns after cancellation. -- Fixed cancel triggering provider fallback (cancel now stops the download flow immediately). -- Fixed stale ISRC cache returning deleted files after cancel. -- Fixed search results mixing extension and built-in artists when using default provider. -- Fixed audio files opening with non-music apps by passing audio MIME type on open. -- Fixed album artist showing null/blank by normalizing empty metadata and using artist fallback for tags. -- Fixed `use_build_context_synchronously` lint warnings in `home_tab.dart` -- Fixed `unnecessary_underscores` lint warnings in error widget callbacks -- Fixed duplicate artist entries in recent history (recording now only happens in screen's initState) -- **Go Backend: Missing `item_type` and `album_type` fields** - - Added `ItemType` and `AlbumType` fields to `ExtTrackMetadata` struct - - Fixed `CustomSearchWithExtensionJSON` - now includes `item_type` and `album_type` in response - - Fixed `HandleURLWithExtensionJSON` - now includes `item_type` and `album_type` for tracks - - Fixed `GetAlbumWithExtensionJSON` - now includes `item_type` and `album_type` for album tracks - - Fixed `GetPlaylistWithExtensionJSON` - now includes `item_type` and `album_type` for playlist tracks -- **Album/Playlist Track Thumbnails**: Tracks inside albums/playlists now use album/playlist cover as fallback when no individual cover exists -- **YouTube Music Extension getArtist**: Fixed `getArtist()` function not being registered in extension, causing artist pages to fail with "returned null" error -- **Recent Access UI**: Fixed recent access list disappearing when keyboard is dismissed - now stays visible until user presses Back button -- **Extension Artist Top Tracks**: Fixed top tracks not appearing when opening artist from extension search results - - YT Music extension `getArtist()` now returns `top_tracks` array with up to 10 popular songs - - Go backend `GetArtistWithExtensionJSON` now forwards `top_tracks`, `header_image`, and `listeners` to Flutter - - `ExtensionArtistScreen` now parses and passes top tracks to `ArtistScreen` - - `ArtistScreen` with `extensionId` skips metadata fetch, uses extension data only (fixes "Rate Limited" errors) -- **Search Bar Unfocus**: Fixed search bar not unfocusing when tapping outside - now properly dismisses keyboard and unfocus when tapping anywhere outside the search field -- **Keyboard Appearing on Settings Navigation**: Fixed keyboard randomly appearing when returning from Settings sub-pages (e.g., Appearance) - now uses `FocusManager.instance.primaryFocus?.unfocus()` for more aggressive unfocus -- **Recent Access Artist Navigation**: Fixed opening artist from recent access using wrong screen - now correctly uses `ExtensionArtistScreen` for extension artists (YT Music, etc.) instead of trying to fetch from API - -### Extensions - -- **YouTube Music Extension**: Updated to v1.5.0 - - `getArtist()` now returns `top_tracks` array with popular songs - - Added `header_image` and `listeners` to artist response -- **Web Extension**: Updated to v1.6.0 - -### Localization - -- **Multi-Language Support**: App now supports multiple languages with community contributions via Crowdin - - Available languages: English, Indonesian (Bahasa Indonesia) - - More languages coming soon with community translations - - Contribute translations at [Crowdin](https://crowdin.com/project/spotiflac-mobile) -- Added new localization strings for recent access types: - - `recentTypeArtist` - "Artist" / "Artis" - - `recentTypeAlbum` - "Album" / "Album" - - `recentTypeSong` - "Song" / "Lagu" - - `recentTypePlaylist` - "Playlist" / "Playlist" - - `recentPlaylistInfo` - "Playlist: {name}" - - `errorGeneric` - "Error: {message}" +- Multiple extension-related fixes for artist, album, and playlist handling +- UI fixes for search, settings, and navigation --- @@ -574,1541 +369,32 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party integrations for metadata, downloads, and more. -#### Extension Store - -- Browse and install extensions directly from the app -- New "Store" tab in bottom navigation -- Browse by category: Metadata, Download, Utility, Lyrics, Integration -- Search extensions by name, description, or tags -- One-tap install, update, and uninstall -- Offline cache for browsing without internet - -#### Web Extension - -- Available in Extension Store - install and enable in Settings > Extensions -- Metadata provider using web player API -- Download tracks from Daily Mix, Discover Weekly, and other personalized playlists -- Useful when official API is rate-limited or unavailable - -#### Extension Capabilities - -- **Custom Search Providers** -- **Custom URL Handlers** -- **Custom Thumbnail Ratios**: Square (1:1), Wide (16:9), Portrait (2:3) -- **Post-Processing Hooks**: Extensions can process downloaded files -- **Quality Options**: Extensions can define custom quality settings - -#### Extension APIs - -- Full HTTP support: GET, POST, PUT, DELETE, PATCH -- Persistent cookie jar per extension -- Browser-like polyfills: `fetch()`, `atob()`/`btoa()`, `TextEncoder`/`TextDecoder`, `URL`/`URLSearchParams` -- Storage API for persistent data -- File API for file operations -- HMAC-SHA1 utility for cryptographic operations - -#### Security - -- Sandboxed JavaScript runtime (goja) -- Permission-based access control -- Network domain whitelisting -- Improved credential encryption with per-installation random salt - -### Added - -- **Album Folder Structure Setting**: Option to remove artist folder from album path - - - `Artist / Album` (default): `Albums/Artist Name/Album Name/` - - `Album Only`: `Albums/Album Name/` - -- **Separate Singles Folder**: Organize downloads into Albums/ and Singles/ folders - - - Based on `album_type` from metadata - - Toggle in Settings > Download > Separate Singles Folder - -- **Year in Album Folder Name**: New album folder structure options with release year - - - `Artist / [Year] Album`: Albums/Coldplay/[2005] X&Y/ - - `[Year] Album Only`: Albums/[2005] X&Y/ - - Year extracted from release date metadata - - Matches desktop SpotiFLAC folder structure - -- **Parallel API Calls**: Download URL fetching now uses parallel requests - - Tidal: All 8 APIs requested simultaneously, first success wins - - Qobuz: Both APIs requested simultaneously, first success wins - - Significantly reduces download URL fetch time - -### UI/UX Improvements - -- **Swipeable History Filters**: History tab now supports swipe gestures between All, Albums, and Singles filters - - - Swipe left/right to switch between filter tabs - - Filter chips sync with swipe position - - Smooth edge-to-edge transition: swipe past Singles to go to Store, swipe past All to go to Home - - Natural gesture feel - drag connects to parent navigation - - - **Improved File Open Intent**: Play button in History now correctly opens music players only - - Added proper MIME type (`audio/flac`, `audio/mpeg`, etc.) when opening downloaded files - - Prevents system from showing unrelated apps in the "Open with" dialog - -### Fixed - -- **Fixed Tab Edge Overscroll**: Home and Settings tabs now stop at edges instead of bouncing into empty space - -- **Fixed Extension Duplicate Load Error**: Extension loading now silently skips already-loaded extensions instead of throwing error - -- **Fixed Settings Item Highlight on Swipe**: Settings items no longer highlight when swiping at page edge - -- **Fixed Keyboard Appearing on Tab Switch**: Keyboard now auto-dismisses when swiping between tabs - -- **Removed Search Source Badges**: Removed "Free" and "API Key" labels from provider selector in Options - -- **Back Gesture Freeze on Android 13+**: Fixed app freeze when using back gesture in settings - - - Added `PopScope` with `canPop: true` to all settings pages - - Changed navigation to use `PageRouteBuilder` with proper slide transition - -- **Bottom Overflow in Folder Organization Dialog**: Fixed overflow in portrait and landscape mode - - - Made dialog scrollable with max height constraint - -- **Japanese Artist Name Order**: Fixed artist mismatch for Japanese names - - - "Sawano Hiroyuki" vs "Hiroyuki Sawano" now correctly matches - -- **Multi-Artist Matching**: Fixed artist mismatch for collaboration tracks - - - "RADWIMPS feat. Toko Miura" now matches when service only shows "Toko Miura" - -- **Max Resolution Cover Download**: Fixed cover not upgrading to max resolution on mobile - - - Mobile now correctly upgrades 300x300 → 640x640 → max resolution (~2000x2000) - -- **EXISTS: Prefix in File Path**: Fixed "File not found" error in metadata screen - - - Duplicate detection prefix now stripped before saving to history - -- **Extension Search Result Parsing**: Fixed "cannot unmarshal array" error - - - Go backend now handles both array and object formats from extensions - -- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error - -- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track - - - Detects existing entries by track ID, Deezer ID, or ISRC - -- **Permission Error Message**: Fixed download showing "Song not found" when actually permission error - - - Now shows proper message: "Cannot write to folder, check storage permission" - -- **Android 13+ Storage Permission**: Fixed storage permission not working on Android 13+ - - Now requests both `MANAGE_EXTERNAL_STORAGE` and `READ_MEDIA_AUDIO` - -### Changed - -- **Extension Manifest**: New `file` permission required for file operations - ```json - "permissions": { - "network": ["api.example.com"], - "storage": true, - "file": true - } - ``` - -### Technical - -- Go backend: Simplified parallel download result handling in Tidal/Qobuz -- Go backend: Removed unused functions and fixed bit shifting warnings -- Release workflow: Fixed duplicate `---` separator in release notes - ---- - -## [3.0.0-beta.2] - 2026-01-13 - -### Added - -- **Album Folder Structure Setting**: Option to remove artist folder from album path - - New setting in Download Settings when "Separate Singles Folder" is enabled - - `Artist / Album` (default): `Albums/Artist Name/Album Name/` - - `Album Only`: `Albums/Album Name/` - - Requested by user who prefers flat album organization - -### Fixed - -- **Back Gesture Freeze on OnePlus/Android 13+**: Fixed app freeze when using back gesture in settings - - - Added `PopScope` with `canPop: true` to all settings pages - - Changed navigation to use `PageRouteBuilder` with proper slide transition - - Fixes predictive back gesture conflict on devices with gesture navigation - - Affected pages: Download, Appearance, Options, Extensions, About, Logs, Extension Detail - -- **Extension Search Result Parsing**: Fixed "cannot unmarshal array into Go value" error - - - Go backend now handles both array and object formats from extensions - - Extensions returning `[{track}, {track}]` now work correctly - - Extensions returning `{tracks: [...], total: N}` still work as before - -- **Max Resolution Cover Download**: Fixed cover not upgrading to max resolution on mobile - - - Added missing `spotifySize300` constant (300x300 size code) - - Mobile now correctly upgrades 300x300 → 640x640 → max resolution (~2000x2000) - - Added `_upgradeToMaxQualityCover()` helper in Flutter for M4A conversion path - - Go backend `cover.go` now directly replaces URL without HEAD verification - -- **Extension Search Provider Reset**: Fixed search provider not resetting to default when disabled - - - `copyWith` in `AppSettings` couldn't set `searchProvider` to `null` - - Added `clearSearchProvider` boolean parameter to properly clear the value - - Settings menu now correctly switches back to default provider - -- **Extension Disabled Search Fallback**: Fixed error when extension is disabled but still called - - - `_performSearch` now checks if extension is still enabled before calling custom search - - Automatically falls back to Deezer search if extension was disabled - - Clears `searchProvider` setting if extension no longer available - -- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error - - - Added `mounted` check after async operation in `_initialize()` - - Prevents crash when navigating away from Store tab during initialization - -- **EXISTS: Prefix in File Path**: Fixed "File not found" error in metadata screen after download - - - Duplicate detection was adding `EXISTS:` prefix to file paths - - Prefix now stripped before saving to download history - - Legacy history items with prefix are handled gracefully - -- **History Error Badge**: Fixed error badge showing on history items even when file exists - - - `queue_tab.dart` now strips `EXISTS:` prefix before checking file existence - - File open and delete operations also use cleaned path - -- **Extension Artist URL Handler**: Fixed artist pages showing "0 releases" from extensions - - - Extension `fetchArtist` now returns correct format: `{ type: "artist", artist: { albums } }` - - Go backend `HandleURLWithExtensionJSON` now includes albums in artist response - - Added `AlbumType` field to `ExtAlbumMetadata` struct - -- **Extension Artist Name in Logs**: Fixed empty artist name in extension track logs - - - Now uses `firstArtist` + `otherArtists` instead of deprecated `artists.items` - - Logs correctly show "Fetched track: {title} by {artist}" - -- **Japanese Artist Name Order**: Fixed artist mismatch for Japanese names with different order - - - "Sawano Hiroyuki" vs "Hiroyuki Sawano" now correctly matches - - Added `sameWordsUnordered` check to both Tidal and Qobuz artist matching - - Handles Japanese name order (family name first) vs Western name order (given name first) - -- **Multi-Artist Matching**: Fixed artist mismatch for collaboration tracks - - - "RADWIMPS feat. Toko Miura" now matches when Qobuz/Tidal only shows "Toko Miura" - - Split artists by separators (`, `, `feat.`, `ft.`, `&`, `and`, `x`) - - Match if ANY expected artist matches ANY found artist - -- **Cover Download Logging**: Improved cover download logs for debugging - - Shows original URL, upgrade steps, and final URL - - Displays estimated resolution based on file size - - Logs now appear in Settings > Logs via GoLog - ---- - -## [3.0.0-beta.1] - 2026-01-13 - -### Security - -- Improved extension sandbox security -- Improved credential encryption with per-installation random salt - -### Changed - -- **Extension Manifest**: New `file` permission required for file operations - ```json - "permissions": { - "network": ["api.example.com"], - "storage": true, - "file": true - } - ``` - Extensions that need to download files must declare `"file": true` in manifest. - -### Fixed - -- Extension packages now preserve directory structure (subdirectories supported) -- Back gesture freeze in settings pages on Android gesture navigation - ---- - -## [3.0.0-alpha.4] - 2026-01-12 - -### Added - - **Extension Store**: Browse and install extensions directly from the app +- **Web Extension**: Metadata provider for personalized playlists +- **Extension Capabilities**: Custom search, URL handlers, thumbnail ratios, post-processing +- **Extension APIs**: Full HTTP, storage, file, and crypto support +- **Security**: Sandboxed JavaScript runtime with permission-based access - - New "Store" tab in bottom navigation - - Browse extensions by category (Metadata, Download, Utility, Lyrics, Integration) - - Search extensions by name, description, or tags - - One-tap install and update - - Offline cache for browsing without internet - - Extensions hosted at github.com/zarzet/SpotiFLAC-Extension +### Added -- **Custom URL Handler for Extensions**: Extensions can now register custom URL patterns - - - Handle URLs from YouTube Music, SoundCloud, Bandcamp, etc. - - Manifest config: `urlHandler: { enabled: true, patterns: ["music.youtube.com"] }` - - Implement `handleUrl(url)` function in extension to parse and return track metadata - - SpotiFLAC automatically routes matching URLs to the appropriate extension - - Supports share intents and paste from clipboard - -- **Artist URL Handler Support**: Extensions can now return artist data from URL handlers - - - Added `type: "artist"` handling in track_provider.dart - - Navigate to artist screen with albums list from extension - -- **HMAC-SHA1 Utility**: New `utils.hmacSHA1(key, message)` function for extensions - - Enables TOTP generation and other cryptographic operations - - Returns byte array for flexible use +- Album folder structure settings +- Separate singles folder option +- Year in album folder name +- Parallel API calls for faster downloads +- Swipeable history filters ### Fixed -- **Extension Store Refresh**: Store tab now properly refreshes after uninstalling an extension - - "Installed" badge correctly updates to "Install" button - -### Documentation - -- Updated `docs/EXTENSION_DEVELOPMENT.md`: - - Added Custom URL Handler section with examples - - Added `handleUrl` function documentation - - Added URL pattern examples for YouTube, SoundCloud, Bandcamp - - Added `utils.hmacSHA1` documentation with TOTP example - -### Extensions - -- **Web Extension** (example): New extension for metadata via web API - - Supports personalized playlists (Daily Mix, Discover Weekly, Release Radar, etc.) - - Search, album, playlist, track, and artist fetching - - Available in Extension Store (3.0.0-alpha.4) +- Tab edge overscroll +- Extension duplicate load error +- Settings item highlight on swipe +- Back gesture freeze on Android 13+ +- Bottom overflow in dialogs +- Japanese artist name matching +- Multi-artist matching +- Max resolution cover download +- Various extension-related fixes --- -## [3.0.0-alpha.3] - 2026-01-12 - -### Added - -- **Separate Singles Folder**: Option to organize downloads into Albums/ and Singles/ folders - - Based on `album_type` from metadata - - Toggle in Settings > Download > Separate Singles Folder - - Singles saved to `{output}/Singles/`, albums to `{output}/Albums/` -- **Browser-like Polyfills**: New global APIs for easier library porting - - `fetch()` - Browser-compatible HTTP API with `json()`, `text()`, `arrayBuffer()` methods - - `atob()` / `btoa()` - Global Base64 encoding/decoding - - `TextEncoder` / `TextDecoder` - UTF-8 text encoding classes - - `URL` / `URLSearchParams` - URL parsing and manipulation classes - - Makes porting browser libraries (like `youtubei.js`) much easier - -### Performance - -- **Parallel API Calls**: Download URL fetching now uses parallel requests - - Tidal: All 8 APIs requested simultaneously, first success wins - - Qobuz: Both APIs requested simultaneously, first success wins - - Significantly reduces download URL fetch time - -### Fixed - -- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track - - Detects existing entries by track ID, Deezer ID, or ISRC - - Replaces existing entry and moves to top of list - - Auto-deduplicates existing history on app load -- **Extension Search Fallback**: Fixed error when extension is disabled but still called for search - - Now checks if extension is still enabled before calling custom search - - Auto-resets search provider to default if extension was disabled -- **Permission Error Message**: Fixed download showing "Song not found" when actually a permission error - - Now shows proper message: "Cannot write to folder, check storage permission" - - Added `permission` error type detection in backend -- **Android 13+ Storage Permission**: Fixed storage permission not working on Android 13+ - - Android 13+ now requests both `MANAGE_EXTERNAL_STORAGE` and `READ_MEDIA_AUDIO` - - `MANAGE_EXTERNAL_STORAGE` opens Settings (system-level, persists across app data clear) - - `READ_MEDIA_AUDIO` shows dialog (app-level, resets on app data clear) - - Proper permission check before showing "granted" status - ---- - -## [3.0.0-alpha.2] - 2026-01-12 - -### Added - -- **Full HTTP Method Support**: New shortcut methods for all common HTTP verbs - - `http.put(url, body, headers)` - PUT requests - - `http.delete(url, headers)` - DELETE requests - - `http.patch(url, body, headers)` - PATCH requests - - `http.clearCookies()` - Clear all cookies for the extension -- **Persistent Cookie Jar**: Each extension now has its own cookie jar - - Cookies automatically stored from `Set-Cookie` headers - - Cookies automatically sent with subsequent requests to same domain - - Useful for APIs requiring session cookies (YouTube, etc.) -- **Multi-Value Header Support**: Response headers now return arrays for multi-value headers - - `Set-Cookie` and other headers with multiple values returned as arrays - - Single-value headers still returned as strings for convenience -- **Generic HTTP Request Method**: New `http.request()` for full HTTP control - - Supports all HTTP methods (GET, POST, PUT, DELETE, PATCH, etc.) - - Single options object for cleaner API: `http.request(url, { method, body, headers })` -- **Response Helper Properties**: HTTP responses now include convenience properties - - `response.ok` - true if status code is 2xx - - `response.status` - alias for `statusCode` - -### Fixed - -- **User-Agent Header Respect**: Custom `User-Agent` headers are now respected - - Previously, extension-provided User-Agent was overwritten - - Now only sets default User-Agent if extension doesn't provide one -- **HTTP POST Body Auto-Stringify**: `http.post()` now automatically stringifies objects to JSON - - Previously, passing an object as body resulted in `[object Object]` - - Now objects and arrays are automatically JSON.stringify'd - - String bodies still work as before (no double-encoding) - -### Documentation - -- Updated `docs/EXTENSION_DEVELOPMENT.md`: - - Added complete HTTP API documentation with all methods - - Added Cookie Jar documentation - - Added `http.put()`, `http.delete()`, `http.patch()`, `http.clearCookies()` docs - - Added YouTube Music / Innertube API example with custom User-Agent - - Added common domain lists for YouTube, SoundCloud, Bandcamp - - Improved HTTP API documentation with response properties - ---- - -## [3.0.0-alpha.1] - 2026-01-11 - -#### Extension System - -- **Custom Search Providers**: Extensions can now provide custom search functionality - - YouTube, SoundCloud, and other platforms via extensions - - Custom search placeholder text per extension - - Configurable thumbnail aspect ratios (square, wide, portrait) -- **Extension Upgrade System**: Upgrade extensions without losing data - - Preserves extension settings and cached data during upgrades - - Version comparison prevents downgrades - - Auto-detects upgrades when installing same extension -- **Custom Thumbnail Ratios**: Extensions can specify thumbnail display format - - `"square"` (1:1) - Album art style (default) - - `"wide"` (16:9) - YouTube/video style - - `"portrait"` (2:3) - Poster style - - Custom width/height override available - -### Added - -- **Track Source Tracking**: Tracks now remember which extension provided them - - `Track.source` field stores extension ID - - `TrackState.searchExtensionId` for current search context - - Enables extension-specific UI customization -- **Extension Upgrade API**: New methods for extension management - - `upgradeExtension(filePath)` - Upgrade existing extension - - `checkExtensionUpgrade(filePath)` - Check if file is an upgrade - - `RemoveExtensionByID` - Remove extension by ID -- **iOS Extension Support**: Added missing iOS method handlers - - `upgradeExtension` - Upgrade extension from file - - `checkExtensionUpgrade` - Check upgrade compatibility -- **Extension Documentation**: Comprehensive extension development guide - - Thumbnail ratio customization documentation - - Extension upgrade workflow documentation - - New troubleshooting entries for common issues - -### Changed - -- **Version Bump**: 2.2.7 → 3.0.0-alpha.1 (major version for extension system) -- **Build Number**: 49 → 50 -- **Extension Manager**: Improved upgrade detection in `LoadExtensionFromFile` - - Auto-detects if installing same extension with higher version - - Calls `UpgradeExtension` automatically for seamless upgrades - -### Fixed - -- **Extension `registerExtension`**: Fixed global `extension` variable not being set - - Extensions can now access their own functions via `extension.functionName()` - - Required for `customSearch` and other provider functions -- **Custom Search Empty Results**: Fixed error when extension returns null - - Now returns empty array instead of error - - Prevents crash when no results found -- **Mutex Crash on Upgrade**: Fixed "Unlock of unlocked RWMutex" crash - - Removed `defer m.mu.Unlock()` when manual unlock is used - - Proper lock handling in upgrade flow -- **Duplicate Error Messages**: Fixed extension install errors showing twice - - Added `clearError()` method to extension provider - - Improved PlatformException parsing to remove "null, null" artifacts -- **Extension Images Field**: Fixed thumbnails not showing in search results - - Added `Images` field to `ExtTrackMetadata` struct - - Renamed `GetCoverURL` to `ResolvedCoverURL` (gomobile conflict) - -### Technical - -- **Go Backend Changes**: - - `go_backend/extension_manager.go`: Added `compareVersions()`, `UpgradeExtension()`, `CheckExtensionUpgradeJSON()` - - `go_backend/extension_providers.go`: Added `Images` field, `ResolvedCoverURL()` method - - `go_backend/extension_manifest.go`: Added `ThumbnailRatio`, `ThumbnailWidth`, `ThumbnailHeight` to `SearchBehaviorConfig` - - `go_backend/exports.go`: Added `RemoveExtensionByID`, `UpgradeExtensionFromPath`, `CheckExtensionUpgradeFromPath` -- **Flutter Changes**: - - `lib/models/track.dart`: Added `source` field - - `lib/models/track.g.dart`: Updated for `source` field - - `lib/providers/track_provider.dart`: Added `searchExtensionId`, updated `_parseSearchTrack` with source parameter - - `lib/providers/extension_provider.dart`: Added `SearchBehavior.getThumbnailSize()`, `clearError()` - - `lib/screens/home_tab.dart`: Dynamic thumbnail size based on extension config - - `lib/screens/settings/extensions_page.dart`: Improved error handling - - `lib/services/platform_bridge.dart`: Added `upgradeExtension()`, `checkExtensionUpgrade()`, `removeExtension()` -- **iOS Changes**: - - `ios/Runner/AppDelegate.swift`: Added `upgradeExtension`, `checkExtensionUpgrade` handlers -- **Android Changes**: - - `android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt`: Already had upgrade methods - ---- - -## [2.2.8] - 2026-01-12 - -### Added - -- **Multi-Select Batch Delete**: Long-press tracks in History to enter selection mode - - Select multiple tracks at once - - "Select All" and "Delete Selected" actions - - Modern Material 3 bottom action bar (slides up from bottom) - - Works in both grid and list view modes -- **History Filter Tabs**: Filter history by All/Albums/Singles - - Album = tracks where album has >1 track in history - - Single = tracks where album has only 1 track in history - - Filter chips show counts for each category -- **Album Grouping View**: When "Albums" filter is selected, tracks are grouped by album - - Album cards displayed in 2-column grid with cover art and track count badge - - Tap album to open dedicated album detail screen - - Album detail shows all downloaded tracks from that album - - Multi-select delete support within album view - - Auto-navigates back when album has <2 tracks remaining - -### Changed - -- **Issue Templates**: Updated version confirmation checkbox to specify "(Stable Version)" - ---- - -## [2.2.7] - 2026-01-11 - -### Added - -- **CSV Import Metadata Enrichment**: Tracks imported from CSV now automatically fetch metadata from Deezer - - Cover art, duration, track/disc number fetched via ISRC lookup - - Fallback to text search (artist + track name) when ISRC not found in Deezer - - Progress dialog shows enrichment status during import - - Ensures downloaded files have proper cover art and metadata -- **Deezer Metadata Support**: Enhanced metadata viewer for Deezer tracks - - "Open in Deezer" button for Deezer-sourced tracks (opens app or web) - - Displays "Deezer ID" instead of "Spotify ID" when applicable -- **Smart Tag Injection**: Filename format editor intelligently handles separators - - Auto-detects if " - " is needed between tags - - Prevents double separators or missing spaces -- **Dynamic Source Info**: Search source selector now shows helpful context - - "No login required" for Deezer - - "Requires credentials" for Spotify - -### Changed - -- **UI Modernization**: Major UI consistency updates across the app - - **Unified App Bars**: Home, History, and Settings now share identical behavior - - Lowered expanded header for easier one-handed reachability - - Dynamic title text scaling (20px to 34px) - - **Appearance Settings**: Completely redesigned appearance page - - New "Theme Preview" card showing visualizing current theme - - Modern color palette picker replacing old color dots - - Clean, grouped layout - - "AMOLED Dark" switch is now hidden when using Light Mode - - **App Logo**: Refined logo style on Home and About screens - - Inverted colors: Filled primary color circle with on-color icon - - Removed padding for a cleaner, bolder look - - **Material 3 Switches**: Added checkmark icon to active switches -- **UI Modernization (Global)**: Complete design refresh for a cleaner, modern look - - **Rounded Corners**: Standardized 16px radius for all cards, buttons, and input fields - - **Transparent Elements**: Applied subtle transparency to input fields and containers using `surfaceContainerHighest` - - **Consistent Buttons**: Unified button styling across the app (pill shape, 16px radius) -- **Options Settings Redesign**: improved layout and usability - - **Search Source Priority**: Moved "Search Source" section to the very top for quick access - - **Compact Source Selector**: Redesigned provider toggle (Deezer/Spotify) to be compact and consistent - - **Credentials Workflow**: Reorganized Custom Credentials settings; toggle now auto-prompts if credentials missing - - **Modern Credentials Dialog**: Totally redesigned input dialog for Spotify Client ID/Secret -- **Filename Format Editor 2.0**: - - **Modern Sheet UI**: Replaced legacy dialog with a clean, full-width bottom sheet - - **Tag Chips**: Added clickable chips ({artist}, {title}) for one-tap insertion - - **Smart Formatting**: Automatically injects separators (" - ") when adding tags for faster editing - -### Fixed - -- **CSV Import Missing Cover Art**: Fixed tracks from CSV having no cover art in download history - - Cover URL now properly fetched from Deezer during enrichment - - Falls back to text search when ISRC lookup fails -- **CSV Import Missing Duration**: Fixed duration showing 0:00 for CSV-imported tracks - - Duration now fetched from Deezer metadata during enrichment -- **Disc Number Not Displayed**: Fixed disc number not showing in track metadata screen - - Changed condition from `discNumber > 0` to `discNumber > 0` - - Now displays disc 1 instead of hiding it -- **Download History Using Wrong Track Data**: Fixed history using original CSV data instead of enriched data - - Now uses `trackToDownload` (enriched) instead of `item.track` (original) - -### Technical - -- Updated `lib/services/csv_import_service.dart`: - - Added `_enrichTracksMetadata()` with ISRC lookup + text search fallback - - Added progress callback for UI feedback -- Updated `lib/screens/home_tab.dart`: - - Added progress dialog during CSV enrichment -- Updated `lib/providers/download_queue_provider.dart`: - - Uses enriched track data for download history -- Updated `lib/screens/track_metadata_screen.dart`: - - Show disc number when > 0 (was > 1) -- Updated `go_backend/metadata.go`: - - Added `TotalSamples` to `AudioQuality` struct for duration calculation -- Updated `go_backend/exports.go`: - - `ReadFileMetadata` now returns duration calculated from FLAC stream info -- Updated `AppTheme` with new `InputDecorationTheme` and `ButtonTheme` definitions -- Refactored `DownloadSettingsPage` to use new `_showFormatEditor` with cursor-aware capabilities -- Optimized various dialogs to use `showModalBottomSheet` with `isScrollControlled` for better keyboard handling - ---- - -## [2.2.6] - 2026-01-11 - -### Fixed - -- **Release Mode Logging**: Flutter app logs now properly captured in release builds - - Previously only Go backend logs appeared when "Detailed Logging" was enabled - - Now both Flutter and Go logs are captured in release mode - - Bypasses Logger package which filters logs in release mode - -### Added - -- **Detailed Deezer Search Logging**: Better debugging for search issues - - Logs API URLs, response counts, and errors - - Helps diagnose geo-restriction and API issues - - Detects Deezer API error responses - -### Changed - -- **Home Screen Logo**: Replaced music note icon with app logo - - Uses `assets/images/logo.png` - - Rounded corners (24px radius) - - Fallback to music note icon if logo fails to load -- **About Page Logo**: Removed shadow/border from logo - - Cleaner appearance without background container -- **About Page Icon Alignment**: Icons now aligned with contributor avatars - - DoubleDouble and DAB Music icons use 40x40 area - - Text now properly aligned with contributor items - -## [2.2.5] - 2026-01-10 - -### Added - -- **In-App Log Viewer with Go Backend Logs**: Complete logging system for debugging - - Go backend logs now captured and displayed in app - - Circular buffer stores up to 500 log entries - - Real-time polling (500ms) for Go backend logs - - Logs include timestamp, level, tag, and message - - "Go" badge indicates logs from backend -- **Detailed Logging Toggle**: Control logging in Settings > Options > Debug - - Disabled by default for performance - - Errors are always logged regardless of setting - - Enable before reproducing bugs for detailed logs -- **Log Issue Summary**: Automatic detection of common issues in logs - - ISP Blocking detection with affected domains - - Rate limiting detection - - Network error detection - - Track not found detection - - Shows suggestions for each issue type -- **ISP Blocking Detection**: Detects when ISP blocks download services - - DNS resolution failure detection - - Connection reset/refused detection - - TLS handshake failure detection - - HTTP 403/451 blocking page detection - - Suggests VPN or DNS change (1.1.1.1 / 8.8.8.8) - -### Fixed - -- **Artist Profile Placeholder**: Shows person icon when artist has no profile image - - Validates image URL before loading - - Fallback icon on load error -- **Latin Extended Character Detection**: Fixed wrong track downloads for Polish, Czech, French, Spanish songs - - Characters like Ł, ę, ć, ñ, é now correctly treated as Latin script - - Previously treated as "different script" causing false matches - - Affects both Tidal and Qobuz search - -### Changed - -- **Log Screen UI Improvements**: - - Copy button moved to app bar (left of menu) - - Removed redundant info card - - Cleaner interface -- **Issue Templates Updated**: Instructions for enabling detailed logging before submitting bug reports - -### Technical - -- New file: `go_backend/logbuffer.go` with circular buffer and GoLog function -- Updated `go_backend/httputil.go` with ISP blocking detection -- Updated `go_backend/tidal.go` and `go_backend/qobuz.go` with `isLatinScript()` function -- Updated `lib/utils/logger.dart` with Go log polling -- Updated `lib/screens/settings/log_screen.dart` with issue summary -- Added method channel handlers for logging in Android and iOS -- New error type: `isp_blocked` for ISP blocking errors - ---- - -## [2.2.0] - 2026-01-10 - -### Fixed - -- **ISRC Metadata Missing:** Fixed an issue where ISRC codes were not being saved to the download history or embedded in file metadata for certain downloads. The backend now correctly propagates the ISRC found from streaming services (Tidal, Qobuz, Amazon) back to the application. -- **Tidal Track/Disc Numbers:** Fixed missing Track Number and Disc Number in Tidal downloads. The downloader now prioritizes the actual metadata returned by Tidal over the potentially incomplete metadata from the initial search request. -- **Concurrent Download Race Condition:** Fixed a potential race condition where temporary cover art files could overwrite each other during rapid concurrent downloads by adding randomization to temporary filenames. -- **Qobuz Search Accuracy:** Reduced the duration tolerance for Qobuz search matches from 30s to 10s to prevent matching with incorrect versions/remixes. -- **Metadata Enrichment Null Safety**: Fixed `type 'Null' is not a subtype of type 'String'` error - - Added proper null checks when parsing Go backend response - - Added type checking for track data before parsing -- **Duration Calculation in Enrichment**: Fixed duration conversion bug - - Go backend returns `duration_ms` (milliseconds) - - Now properly converts to seconds for Track model - -### Changed - -- **Default Service Priority:** Updated the default download fallback order to **Tidal → Qobuz → Amazon**. - - Tidal is now the default download service (was Qobuz) - - Tidal has faster and more reliable ISRC matching - - Existing users need to change setting manually or clear app data -- **Metadata Enrichment:** Improved metadata handling for Deezer tracks. If critical metadata (ISRC, Track Number) is missing from the initial search, the app now automatically fetches full details from the Deezer API before finding a source. - -### Added - -- **ISRC in History:** The Download History now reliably displays the ISRC code for downloaded tracks. -- **Tidal Search Optimization:** Optimized Tidal search logic to immediately check for ISRC matches within search results, improving match speed and accuracy. - - Returns as soon as ISRC match is found in first query results - - Significantly faster for tracks with valid ISRC -- **ISRC Enrichment for Search Results**: Tracks from Home search now fetch ISRC before download - - Search results don't include ISRC (for performance) - - ISRC is now fetched via metadata enrichment when download starts - - Ensures accurate track matching on all streaming services -- **Deezer-to-Tidal Fallback:** Added native support for converting Deezer IDs to Tidal links via SongLink when using the fallback mechanism. -- **Better Logging for Qobuz ISRC Search**: Added detailed logs for debugging - - Shows when ISRC search is attempted - - Shows number of results and exact ISRC matches found - -### Technical - -- Updated `go_backend/tidal.go`: - - Early exit optimization in `SearchTrackByMetadataWithISRC()` - - Deezer ID support in SongLink lookup -- Updated `go_backend/qobuz.go`: - - Added logging for ISRC search flow - - Duration tolerance reduced from 30s to 10s -- Updated `go_backend/exports.go`: - - Default service order changed to `[tidal, qobuz, amazon]` -- Updated `lib/providers/download_queue_provider.dart`: - - ISRC-based enrichment condition - - Null-safe parsing of Go backend response -- Updated `lib/services/platform_bridge.dart`: - - Null check for `getDeezerMetadata` result -- Updated `lib/models/settings.dart`: - - Default service changed to `tidal` - ---- - -## [2.1.7] - 2026-01-09 - -### Added - -- **Special Thanks Section**: Added new "Special Thanks" section in About page to credit API creators - - **uimaxbai** - Creator of QQDL & HiFi API for Tidal downloads - - **sachinsenal0x64** - Original HiFi project creator, foundation of Tidal integration - - **DoubleDouble** - Amazing API for Amazon Music downloads - - **DAB Music** - The best Qobuz streaming API for Hi-Res downloads -- **New Contributor**: Added Amonoman to Contributors section as the app logo creator - -### Fixed - -- **Missing PlatformBridge Import**: Fixed build errors in `home_tab.dart` and `playlist_screen.dart` - - Added missing `import 'package:spotiflac_android/services/platform_bridge.dart'` -- **iOS Method Channel Crash**: Fixed "Method not implemented" crash when searching Deezer from iOS - - Implemented missing `searchDeezerAll` handler in `AppDelegate.swift` - - Ensures full compatibility with new Deezer integration features on iOS - ---- - -## [2.1.6] - 2026-01-08 - -### Added - -- **Metadata Enrichment**: Automatically fetches full track details if metadata is incomplete (e.g., Track Number 0) - - Fixes missing Track Number, Disc Number, and Year for tracks added from Search results - - Ensures accurate tagging for Deezer/Tidal downloads -- **ISRC Index Building**: Fast duplicate checking with cached ISRC index - - - Scans download folder once and builds index of all ISRCs - - 5 minute cache TTL for optimal performance - - Parallel duplicate checking for album/playlist tracks - - Auto-adds new downloads to index (no rebuild needed) - -- **Japanese to Romaji Search**: Better search results for Japanese tracks - - - Converts Hiragana/Katakana to Romaji for Tidal/Qobuz search - - 4 fallback search strategies (like PC version): - 1. Original text (artist + track) - 2. Romaji converted (artist + track) - 3. ASCII-only cleaned version - 4. Artist name only as last resort - - Handles combination characters (きゃ →kya, シャ →sha, etc.) - -- **SongLink Deezer Support**: Query SongLink using Deezer ID as source - - - `CheckAvailabilityFromDeezer()` - find track on other platforms using Deezer ID - - `CheckAvailabilityByPlatform()` - generic function for any platform - - `GetSpotifyIDFromDeezer()`, `GetTidalURLFromDeezer()`, `GetAmazonURLFromDeezer()` - - Useful when starting from Deezer metadata - -- **LRC Metadata Headers**: Lyrics now include metadata headers - - - `[ti:Track Name]` - track title - - `[ar:Artist Name]` - artist name - - `[by:SpotiFLAC-Mobile]` - generator tag - -- **Download Error Types**: Better error categorization for UI - - - `not_found` - track not available on any service - - `rate_limit` - API rate limit exceeded - - `network` - connection/timeout errors - - `unknown` - other errors - -- **Amazon Rate Limiting**: Proper rate limiting for Amazon via SongLink - - 7 second minimum delay between requests - - Max 9 requests per minute - - 3x retry with 15s wait on 429 rate limit - -### Fixed - -- **SongLink 400 Error**: Added validation for empty Spotify ID - - - Specific error messages for 400, 404, 429 status codes - - Better error handling for invalid track IDs - -- **gomobile Compatibility**: Fixed `ISRCIndex.Lookup()` signature - - Changed from `(string, bool)` to `(string, error)` for gomobile binding - -### Technical - -- New file: `go_backend/romaji.go` with Japanese to Romaji conversion -- New file: `go_backend/duplicate.go` with ISRC index building -- Updated `go_backend/tidal.go` and `go_backend/qobuz.go` with romaji search strategies -- Updated `go_backend/songlink.go` with Deezer support functions -- Updated `go_backend/exports.go` with new export functions for Flutter -- Updated `go_backend/lyrics.go` with `convertToLRCWithMetadata()` -- Updated `go_backend/progress.go` with `SpeedMBps` field -- Updated `lib/models/download_item.dart` with `DownloadErrorType` enum -- Updated `lib/screens/queue_tab.dart` with speed display and error messages - ---- - -## [2.1.6-preview] - 2026-01-08 - -### Added - -- **Deezer as Alternative Metadata Source**: Choose between Deezer or Spotify for search - - - Configure in Settings > Options > Spotify API > Search Source - - Default is Deezer for better reliability - - Spotify URLs are always supported regardless of this setting - -- **Automatic Deezer Fallback for Spotify URLs**: When Spotify API is rate limited (429), automatically falls back to Deezer - - Uses SongLink/Odesli API to convert Spotify track/album ID to Deezer ID - - Fetches metadata from Deezer instead - -### Changed - -- **Default Download Service**: Changed from Tidal to Qobuz - - Fallback order is now: Qobuz → Tidal → Amazon -- **Deezer API Updated to v2.0**: More reliable and complete metadata - - Direct ISRC lookup via `/track/isrc:{ISRC}` endpoint - - Search results now fetch full track info to include ISRC - -### Fixed - -- **Progress Bar Not Updating**: Fixed bug where download progress jumped from 1% directly to 100% - - Progress now updates smoothly every 64KB of data received - - First progress update happens immediately when download starts -- **Incomplete Downloads**: Fixed bug where interrupted downloads could result in corrupted/incomplete files - - File size is validated against server's Content-Length header - - Incomplete files are automatically deleted and error is reported - - Applies to all services: Tidal, Qobuz, and Amazon -- **ISRC Not Available from Deezer Search**: Search results now fetch full track details to get ISRC - -### Technical - -- Settings migration for existing users to set Deezer as default metadata source - ---- - -## [2.1.5] - 2026-01-08 - -### Added - -- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality - - Service selector chips appear above quality options - - Defaults to your preferred service from settings - - Change service on-the-fly without going to settings - - Available in Home, Album, and Playlist screens -- **AMOLED Dark Theme**: Pure black background for OLED screens - - Toggle in Settings > Appearance > Theme - - Saves battery on OLED/AMOLED displays - - All surface colors adjusted for true black background -- **Update Channel Setting**: Choose between Stable and Preview release channels - - Stable: Only receive stable release notifications - - Preview: Get notified about preview/beta releases too - - Configure in Settings > Options > App - -### Changed - -- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs - - arm64 APK: 46.6 MB (previously 51 MB) - - arm32 APK: 59 MB (previously 64 MB) - - Only includes FLAC, MP3 (LAME), and AAC codecs - - Custom FFmpeg AAR with arm64-v8a and armeabi-v7a only - - Native MethodChannel bridge for FFmpeg operations - - Separate iOS build configuration with ffmpeg_kit_flutter plugin - -### Fixed - -- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing - - Now properly handles retry when queue processing has finished - - Also allows retrying skipped (cancelled) downloads -- **Lyrics Loading Timeout**: Added 20 second timeout for lyrics fetching - - Shows "Lyrics not available" instead of loading forever -- **iOS Directory Picker**: Fixed unable to select download folder on iOS - - iOS limitation: Empty folders cannot be selected via document picker - - Added "App Documents Folder" option as recommended default - - Files saved to app Documents folder are accessible via iOS Files app - -### Performance - -- **Download Speed Optimizations**: Significant improvements to download initialization and throughput - - Token caching for Tidal (eliminates redundant auth requests) - - Singleton pattern for all downloaders (HTTP connection reuse) - - ISRC search first strategy (faster than SongLink API) - - Track ID cache with 30 minute TTL for album/playlist downloads - - Pre-warm cache when viewing album/playlist - - Parallel cover art and lyrics fetching during audio download - - 64KB HTTP read/write buffers - - 256KB buffered file writer for all downloaders - - Progress updates every 64KB (reduced lock contention) -- **Amazon Music Optimizations**: Same optimizations now applied to Amazon downloader - -## [2.1.0-preview2] - 2026-01-06 - -### Added - -- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality - - Service selector chips appear above quality options - - Defaults to your preferred service from settings - - Change service on-the-fly without going to settings - - Available in Home, Album, and Playlist screens -- **AMOLED Dark Theme**: Pure black background for OLED screens - - Toggle in Settings > Appearance > Theme - - Saves battery on OLED/AMOLED displays - - All surface colors adjusted for true black background -- **Update Channel Setting**: Choose between Stable and Preview release channels - - Stable: Only receive stable release notifications - - Preview: Get notified about preview/beta releases too - - Configure in Settings > Options > App - -### Fixed - -- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing - - Now properly handles retry when queue processing has finished - - Also allows retrying skipped (cancelled) downloads - - Added logging for better debugging -- **Lyrics Loading Timeout**: Added 20 second timeout for lyrics fetching - - Shows "Lyrics not available" instead of loading forever - - Better error messages for timeout and not found cases - -## [2.1.0-preview] - 2026-01-06 - -### Performance - -- **Download Speed Optimizations**: Significant improvements to download initialization and throughput - - Token caching for Tidal (eliminates redundant auth requests) - - Singleton pattern for all downloaders (HTTP connection reuse) - - ISRC search first strategy (faster than SongLink API) - - Track ID cache with 30 minute TTL for album/playlist downloads - - Pre-warm cache when viewing album/playlist - - Parallel cover art and lyrics fetching during audio download - - 64KB HTTP read/write buffers - - 256KB buffered file writer for all downloaders - - Progress updates every 64KB (reduced lock contention) -- **Amazon Music Optimizations**: Same optimizations now applied to Amazon downloader - -### Technical - -- New `go_backend/parallel.go` with `TrackIDCache`, `FetchCoverAndLyricsParallel()`, `PreWarmTrackCache()` -- Flutter: `_preWarmCacheForTracks()` in `track_provider.dart` -- New method channels: `preWarmTrackCache`, `getTrackCacheSize`, `clearTrackCache` - -## [2.0.7-preview2] - 2026-01-06 - -### Fixed - -- **iOS Directory Picker**: Fixed unable to select download folder on iOS - - iOS limitation: Empty folders cannot be selected via document picker - - Added "App Documents Folder" option as recommended default - - Shows info message explaining iOS limitation - - Files saved to app Documents folder are accessible via iOS Files app - -## [2.0.7-preview] - 2026-01-05 - -### Changed - -- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs - - arm64 APK: 46.6 MB (previously 51 MB) - - arm32 APK: 59 MB (previously 64 MB) - - Only includes FLAC, MP3 (LAME), and AAC codecs - - Removed x86/x86_64 architectures (emulator only) - -### Technical - -- Custom FFmpeg AAR with arm64-v8a and armeabi-v7a only -- Native MethodChannel bridge for FFmpeg operations -- Separate iOS build configuration with ffmpeg_kit_flutter plugin - -## [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 - -### Added - -- **Large Playlist Support**: Playlists with up to 1000 tracks are now fully fetched (was limited to 100) - -### Fixed - -- **Wrong Track Download**: Fixed issue where tracks with same ISRC but different versions (e.g., short/instrumental vs full version) would download the wrong track. Now verifies duration matches before downloading (30 second tolerance). - -## [2.0.4] - 2026-01-04 - -### Fixed - -- **Android 11 Storage Permission**: Fixed "Permission denied" error on Android 11 (API 30) devices - - Added `MANAGE_EXTERNAL_STORAGE` permission for Android 11-12 - - Shows explanation dialog before opening system settings - -## [2.0.3] - 2026-01-03 - -### Added - -- **Custom Spotify API Credentials**: Set your own Spotify Client ID and Secret in Settings > Options to avoid rate limiting - - Toggle to enable/disable custom credentials without deleting them - - Material Expressive 3 bottom sheet UI for entering credentials -- **Keyboard Dismiss on Scroll**: Keyboard now automatically dismisses when scrolling search results -- **Rate Limit Error UI**: Shows friendly error card when API rate limit (429) is hit on Home, Artist, and Album screens - -### Changed - -- **Search on Enter Only**: Removed auto-search debounce, now only searches when pressing Enter key (saves API calls) - -### Fixed - -- **Download Cancel**: Fixed cancelled downloads still completing in background and appearing in history. Cancelled files are now properly deleted. -- **Search Keyboard Dismiss**: Fixed keyboard randomly dismissing and navigating back when starting to search -- **Back Button During Search**: Back button now properly dismisses keyboard first before clearing search -- **Search Error Navigation**: Fixed pressing Enter during search (when loading or error) navigating back to home instead of staying on search screen -- **Duplicate Search on Enter**: Enter key no longer triggers duplicate search if results already loaded - -## [2.0.2] - 2026-01-03 - -### Added - -- **Actual Quality Display**: Shows real audio quality (bit depth/sample rate) after download - - Quality badge on download history items (e.g., "24-bit", "16-bit") - - Full quality info in Track Metadata screen (e.g., "24-bit/96kHz") - - Tertiary color highlight for Hi-Res (24-bit) downloads -- **Quality Disclaimer**: Added note in quality picker explaining that actual quality depends on track availability -- **Instant Lyrics Loading**: Lyrics now load from embedded file first (instant) before falling back to internet fetch - -### Fixed - -- **Fallback Service Display**: Fixed download history showing wrong service when fallback occurs (e.g., showing "TIDAL" when actually downloaded from "QOBUZ") -- **Open in Spotify**: Fixed "Open in Spotify" button not opening Spotify app correctly - -### Removed - -- **Romaji Conversion**: Removed Japanese lyrics to romaji conversion feature (Kanji not supported, results were incomplete) - -### Technical - -- Go backend now returns `actual_bit_depth` and `actual_sample_rate` in download response -- Go backend now returns `service` field indicating actual service used (important for fallback) -- Tidal API v2 response provides exact quality info -- Qobuz uses track metadata for quality info -- Amazon now reads quality from downloaded FLAC file (previously returned unknown) - -## [2.0.1] - 2026-01-03 - -### Added - -- **Quality Picker Track Info**: Shows track name, artist, and cover in quality picker - - Tap to expand long track titles - - Expand icon only shows when title is truncated - - Ripple effect follows rounded corners including drag handle - -### Changed - -- **Unified Progress Tracking System**: Deprecated legacy single-download progress - - All downloads now use item-based progress tracking - - Fixes duplicate notification bug when finalizing - - Cleaner codebase with single progress system - -### Fixed - -- **Duplicate Notification Bug**: Fixed issue where "Finalizing" and "Downloading" notifications appeared simultaneously -- **Update Notification Stuck**: Fixed notification staying at 100% after download completes -- **Quality Picker Consistency**: Unified quality picker UI across all screens (Home, Album, Playlist) - - Container with `primaryContainer` background for each option - - Distinct icons: music_note (Lossless), high_quality (Hi-Res), four_k (Max) - -## [2.0.0] - 2026-01-03 - -### Added - -- **Artist Search Results**: Search now shows artists alongside tracks - - Horizontal scrollable artist cards with circular avatars - - Tap artist to view their discography -- **Multi-Layer Caching System**: Aggressive caching to minimize API calls - - Go backend cache: Artist (10 min), Album (10 min), Search (5 min) - - Flutter memory cache: Instant navigation for previously viewed artists/albums - - Duplicate search prevention: Same query won't trigger new API call -- **Real-time Download Status**: Track items show live download progress - - Queued: Hourglass icon - - Downloading: Circular progress with percentage - - Completed: Check icon - - Works in Home search, Album, and Playlist screens -- **Downloaded Track Indicator**: Tracks already in history show check mark - - Lazy file verification: Only checks file existence when tapped - - Auto-removes from history if file was deleted, allowing re-download - - Prevents accidental duplicate downloads -- **Pre-release Support**: GitHub Actions auto-detects preview/beta/rc/alpha tags - - Stable users won't receive update notifications for preview versions - -### Changed - -- **Instant Navigation UX**: Navigate to Artist/Album screens immediately - - Header (name, cover) shows instantly from available data - - Content (albums/tracks) loads in background inside the screen - - Second visit to same artist/album is instant from Flutter cache -- **Search Results UI Redesign**: - - Removed "Download All" button from search results - - Added "Songs" section header (matches "Artists" header style) - - Track list now in grouped card with rounded corners (like Settings) - - Track items with dividers and InkWell ripple effect -- **Larger UI Elements**: Improved touch targets and visual hierarchy - - Recent downloads: Album art 56→100px, section height 80→130px - - Artist cards: Avatar 72→88px, container 90→100px - - Track items: Album art 48→56px -- **Optimized Search**: Pressing Enter with same query no longer triggers duplicate search -- **Smoother Progress Animation**: Progress jumps to 100% after download completes - - Embedding (cover, metadata, lyrics) happens in background without blocking UI -- **Finalizing Status**: Shows "Finalizing" indicator while embedding metadata - - Distinct icon (edit_note) with tertiary color - - User knows download is complete, just processing metadata -- **Consistent Download Button Sizes**: All download/status buttons now 44x44px -- **Better Dynamic Color Contrast**: Improved visibility for cards and chips with dynamic color - - Settings cards use overlay colors for better contrast - - Theme/view mode chips have visible borders in light mode -- **Navigation Bar Styling**: Distinct background color from content area -- **Ask Before Download Default**: Now enabled by default for better UX - -### Fixed - -- **Artist Profile Images**: Fixed artist images not showing in search results (field name mismatch) -- **Album Card Overflow**: Fixed 5px overflow in artist discography album cards -- **Optimized Rebuilds**: Each track item only rebuilds when its own status changes - - Uses Riverpod `select()` for granular state watching - - Prevents entire list rebuild on progress updates -- **Update Notification Stuck**: Fixed notification staying at 100% after download complete - -## [1.6.3] - 2026-01-03 - -### Added - -- **Predictive Back Navigation**: Support for Android 14+ predictive back gesture with smooth animations -- **Separate Detail Screens**: Album, Artist, and Playlist now open as dedicated screens with Material Expressive 3 design - - Collapsing header with cover art and gradient overlay - - Card-based info section with rounded corners (20px radius) - - Tonal download buttons with circular shape - - Quality picker bottom sheet with drag handle -- **Double-Tap to Exit**: Press back twice to exit app when at home screen (replaces exit dialog) - -### Changed - -- **Navigation Architecture**: Refactored from state-based to screen-based navigation - - Album/Artist/Playlist URLs navigate to dedicated screens via `Navigator.push()` - - Enables native predictive back gesture animations - - Search results stay on Home tab for quick downloads -- **Simplified State Management**: Removed `previousState` chain from TrackProvider since Navigator handles back navigation - -## [1.6.2] - 2026-01-02 - -### Added - -- **HTTPS-Only Downloads**: APK downloads and update checks now enforce HTTPS-only connections for security - -### Changed - -- **Home Tab Rename**: Renamed "Search" tab to "Home" with home icon -- **Branding**: Changed idle screen title from "Search Music" to "SpotiFLAC" -- **About Page Redesign**: New Material Expressive 3 grouped layout with app header, contributors section with GitHub avatars, and organized links - -### Fixed - -- **Play Button Flash**: Fixed play button briefly showing red error icon on app start (now uses optimistic rendering) - -### Performance - -- **Optimized State Management**: Use `.select()` for Riverpod providers to prevent unnecessary widget rebuilds -- **List Keys**: Added keys to all list builders for efficient list updates and reordering -- **Request Cancellation**: Outdated API requests are ignored when new search/fetch is triggered -- **Debounced URL Fetches**: All network requests now debounced to prevent rapid duplicate calls -- **Bounded File Cache**: File existence cache now limited to 500 entries to prevent memory leak -- **Timer Cleanup**: Progress polling timer properly disposed when provider is destroyed -- **Stream Error Handling**: Share intent stream now has proper error handling - -## [1.6.1] - 2026-01-02 - -### Added - -- **Background Download Service**: Downloads now continue running when app is in background - - Foreground service with wake lock prevents Android from killing downloads - - Persistent notification shows download progress - - No more "connection abort" errors when switching apps - -### Fixed - -- **Share Intent App Restart**: Fixed download queue being lost when sharing from Spotify while downloads are in progress - - Download queue is now persisted to storage and automatically restored on app restart - - Interrupted downloads (marked as "downloading") are reset to "queued" and auto-resumed - - Changed launch mode to `singleTask` to reuse existing activity instead of restarting - - Added `onNewIntent` handler to properly receive new share intents -- **Back Button During Loading**: Back button no longer clears state while loading shared URL - -### Changed - -- **Kotlin**: Upgraded from 2.2.20 to 2.3.0 for better plugin compatibility - -## [1.6.0] - 2026-01-02 - -### Added - -- **Manual Quality Selection**: New option to choose audio quality before each download - - Toggle "Ask Before Download" in Download Settings - - When enabled, shows quality picker (Lossless, Hi-Res, Hi-Res Max) before downloading - - Works for both single track and batch downloads -- **Live Search**: Search results appear as you type with 400ms debounce - - Animated search bar moves from center to top when typing - - Keyboard stays open during transition - - Back button navigates through search history (album → artist → idle) - - Clear button to reset search - - URLs still require manual submit -- **Search Tab Header**: Added collapsing app bar to centered search view for consistent UI across all tabs -- **Share Audio File**: Share downloaded tracks to other apps from Track Metadata screen - -### Fixed - -- **Update Checker**: Fixed version comparison for versions with suffix (e.g., `1.5.0-hotfix6`) - - Users on hotfix versions now properly receive update notifications - - Handles `-hotfix`, `-beta`, `-rc` suffixes correctly -- **Settings Ripple Effect**: Fixed splash/ripple effect to properly clip within rounded card corners - -### Changed - -- **Settings UI Redesign**: New Android-style grouped settings with connected cards - - Items in same group are connected with rounded card container - - Section headers outside cards for clear visual hierarchy - - Better contrast with white overlay for dark mode dynamic colors -- **Larger Tab Titles**: Increased app bar title size (28px) and height (130px) for better visibility -- **Consistent Header Position**: Fixed Search tab header alignment to match History and Settings tabs - -### Improved - -- **Code Quality**: Replaced all `print()` statements with structured logging using `logger` package -- **Dependencies Updated**: - - `share_plus`: 10.1.4 → 12.0.1 - - `flutter_local_notifications`: 18.0.1 → 19.0.0 - - `build_runner`: 2.4.15 → 2.10.4 - -## [1.5.5] - 2026-01-02 - -### Added - -- **Share to App**: Share Spotify links directly from Spotify app or browser to SpotiFLAC - - Supports track, album, playlist, and artist URLs - - Auto-fetches metadata when link is shared - - Works with both `open.spotify.com` URLs and `spotify:` URIs -- **Lyrics Viewer**: View lyrics for downloaded tracks in Track Metadata screen - - Fetches lyrics from LRCLIB on-demand - - Clean display without timestamps - - Copy lyrics to clipboard -- **Artist URL Support**: Paste artist URL to browse their discography - - Shows all albums, singles, and compilations - - Horizontal scrollable album cards grouped by type - - Tap any album to view and download its tracks -- **Folder Organization**: Organize downloads into folders by artist or album - - Options: None, By Artist, By Album, By Artist & Album - - Configurable in Settings > Download -- **Japanese Lyrics to Romaji**: Auto-convert Hiragana/Katakana lyrics to romaji - - Useful for non-Japanese speakers who want to sing along - - Toggle in Settings > Options > Lyrics - - Kanji characters are preserved (requires dictionary lookup) -- **History View Mode**: Choose between grid or list view for download history - - Grid view shows album art in a 3-column layout (default) - - List view shows detailed track info with date - - Configurable in Settings > Appearance > Layout -- **Exit Confirmation**: Dialog prompt when pressing back to exit app (only at root) - -### Changed - -- **Downloads Tab Renamed to History**: Better reflects the tab's purpose - - Shows download queue at top when active - - Completed downloads auto-move to history section - - Cleaner separation between active downloads and history -- **Smarter Back Navigation**: Back button now navigates properly - - Goes back through search history (album → artist → empty) - - Returns to Search tab from other tabs - - Only shows exit dialog when truly at root - -### Fixed - -- **Download Progress**: Fixed progress stuck at 0% when using item-based progress tracking (affected sequential downloads after multi-download feature was added) -- **Artist View State**: Fixed UI state not clearing properly when switching between artist and album views -- **Share Intent Timing**: Fixed shared URLs not being processed when app was cold-started from share intent - -### Improved - -- **Cleaner UI for Returning Users**: Helper text "Supports: Track, Album, Playlist URLs" now only shows for new users and hides after first search -- **Cleaner Home Tab**: Removed redundant "Recent Downloads" section, renamed to "Search" tab -- **Centered Search Bar**: Search bar now appears centered on screen when empty, moves to top when results are shown - easier to reach on large phones -- **Back Navigation**: Android back button now works as expected - returns to previous view (album → artist → empty search) - -## [1.5.0-hotfix6] - 2026-01-02 - -### Fixed - -- **App Signing**: Use r0adkll/sign-android-release GitHub Action for reliable signing - -## [1.5.0-hotfix5] - 2026-01-02 - -### Fixed - -- **App Signing**: Use key.properties as per Flutter official documentation - -## [1.5.0-hotfix4] - 2026-01-02 - -### Fixed - -- **App Signing**: Create keystore.properties in workflow for Gradle - -## [1.5.0-hotfix] - 2026-01-02 - -### Important Notice - -We apologize for the inconvenience. Previous releases were signed with different keys, causing "package conflicts" errors when upgrading. Starting from this version, all releases will use a consistent signing key. - -**If you're upgrading from v1.5.0 or earlier, please uninstall the app first before installing this version.** This is a one-time requirement. Future updates will work seamlessly without uninstalling. - -### Added - -- **In-App Update**: Download and install updates directly from the app - - Progress bar shows download status - - Automatic device architecture detection (arm64/arm32) - - Downloads correct APK for your device -- **Consistent App Signing**: All future releases will use the same signing key - -### Fixed - -- **Update Checker**: Now downloads APK directly instead of opening browser - -## [1.5.0] - 2026-01-02 - -### Added - -- **Download Progress Notification**: Shows notification with download progress percentage while downloading - - Progress bar in notification during download - - Completion notification when track finishes - - Summary notification when all downloads complete -- **Notification Permission in Setup**: Android 13+ users will be prompted for notification permission during initial setup - - New step in setup wizard for notification permission - - Option to skip if user doesn't want notifications -- **Per-Item Queue Controls**: Each track in download queue now has individual controls - - Cancel button for queued items - - Stop button for currently downloading items - - Retry and Remove buttons for failed/skipped items - - Visual progress bar with percentage for each downloading track -- **Pull-to-Refresh on Home**: Swipe down to clear URL input and fetched tracks - - No need to exit app to clear current search/fetch -- **Multi-Progress Tracking for Concurrent Downloads**: Each concurrent download now shows individual progress percentage - - Previously concurrent downloads jumped from 0% to 100% - - Now each track shows real-time progress when downloading in parallel -- **In-App Update**: Download and install updates directly from the app - - Progress bar shows download status - - Automatic device architecture detection (arm64/arm32) - - Downloads correct APK for your device - -### Changed - -- **Recent Downloads**: Now shows up to 10 items (was 5) for better scrolling -- **Queue UI Redesign**: Card-based layout with clearer status indicators - - Removed global pause/resume in favor of per-item controls - - Better visual hierarchy with cover art, track info, and action buttons -- **Settings UI**: Redesigned with category-based navigation (One UI style) - - Main settings tab with 4 categories: Appearance, Download, Options, About - - Each category opens a detail page - - Large title at top with menu items below - - One-handed friendly layout -- **Collapsing Toolbar**: Implemented One UI style collapsing header for all tabs - - Title animates from 28px (expanded) to 20px (collapsed) - - Back button only on settings detail pages - - Consistent across Home, Downloads, and Settings tabs -- **Home Search Bar Redesign**: More prominent and user-friendly input - - Larger card-style search bar with border outline - - Tap to open bottom sheet with full input experience - - Paste and Search buttons clearly visible - - Helper text showing supported URL types -- **Empty State Improved**: Better onboarding for new users - - "Ready to Download" title with icon - - Clear instructions on how to use the app - - "Add Music" button for quick access - -### Technical - -- Added `flutter_local_notifications` package for notifications -- Added notification permission request in setup screen for Android 13+ -- Enabled core library desugaring for all Android subprojects -- Added multi-progress tracking in Go backend (`ItemProgress`, `ItemProgressWriter`) -- Added `GetAllDownloadProgress`, `InitItemProgress`, `FinishItemProgress`, `ClearItemProgress` exports -- Updated platform channel handlers for both Android (Kotlin) and iOS (Swift) - -### Performance - -- Optimized SliverAppBar: Removed LayoutBuilder that was called every frame during scroll -- Optimized image caching: Added `memCacheWidth/Height` to CachedNetworkImage for memory efficiency -- Optimized state management: Use `select()` to only rebuild when specific state changes -- Smoother animations: Changed to `BouncingScrollPhysics` and `Curves.easeOutCubic` - -## [1.2.0] - 2026-01-02 - -### Added - -- **Track Metadata Screen**: New detailed metadata view when tapping on downloaded tracks - - Material Expressive 3 design with cover art header and gradient - - Hero animation from list to detail view - - Displays: track name, artist, album artist, album, track number, disc number, duration, release date, ISRC, Spotify ID, quality, service, download date - - File info: format (FLAC/M4A), file size, quality badge, service badge with colors - - Tap to copy ISRC and Spotify ID - - "Open in Spotify" button to open track in Spotify app/browser - - File path display with copy functionality - - Play and Delete action buttons -- **Hi-Res Lossless MAX**: New highest quality option for maximum audio fidelity - -### Fixed - -- **Hi-Res Quality Bug**: Fixed issue where Hi-Res downloads were stuck at Lossless quality - - Users on previous versions are recommended to upgrade to get proper Hi-Res downloads -- **Settings Navigation Bug**: Fixed issue where changing settings (like audio quality) would navigate back to Home tab -- **Tidal Badge Color**: Fixed unreadable Tidal service badge (was too bright cyan, now darker blue) - -### Changed - -- **Recent Downloads**: Tapping on a track now opens metadata screen instead of playing directly - - Play button still available for quick playback -- **Download History Model**: Extended with additional metadata fields (albumArtist, isrc, spotifyId, trackNumber, discNumber, duration, releaseDate, quality) -- Removed unused `history_screen.dart` and `history_tab.dart` files - -## [1.1.2] - 2026-01-01 - -### Added - -- **Update Checker**: Automatic check for new versions from GitHub releases - - Shows changelog in update dialog - - Option to disable update notifications -- **Release Changelog**: GitHub releases now include full changelog - -### Changed - -- Updated version to 1.1.2 - -## [1.1.1] - 2026-01-01 - -### Fixed - -- **About Dialog**: Custom About dialog with cleaner layout -- **Setup Screen**: Fixed step indicator line alignment -- **Warning Text**: Fixed parallel downloads warning to use Material theme colors -- **Copyright Year**: Updated to 2026 - -### Changed - -- Removed Theme Preview from Settings -- Added MIT License - -## [1.1.0] - 2026-01-01 - -### Added - -- **Parallel Downloads**: Download up to 3 tracks simultaneously (configurable in Settings) - - Default: Sequential (1 at a time) for stability - - Options: 1, 2, or 3 concurrent downloads - - Warning about potential rate limiting from streaming services -- **Download Progress Tracking**: Real-time progress for BTS manifest downloads from Tidal -- **History Persistence**: Download history now persists across app restarts using SharedPreferences -- **Connection Pooling**: Shared HTTP transport to prevent TCP connection exhaustion during large batch downloads -- **Connection Cleanup**: Automatic cleanup of idle connections every 50 downloads and at queue end - -### Fixed - -- **Download Progress Bug**: Fixed 0% → 100% jump by adding proper progress tracking for BTS format downloads -- **TCP Connection Exhaustion**: Fixed slow downloads after ~300 tracks by implementing connection pooling and periodic cleanup -- **Trailing Space in Names**: Fixed download failures when playlist/album/track names have trailing spaces -- **History Loss on Debug**: History no longer disappears when sideloading via `flutter run --debug` - -### Changed - -- Updated version to 1.1.0 - -### Technical Details - -- Added `concurrentDownloads` field to `AppSettings` model (default: 1, max: 3) -- Implemented worker pool pattern in `DownloadQueueNotifier` for parallel processing -- Added `SetCurrentFile()`, `SetBytesTotal()`, and `ProgressWriter` for BTS downloads in Go backend -- Added `strings.TrimSpace()` to all string fields in `DownloadTrack()` and `DownloadWithFallback()` -- Added shared `http.Transport` with connection pooling in `httputil.go` -- Added `CleanupConnections()` export for Flutter to call via method channel - -## [1.0.5] - Previous Release - -- Material Expressive 3 UI -- Dynamic color support -- Swipe navigation with PageView -- Settings as bottom navigation tab -- APK size optimization +*For older versions, see [GitHub Releases](https://github.com/zarzet/SpotiFLAC-Mobile/releases)* diff --git a/go_backend/deezer.go b/go_backend/deezer.go index 2fb92c58..eee179cd 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -325,6 +325,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp Name: album.Title, ReleaseDate: album.ReleaseDate, Artists: artistName, + ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID), Images: albumImage, Genre: genreStr, // From Deezer album Label: album.Label, // From Deezer album diff --git a/go_backend/exports.go b/go_backend/exports.go index b485ca90..8f8b0476 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1720,6 +1720,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) { "id": album.ID, "name": album.Name, "artists": album.Artists, + "artist_id": album.ArtistID, "cover_url": album.CoverURL, "release_date": album.ReleaseDate, "total_tracks": album.TotalTracks, diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 672a06c9..4f66aeca 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -58,6 +58,7 @@ type ExtAlbumMetadata struct { ID string `json:"id"` Name string `json:"name"` Artists string `json:"artists"` + ArtistID string `json:"artist_id,omitempty"` CoverURL string `json:"cover_url,omitempty"` ReleaseDate string `json:"release_date,omitempty"` TotalTracks int `json:"total_tracks"` diff --git a/go_backend/spotify.go b/go_backend/spotify.go index 65b37143..e212c625 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -170,6 +170,7 @@ type AlbumInfoMetadata struct { Name string `json:"name"` ReleaseDate string `json:"release_date"` Artists string `json:"artists"` + ArtistId string `json:"artist_id,omitempty"` Images string `json:"images"` Genre string `json:"genre,omitempty"` Label string `json:"label,omitempty"` @@ -512,11 +513,19 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s } albumImage := firstImageURL(data.Images) + + // Get first artist ID + var firstArtistId string + if len(data.Artists) > 0 { + firstArtistId = data.Artists[0].ID + } + info := AlbumInfoMetadata{ TotalTracks: data.TotalTracks, Name: data.Name, ReleaseDate: data.ReleaseDate, Artists: joinArtists(data.Artists), + ArtistId: firstArtistId, Images: albumImage, } diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb index b451b4a5..f9e67d0b 100644 --- a/lib/l10n/arb/app_es_ES.arb +++ b/lib/l10n/arb/app_es_ES.arb @@ -85,7 +85,7 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, one {}=1{1 pista} other{{count} pistas}}", + "historyTracksCount": "{count, plural, =1{1 pista} other{{count} pistas}}", "@historyTracksCount": { "description": "Track count with plural form", "placeholders": { @@ -94,7 +94,7 @@ } } }, - "historyAlbumsCount": "{count, plural, one {}=1{1 álbum} other{{count} álbumes}}", + "historyAlbumsCount": "{count, plural, =1{1 álbum} other{{count} álbumes}}", "@historyAlbumsCount": { "description": "Album count with plural form", "placeholders": { @@ -596,7 +596,7 @@ "@albumTitle": { "description": "Album screen title" }, - "albumTracks": "{count, plural, one {}=1{1 pista} other{{count} pistas}}", + "albumTracks": "{count, plural, =1{1 pista} other{{count} pistas}}", "@albumTracks": { "description": "Album track count", "placeholders": { @@ -633,7 +633,7 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, one {}=1{1 lanzamiento} other{{count} lanzamientos}}", + "artistReleases": "{count, plural, =1{1 lanzamiento} other{{count} lanzamientos}}", "@artistReleases": { "description": "Artist release count", "placeholders": { @@ -1108,7 +1108,7 @@ "@dialogDeleteSelectedTitle": { "description": "Dialog title - delete selected items" }, - "dialogDeleteSelectedMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.", + "dialogDeleteSelectedMessage": "¿Eliminar {count} {count, plural, =1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.", "@dialogDeleteSelectedMessage": { "description": "Dialog message - delete selected tracks", "placeholders": { @@ -1169,7 +1169,7 @@ "@snackbarCredentialsCleared": { "description": "Snackbar - Spotify credentials removed" }, - "snackbarDeletedTracks": "Eliminado {count} {count, plural, one {}=1{pista} other{pistas}}", + "snackbarDeletedTracks": "Eliminado {count} {count, plural, =1{pista} other{pistas}}", "@snackbarDeletedTracks": { "description": "Snackbar - tracks deleted", "placeholders": { @@ -1376,7 +1376,7 @@ "@selectionTapToSelect": { "description": "Hint - how to select items" }, - "selectionDeleteTracks": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}", + "selectionDeleteTracks": "¡Eliminar {count} {count, plural, =1{pista} other{pistas}}", "@selectionDeleteTracks": { "description": "Delete button with count", "placeholders": { @@ -1916,7 +1916,7 @@ } } }, - "tracksCount": "{count, plural, one {}=1{1 pista} other{{count} pistas}}", + "tracksCount": "{count, plural, =1{1 pista} other{{count} pistas}}", "@tracksCount": { "description": "Track count display", "placeholders": { @@ -2520,7 +2520,7 @@ "@downloadedAlbumDeleteSelected": { "description": "Button - delete selected tracks" }, - "downloadedAlbumDeleteMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.", + "downloadedAlbumDeleteMessage": "¿Eliminar {count} {count, plural, =1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.", "@downloadedAlbumDeleteMessage": { "description": "Delete confirmation with count", "placeholders": { @@ -2559,7 +2559,7 @@ "@downloadedAlbumTapToSelect": { "description": "Selection hint" }, - "downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}", + "downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, =1{pista} other{pistas}}", "@downloadedAlbumDeleteCount": { "description": "Delete button text with count", "placeholders": { diff --git a/lib/l10n/arb/app_pt_PT.arb b/lib/l10n/arb/app_pt_PT.arb index cc4dbc02..7f8d81a2 100644 --- a/lib/l10n/arb/app_pt_PT.arb +++ b/lib/l10n/arb/app_pt_PT.arb @@ -85,7 +85,7 @@ "@historyFilterSingles": { "description": "Filter chip - show singles only" }, - "historyTracksCount": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}", + "historyTracksCount": "{count, plural, =1{1 faixa} other{{count} faixas}}", "@historyTracksCount": { "description": "Track count with plural form", "placeholders": { @@ -94,7 +94,7 @@ } } }, - "historyAlbumsCount": "{count, plural, one {}=1{1 álbum} other{{count} álbuns}}", + "historyAlbumsCount": "{count, plural, =1{1 álbum} other{{count} álbuns}}", "@historyAlbumsCount": { "description": "Album count with plural form", "placeholders": { @@ -596,7 +596,7 @@ "@albumTitle": { "description": "Album screen title" }, - "albumTracks": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}", + "albumTracks": "{count, plural, =1{1 faixa} other{{count} faixas}}", "@albumTracks": { "description": "Album track count", "placeholders": { @@ -633,7 +633,7 @@ "@artistCompilations": { "description": "Section header for compilations" }, - "artistReleases": "{count, plural, one {}=1{1 lançamento} other{{count} lançamentos}}", + "artistReleases": "{count, plural, =1{1 lançamento} other{{count} lançamentos}}", "@artistReleases": { "description": "Artist release count", "placeholders": { @@ -1376,7 +1376,7 @@ "@selectionTapToSelect": { "description": "Hint - how to select items" }, - "selectionDeleteTracks": "Apagar {count} {count, plural, one {}=1{faixa} other{faixas}}", + "selectionDeleteTracks": "Apagar {count} {count, plural, =1{faixa} other{faixas}}", "@selectionDeleteTracks": { "description": "Delete button with count", "placeholders": { @@ -1916,7 +1916,7 @@ } } }, - "tracksCount": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}", + "tracksCount": "{count, plural, =1{1 faixa} other{{count} faixas}}", "@tracksCount": { "description": "Track count display", "placeholders": { diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 34c36a6f..f2d176d7 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -12,6 +12,8 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/recent_access_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/screens/artist_screen.dart'; +import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionArtistScreen; class _AlbumCache { static final Map _cache = {}; @@ -43,6 +45,9 @@ class AlbumScreen extends ConsumerStatefulWidget { final String albumName; final String? coverUrl; final List? tracks; // Optional - will fetch if null + final String? extensionId; // If from extension + final String? artistId; // Artist ID for navigation + final String? artistName; // Artist name for navigation const AlbumScreen({ super.key, @@ -50,6 +55,9 @@ class AlbumScreen extends ConsumerStatefulWidget { required this.albumName, this.coverUrl, this.tracks, + this.extensionId, + this.artistId, + this.artistName, }); @override @@ -62,6 +70,7 @@ class _AlbumScreenState extends ConsumerState { String? _error; Color? _dominantColor; bool _showTitleInAppBar = false; + String? _artistId; final ScrollController _scrollController = ScrollController(); @override @@ -78,10 +87,12 @@ class _AlbumScreenState extends ConsumerState { artistName: widget.tracks?.firstOrNull?.artistName, imageUrl: widget.coverUrl, providerId: providerId, - ); +); }); _tracks = widget.tracks ?? _AlbumCache.get(widget.albumId); + _artistId = widget.artistId; // Use provided artist ID if available + if (_tracks == null) { _fetchTracks(); } @@ -103,7 +114,7 @@ class _AlbumScreenState extends ConsumerState { } } - Future _extractDominantColor() async { +Future _extractDominantColor() async { if (widget.coverUrl == null) return; final color = await PaletteService.instance.extractDominantColor(widget.coverUrl); if (mounted && color != null) { @@ -111,7 +122,25 @@ class _AlbumScreenState extends ConsumerState { } } - Future _fetchTracks() async { + String _formatReleaseDate(String date) { + // Handle formats: "2024-01-15", "2024-01", "2024" + if (date.length >= 10) { + // Full date: 2024-01-15 + final parts = date.substring(0, 10).split('-'); + if (parts.length == 3) { + return '${parts[2]}/${parts[1]}/${parts[0]}'; // DD/MM/YYYY + } + } else if (date.length >= 7) { + // Month: 2024-01 + final parts = date.split('-'); + if (parts.length >= 2) { + return '${parts[1]}/${parts[0]}'; // MM/YYYY + } + } + return date; // Year only or unknown format + } + +Future _fetchTracks() async { setState(() => _isLoading = true); try { Map metadata; @@ -127,11 +156,16 @@ class _AlbumScreenState extends ConsumerState { final trackList = metadata['track_list'] as List; final tracks = trackList.map((t) => _parseTrack(t as Map)).toList(); + // Extract artist ID from album_info if available + final albumInfo = metadata['album_info'] as Map?; + final artistId = albumInfo?['artist_id'] as String?; + _AlbumCache.set(widget.albumId, tracks); if (mounted) { setState(() { _tracks = tracks; + _artistId = artistId; _isLoading = false; }); } @@ -300,9 +334,10 @@ class _AlbumScreenState extends ConsumerState { ); } - Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { +Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { final tracks = _tracks ?? []; final artistName = tracks.isNotEmpty ? tracks.first.artistName : null; + final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null; return SliverToBoxAdapter( child: Padding( @@ -322,32 +357,59 @@ class _AlbumScreenState extends ConsumerState { ), if (artistName != null && artistName.isNotEmpty) ...[ const SizedBox(height: 4), - Text( - artistName, - style: Theme.of(context).textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant), + GestureDetector( + onTap: () => _navigateToArtist(context, artistName), + child: Text( + artistName, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colorScheme.primary, + ), + ), ), ], const SizedBox(height: 12), if (tracks.isNotEmpty) - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer), - const SizedBox(width: 4), - Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), - ], - ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer), + const SizedBox(width: 4), + Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), + ], + ), + ), + if (releaseDate != null && releaseDate.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.calendar_today, size: 14, color: colorScheme.onTertiaryContainer), + const SizedBox(width: 4), + Text(_formatReleaseDate(releaseDate), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), + ], + ), + ), + ], ), if (tracks.isNotEmpty) ...[ const SizedBox(height: 16), FilledButton.icon( onPressed: () => _downloadAll(context), - icon: const Icon(Icons.download), + icon: const Icon(Icons.download, size: 18), label: Text(context.l10n.downloadAllCount(tracks.length)), - style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), ), ], ], @@ -426,10 +488,51 @@ class _AlbumScreenState extends ConsumerState { ); } else { ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)))); +ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)))); } } + void _navigateToArtist(BuildContext context, String artistName) { + // Use stored artist ID if available, otherwise use a placeholder + final artistId = _artistId ?? + (widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown'); + + // Don't navigate if artist ID is unknown + if (artistId == 'unknown' || artistId == 'deezer:unknown' || artistId.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Artist information not available')), + ); + return; + } + + // If from extension, use ExtensionArtistScreen + if (widget.extensionId != null) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ExtensionArtistScreen( + extensionId: widget.extensionId!, + artistId: artistId, + artistName: artistName, + coverUrl: widget.coverUrl, + ), + ), + ); + return; + } + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ArtistScreen( + artistId: artistId, + artistName: artistName, + coverUrl: widget.coverUrl, + ), + ), + ); + } + Widget _buildErrorWidget(String error, ColorScheme colorScheme) { final isRateLimit = error.contains('429') || error.toLowerCase().contains('rate limit') || @@ -524,11 +627,20 @@ class _AlbumTrackItem extends ConsumerWidget { elevation: 0, color: Colors.transparent, margin: const EdgeInsets.symmetric(vertical: 2), - child: ListTile( +child: ListTile( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), -leading: track.coverUrl != null - ? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96, cacheManager: CoverCacheManager.instance)) - : Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)), + leading: SizedBox( + width: 32, + child: Center( + child: Text( + '${track.trackNumber ?? 0}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ), + ), title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)), subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)), trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress), diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 3f57539f..adc5917c 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -726,7 +726,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Text( - greeting!, + greeting, style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, ), @@ -1230,6 +1230,8 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient Widget _buildRecentAccessItem(RecentAccessItem item, ColorScheme colorScheme) { IconData typeIcon; String typeLabel; + final isDownloaded = item.providerId == 'download'; + switch (item.type) { case RecentAccessType.artist: typeIcon = Icons.person; @@ -1293,11 +1295,13 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), const SizedBox(height: 4), Text( - item.subtitle != null ? '$typeLabel • ${item.subtitle}' : typeLabel, + isDownloaded + ? (item.subtitle != null ? '${context.l10n.recentTypeSong} • ${item.subtitle}' : context.l10n.recentTypeSong) + : (item.subtitle != null ? '$typeLabel • ${item.subtitle}' : typeLabel), maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + color: isDownloaded ? colorScheme.primary : colorScheme.onSurfaceVariant, ), ), ], @@ -2441,6 +2445,8 @@ class _ExtensionAlbumScreenState extends ConsumerState { List? _tracks; bool _isLoading = true; String? _error; + String? _artistId; + String? _artistName; @override void initState() { @@ -2480,8 +2486,14 @@ class _ExtensionAlbumScreenState extends ConsumerState { final tracks = trackList.map((t) => _parseTrack(t as Map)).toList(); + // Extract artist info from album response + final artistId = result['artist_id'] as String?; + final artistName = result['artists'] as String?; + setState(() { _tracks = tracks; + _artistId = artistId; + _artistName = artistName; _isLoading = false; }); } catch (e) { @@ -2550,6 +2562,9 @@ class _ExtensionAlbumScreenState extends ConsumerState { albumName: widget.albumName, coverUrl: widget.coverUrl, tracks: _tracks, + extensionId: widget.extensionId, + artistId: _artistId, + artistName: _artistName, ); } } @@ -2933,7 +2948,7 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> { shape: BoxShape.circle, color: isActive ? widget.colorScheme.primary - : widget.colorScheme.onSurfaceVariant.withOpacity(0.3), + : widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.3), ), ); }), diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 51b2f969..25fbfee5 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -215,12 +215,15 @@ class _PlaylistScreenState extends ConsumerState { ], ), ), - const SizedBox(height: 16), +const SizedBox(height: 16), FilledButton.icon( onPressed: () => _downloadAll(context), - icon: const Icon(Icons.download), + icon: const Icon(Icons.download, size: 18), label: Text(context.l10n.downloadAllCount(widget.tracks.length)), - style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), ), ], ), From 966935b677bceb7a3c7098fb89509e8675963b2d Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 21 Jan 2026 12:22:46 +0700 Subject: [PATCH 11/19] feat: add missing platform bridge functions for batch duplicate check and cross-platform IDs --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 67 +++++++++++++++++++ ios/Runner/AppDelegate.swift | 66 ++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 85a32ffe..f6c2e457 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -139,6 +139,28 @@ class MainActivity: FlutterActivity() { } result.success(response) } + "checkDuplicatesBatch" -> { + val outputDir = call.argument("output_dir") ?: "" + val tracksJson = call.argument("tracks") ?: "[]" + val response = withContext(Dispatchers.IO) { + Gobackend.checkDuplicatesBatch(outputDir, tracksJson) + } + result.success(response) + } + "preBuildDuplicateIndex" -> { + val outputDir = call.argument("output_dir") ?: "" + withContext(Dispatchers.IO) { + Gobackend.preBuildDuplicateIndex(outputDir) + } + result.success(null) + } + "invalidateDuplicateIndex" -> { + val outputDir = call.argument("output_dir") ?: "" + withContext(Dispatchers.IO) { + Gobackend.invalidateDuplicateIndex(outputDir) + } + result.success(null) + } "buildFilename" -> { val template = call.argument("template") ?: "" val metadata = call.argument("metadata") ?: "{}" @@ -306,6 +328,43 @@ class MainActivity: FlutterActivity() { } result.success(response) } + "checkAvailabilityFromDeezerID" -> { + val deezerTrackId = call.argument("deezer_track_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.checkAvailabilityFromDeezerID(deezerTrackId) + } + result.success(response) + } + "checkAvailabilityByPlatformID" -> { + val platform = call.argument("platform") ?: "" + val entityType = call.argument("entity_type") ?: "" + val entityId = call.argument("entity_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.checkAvailabilityByPlatformID(platform, entityType, entityId) + } + result.success(response) + } + "getSpotifyIDFromDeezerTrack" -> { + val deezerTrackId = call.argument("deezer_track_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getSpotifyIDFromDeezerTrack(deezerTrackId) + } + result.success(response) + } + "getTidalURLFromDeezerTrack" -> { + val deezerTrackId = call.argument("deezer_track_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getTidalURLFromDeezerTrack(deezerTrackId) + } + result.success(response) + } + "getAmazonURLFromDeezerTrack" -> { + val deezerTrackId = call.argument("deezer_track_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getAmazonURLFromDeezerTrack(deezerTrackId) + } + result.success(response) + } // Log methods "getLogs" -> { val response = withContext(Dispatchers.IO) { @@ -468,6 +527,14 @@ class MainActivity: FlutterActivity() { } result.success(response) } + "enrichTrackWithExtension" -> { + val extensionId = call.argument("extension_id") ?: "" + val trackJson = call.argument("track") ?: "{}" + val response = withContext(Dispatchers.IO) { + Gobackend.enrichTrackWithExtensionJSON(extensionId, trackJson) + } + result.success(response) + } "removeExtension" -> { val extensionId = call.argument("extension_id") ?: "" withContext(Dispatchers.IO) { diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 31538db2..94809a20 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -142,6 +142,27 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + case "checkDuplicatesBatch": + let args = call.arguments as! [String: Any] + let outputDir = args["output_dir"] as! String + let tracksJson = args["tracks"] as? String ?? "[]" + let response = GobackendCheckDuplicatesBatch(outputDir, tracksJson, &error) + if let error = error { throw error } + return response + + case "preBuildDuplicateIndex": + let args = call.arguments as! [String: Any] + let outputDir = args["output_dir"] as! String + GobackendPreBuildDuplicateIndex(outputDir, &error) + if let error = error { throw error } + return nil + + case "invalidateDuplicateIndex": + let args = call.arguments as! [String: Any] + let outputDir = args["output_dir"] as! String + GobackendInvalidateDuplicateIndex(outputDir) + return nil + case "buildFilename": let args = call.arguments as! [String: Any] let template = args["template"] as! String @@ -249,6 +270,43 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + case "checkAvailabilityFromDeezerID": + let args = call.arguments as! [String: Any] + let deezerTrackId = args["deezer_track_id"] as! String + let response = GobackendCheckAvailabilityFromDeezerID(deezerTrackId, &error) + if let error = error { throw error } + return response + + case "checkAvailabilityByPlatformID": + let args = call.arguments as! [String: Any] + let platform = args["platform"] as! String + let entityType = args["entity_type"] as! String + let entityId = args["entity_id"] as! String + let response = GobackendCheckAvailabilityByPlatformID(platform, entityType, entityId, &error) + if let error = error { throw error } + return response + + case "getSpotifyIDFromDeezerTrack": + let args = call.arguments as! [String: Any] + let deezerTrackId = args["deezer_track_id"] as! String + let response = GobackendGetSpotifyIDFromDeezerTrack(deezerTrackId, &error) + if let error = error { throw error } + return response + + case "getTidalURLFromDeezerTrack": + let args = call.arguments as! [String: Any] + let deezerTrackId = args["deezer_track_id"] as! String + let response = GobackendGetTidalURLFromDeezerTrack(deezerTrackId, &error) + if let error = error { throw error } + return response + + case "getAmazonURLFromDeezerTrack": + let args = call.arguments as! [String: Any] + let deezerTrackId = args["deezer_track_id"] as! String + let response = GobackendGetAmazonURLFromDeezerTrack(deezerTrackId, &error) + if let error = error { throw error } + return response + case "preWarmTrackCache": let args = call.arguments as! [String: Any] let tracksJson = args["tracks"] as! String @@ -404,6 +462,14 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + case "enrichTrackWithExtension": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let trackJson = args["track"] as? String ?? "{}" + let response = GobackendEnrichTrackWithExtensionJSON(extensionId, trackJson, &error) + if let error = error { throw error } + return response + case "removeExtension": let args = call.arguments as! [String: Any] let extensionId = args["extension_id"] as! String From c01b18947771f39772042390ac7f53550d383dc4 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 21 Jan 2026 13:04:48 +0700 Subject: [PATCH 12/19] fix: discography download context issue after quality picker closes --- lib/screens/artist_screen.dart | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 32e8d784..8748ad90 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -554,11 +554,11 @@ return PopScope( DownloadServicePicker.show( context, onSelect: (quality, service) { - _fetchAndQueueAlbums(context, albums, service, quality); + _fetchAndQueueAlbums(albums, service, quality); }, ); } else { - _fetchAndQueueAlbums(context, albums, settings.defaultService, null); + _fetchAndQueueAlbums(albums, settings.defaultService, null); } } @@ -568,7 +568,6 @@ return PopScope( } Future _fetchAndQueueAlbums( - BuildContext context, List albums, String service, String? qualityOverride, @@ -578,7 +577,10 @@ return PopScope( setState(() => _isFetchingDiscography = true); // Show progress dialog - if (!context.mounted) return; + if (!mounted) { + setState(() => _isFetchingDiscography = false); + return; + } showDialog( context: context, @@ -610,7 +612,7 @@ return PopScope( fetchedCount++; // Update progress dialog - if (context.mounted) { + if (mounted) { _FetchingProgressDialog.updateProgress(context, fetchedCount, albums.length); } } @@ -618,19 +620,19 @@ return PopScope( setState(() => _isFetchingDiscography = false); // Close progress dialog - if (context.mounted) { + if (mounted) { Navigator.of(context, rootNavigator: true).pop(); } // Show warning if some albums failed - if (failedCount > 0 && context.mounted) { + if (failedCount > 0 && mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.discographyFailedToFetch)), ); } if (allTracks.isEmpty) { - if (context.mounted) { + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.discographyNoAlbums)), ); @@ -655,7 +657,7 @@ return PopScope( } if (tracksToQueue.isEmpty) { - if (context.mounted) { + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(context.l10n.discographySkippedDownloaded(0, skippedCount)), @@ -673,7 +675,7 @@ return PopScope( ); // Show success message - if (context.mounted) { + if (mounted) { final message = skippedCount > 0 ? context.l10n.discographySkippedDownloaded(tracksToQueue.length, skippedCount) : context.l10n.discographyAddedToQueue(tracksToQueue.length); From 46afa6e733a19cacc305d4e9dcf7e59e10a24afb Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 21 Jan 2026 13:30:35 +0700 Subject: [PATCH 13/19] fix: use HTML parse mode for Telegram notifications to handle special chars --- .github/workflows/release.yml | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8d64d08..87f0ad11 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -446,15 +446,19 @@ jobs: if [ -z "$FULL_CHANGELOG" ]; then CHANGELOG="See release notes on GitHub for details." else - # Convert GitHub Markdown to Telegram Markdown: - # - **text** → *text* (GitHub bold to Telegram bold) - # - ### Header → *Header* (headers to bold) - # - Add extra line break before major list items for readability + # Convert GitHub Markdown to Telegram HTML: + # - **text** → text + # - `code` → code + # - ### Header → Header + # - Escape HTML special chars first CHANGELOG=$(echo "$FULL_CHANGELOG" | \ - sed 's/\*\*\([^*]*\)\*\*/*\1*/g' | \ - sed 's/^### \(.*\)$/*\1*/g' | \ - sed 's/^## \(.*\)$/*\1*/g' | \ - sed 's/^- \*\*\([^:]*\):\*\*/\n• *\1:*/g' | \ + sed 's/&/\&/g' | \ + sed 's//\>/g' | \ + sed 's/`\([^`]*\)`/\1<\/code>/g' | \ + sed 's/\*\*\([^*]*\)\*\*/\1<\/b>/g' | \ + sed 's/^### \(.*\)$/\1<\/b>/g' | \ + sed 's/^## \(.*\)$/\1<\/b>/g' | \ sed 's/^- /• /g' | \ sed 's/^ - / ◦ /g') @@ -482,23 +486,23 @@ jobs: ARM64_APK=$(find ./release -name "*arm64*.apk" | head -1) ARM32_APK=$(find ./release -name "*arm32*.apk" | head -1) - # Prepare message with changelog (files uploaded separately) + # Prepare message with changelog (HTML format) printf '%s\n' \ - "*SpotiFLAC Mobile ${VERSION} Released!*" \ + "SpotiFLAC Mobile ${VERSION} Released!" \ "" \ - "*What's New:*" \ + "What's New:" \ "${CHANGELOG}" \ "" \ - "[View Release Notes](https://github.com/${{ github.repository }}/releases/tag/${VERSION})" \ + "View Release Notes" \ > /tmp/telegram_message.txt MESSAGE=$(cat /tmp/telegram_message.txt) - # Send message first + # Send message first (using HTML parse mode) curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ -d chat_id="${TELEGRAM_CHANNEL_ID}" \ -d text="${MESSAGE}" \ - -d parse_mode="Markdown" \ + -d parse_mode="HTML" \ -d disable_web_page_preview="true" # Upload arm64 APK to channel From b627ae18742b8ab72708fdf8912384f4d0d021cf Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 21 Jan 2026 16:23:19 +0700 Subject: [PATCH 14/19] fix: handle CRLF in changelog extraction for Telegram --- .github/workflows/release.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87f0ad11..301225e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -441,7 +441,11 @@ jobs: VERSION_NUM=${VERSION#v} # Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead) - FULL_CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md | sed '/^---$/d') + # Use tr -d '\r' to handle CRLF line endings from Windows + FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d') + + echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}" + echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}" if [ -z "$FULL_CHANGELOG" ]; then CHANGELOG="See release notes on GitHub for details." @@ -451,7 +455,9 @@ jobs: # - `code` → code # - ### Header → Header # - Escape HTML special chars first + # - Remove > blockquote prefix CHANGELOG=$(echo "$FULL_CHANGELOG" | \ + sed 's/^> //' | \ sed 's/&/\&/g' | \ sed 's//\>/g' | \ @@ -473,6 +479,8 @@ jobs: fi echo "$CHANGELOG" > /tmp/changelog.txt + echo "DEBUG: Final changelog:" + cat /tmp/changelog.txt - name: Send to Telegram Channel env: From aa35f60fad9633b99148a81f9dc6b836614ff8a0 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 21 Jan 2026 16:33:30 +0700 Subject: [PATCH 15/19] fix: fallback to index+1 for Deezer track position when API returns 0 --- go_backend/deezer.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/go_backend/deezer.go b/go_backend/deezer.go index eee179cd..311080cd 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -340,10 +340,16 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp albumType = "compilation" } - for _, track := range album.Tracks.Data { + for i, track := range album.Tracks.Data { trackIDStr := fmt.Sprintf("%d", track.ID) isrc := isrcMap[trackIDStr] + // Use track position from API, fallback to index+1 if not provided + trackNum := track.TrackPosition + if trackNum == 0 { + trackNum = i + 1 + } + tracks = append(tracks, AlbumTrackMetadata{ SpotifyID: fmt.Sprintf("deezer:%d", track.ID), Artists: track.Artist.Name, @@ -353,7 +359,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp DurationMS: track.Duration * 1000, Images: albumImage, ReleaseDate: album.ReleaseDate, - TrackNumber: track.TrackPosition, + TrackNumber: trackNum, TotalTracks: album.NbTracks, DiscNumber: track.DiskNumber, ExternalURL: track.Link, From 8d205600b84bdc4c6386e6cd84e43e3336850699 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 22 Jan 2026 00:48:45 +0700 Subject: [PATCH 16/19] fix: iOS path migration, local greeting timezone, ICU plural warnings - iOS: Auto-migrate file paths when container UUID changes after app update - Greeting: Use device local time instead of extension response - i18n: Fix 16 ICU plural syntax warnings in Spanish and Portuguese --- CHANGELOG.md | 13 +++ lib/providers/download_queue_provider.dart | 8 ++ lib/providers/explore_provider.dart | 21 +++- lib/services/history_database.dart | 107 ++++++++++++++++++++- 4 files changed, 147 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73750406..224ead95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [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). + +### 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 + +--- + ## [3.2.0] - 2026-01-22 > **Note:** Starting from v3.2.0, changelogs will be concise. diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index b95c05bb..2af7c32c 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -193,6 +193,14 @@ class DownloadHistoryNotifier extends Notifier { _historyLog.i('Migrated history from SharedPreferences to SQLite'); } + // Migrate iOS paths if container UUID changed after app update + if (Platform.isIOS) { + final pathsMigrated = await _db.migrateIosContainerPaths(); + if (pathsMigrated) { + _historyLog.i('Migrated iOS container paths after app update'); + } + } + final jsonList = await _db.getAll(); final items = jsonList .map((e) => DownloadHistoryItem.fromJson(e)) diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart index 63cf137c..3256542a 100644 --- a/lib/providers/explore_provider.dart +++ b/lib/providers/explore_provider.dart @@ -109,6 +109,20 @@ class ExploreState { } } +/// Calculate greeting based on local device time +String _getLocalGreeting() { + final hour = DateTime.now().hour; + if (hour >= 5 && hour < 12) { + return 'Good morning'; + } else if (hour >= 12 && hour < 17) { + return 'Good afternoon'; + } else if (hour >= 17 && hour < 21) { + return 'Good evening'; + } else { + return 'Good night'; + } +} + /// Provider for explore/home feed state class ExploreNotifier extends Notifier { @override @@ -201,9 +215,14 @@ class ExploreNotifier extends Notifier { _log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}'); } + // Always use local device time for greeting to avoid timezone issues + // Extension greeting may use wrong timezone (UTC or Spotify account timezone) + final localGreeting = _getLocalGreeting(); + _log.d('Greeting from extension: $greeting, using local: $localGreeting'); + state = ExploreState( isLoading: false, - greeting: greeting, + greeting: localGreeting, sections: sections, lastFetched: DateTime.now(), ); diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index fc2df58e..1cf1088d 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; @@ -7,6 +8,9 @@ import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('HistoryDatabase'); +/// Cached current iOS container path for path normalization +String? _currentContainerPath; + /// SQLite database service for download history /// Provides O(1) lookups by spotify_id and isrc with proper indexing class HistoryDatabase { @@ -78,6 +82,106 @@ class HistoryDatabase { // Future migrations go here } + // ==================== iOS Path Normalization ==================== + + /// Pattern to match iOS container paths + /// Example: /var/mobile/Containers/Data/Application/UUID-HERE/Documents/... + static final _iosContainerPattern = RegExp( + r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/', + caseSensitive: false, + ); + + /// Initialize and cache the current iOS container path + Future _initContainerPath() async { + if (!Platform.isIOS || _currentContainerPath != null) return; + + try { + final docDir = await getApplicationDocumentsDirectory(); + // Extract container path up to and including the UUID folder + // e.g., /var/mobile/Containers/Data/Application/UUID/ + final match = _iosContainerPattern.firstMatch(docDir.path); + if (match != null) { + _currentContainerPath = match.group(0); + _log.d('iOS container path: $_currentContainerPath'); + } + } catch (e) { + _log.w('Failed to get iOS container path: $e'); + } + } + + /// Normalize iOS file path by replacing old container UUID with current one + /// This fixes the issue where iOS changes container UUID after app updates + String _normalizeIosPath(String? filePath) { + if (filePath == null || filePath.isEmpty) return filePath ?? ''; + if (!Platform.isIOS || _currentContainerPath == null) return filePath; + + // Check if path contains an iOS container path + if (_iosContainerPattern.hasMatch(filePath)) { + final normalized = filePath.replaceFirst(_iosContainerPattern, _currentContainerPath!); + if (normalized != filePath) { + _log.d('Normalized iOS path: $filePath -> $normalized'); + } + return normalized; + } + + return filePath; + } + + /// Migrate iOS paths in database to use current container UUID + /// This is called once after app update if container changed + Future migrateIosContainerPaths() async { + if (!Platform.isIOS) return false; + + await _initContainerPath(); + if (_currentContainerPath == null) return false; + + final prefs = await SharedPreferences.getInstance(); + final lastContainer = prefs.getString('ios_last_container_path'); + + // Skip if container hasn't changed + if (lastContainer == _currentContainerPath) { + _log.d('iOS container path unchanged, skipping migration'); + return false; + } + + _log.i('iOS container changed: $lastContainer -> $_currentContainerPath'); + + try { + final db = await database; + + // Get all items with iOS paths + final rows = await db.query('history', columns: ['id', 'file_path']); + int updatedCount = 0; + + for (final row in rows) { + final id = row['id'] as String; + final oldPath = row['file_path'] as String?; + + if (oldPath != null && _iosContainerPattern.hasMatch(oldPath)) { + final newPath = _normalizeIosPath(oldPath); + if (newPath != oldPath) { + await db.update( + 'history', + {'file_path': newPath}, + where: 'id = ?', + whereArgs: [id], + ); + updatedCount++; + } + } + } + + // Save current container path + await prefs.setString('ios_last_container_path', _currentContainerPath!); + + _log.i('iOS path migration complete: $updatedCount paths updated'); + return updatedCount > 0; + } catch (e, stack) { + _log.e('iOS path migration failed: $e', e, stack); + return false; + } + } + /// Migrate data from SharedPreferences to SQLite /// Returns true if migration was performed, false if already migrated Future migrateFromSharedPreferences() async { @@ -153,6 +257,7 @@ class HistoryDatabase { } /// Convert DB row (snake_case) to JSON format (camelCase) + /// Also normalizes iOS paths if container UUID changed Map _dbRowToJson(Map row) { return { 'id': row['id'], @@ -161,7 +266,7 @@ class HistoryDatabase { 'albumName': row['album_name'], 'albumArtist': row['album_artist'], 'coverUrl': row['cover_url'], - 'filePath': row['file_path'], + 'filePath': _normalizeIosPath(row['file_path'] as String?), 'service': row['service'], 'downloadedAt': row['downloaded_at'], 'isrc': row['isrc'], From f6cea1a68333c1f5d51697040d4d0093e5e73ee2 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 22 Jan 2026 02:15:43 +0700 Subject: [PATCH 17/19] feat: v3.2.1 - lyrics improvements, pause/resume, folder options - Add instrumental track detection (shows 'Instrumental track' instead of 'not available') - Add embed lyrics button in Track Info (preserves synced timestamps) - Add pause/resume button next to 'Downloading' header in History - Add Artist/Album + Singles folder structure option - Fix multi-artist lyrics search (try primary artist first) - Fix lyrics display stripping metadata tags ([ti:], [ar:], [by:]) - Skip lyrics embedding for instrumental tracks during download --- CHANGELOG.md | 21 ++- go_backend/exports.go | 25 ++- go_backend/lyrics.go | 58 +++++-- lib/l10n/app_localizations.dart | 30 ++++ lib/l10n/app_localizations_de.dart | 16 ++ lib/l10n/app_localizations_en.dart | 16 ++ lib/l10n/app_localizations_es.dart | 16 ++ lib/l10n/app_localizations_fr.dart | 16 ++ lib/l10n/app_localizations_hi.dart | 16 ++ lib/l10n/app_localizations_id.dart | 16 ++ lib/l10n/app_localizations_ja.dart | 16 ++ lib/l10n/app_localizations_ko.dart | 16 ++ lib/l10n/app_localizations_nl.dart | 16 ++ lib/l10n/app_localizations_pt.dart | 16 ++ lib/l10n/app_localizations_ru.dart | 16 ++ lib/l10n/app_localizations_tr.dart | 16 ++ lib/l10n/app_localizations_zh.dart | 16 ++ lib/l10n/arb/app_en.arb | 10 ++ lib/providers/download_queue_provider.dart | 22 ++- lib/screens/queue_tab.dart | 41 ++++- .../settings/download_settings_page.dart | 12 ++ lib/screens/track_metadata_screen.dart | 160 ++++++++++++++++-- 22 files changed, 544 insertions(+), 43 deletions(-) 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); } From 55b75dc48de0ef97bfc5377d9b4648b2eb7c9d4c Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 22 Jan 2026 02:17:47 +0700 Subject: [PATCH 18/19] chore: bump version to 3.2.1+64 --- lib/constants/app_info.dart | 4 ++-- pubspec.yaml | 2 +- pubspec_ios.yaml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index d300dca4..841ab631 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { -static const String version = '3.2.0'; - static const String buildNumber = '63'; +static const String version = '3.2.1'; + static const String buildNumber = '64'; static const String fullVersion = '$version+$buildNumber'; diff --git a/pubspec.yaml b/pubspec.yaml index 5c2fddef..2481b533 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.2.0+63 +version: 3.2.1+64 environment: sdk: ^3.10.0 diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index 4e806acf..37bb3b5a 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.2.0+63 +version: 3.2.1+64 environment: sdk: ^3.10.0 From 6388f3a5b8f36be4ba5f21e10a4898d345d9da64 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 22 Jan 2026 03:51:24 +0700 Subject: [PATCH 19/19] perf: optimize providers, caching, and reduce rebuilds - Cache SharedPreferences.getInstance() in providers (settings, theme, recent_access) - Pre-compute download counts in queue provider to avoid repeated filtering - Add identical() caching for RecentAccessView in HomeTab - Use selective watching for exploreProvider (sections, greeting, isLoading only) - Move isYTMusicQuickPicks computation to ExploreSection.fromJson() - Hoist static RegExp patterns to avoid repeated compilation - Use batch operations for iOS path migration in history_database - Replace containsKey+lookup with single lookup in palette_service - Pre-compute lowercase strings outside filter loops in logger - Fix _isLoaded race condition in DownloadHistoryNotifier --- CHANGELOG.md | 9 + lib/providers/download_queue_provider.dart | 34 +-- lib/providers/explore_provider.dart | 22 +- lib/providers/recent_access_provider.dart | 8 +- lib/providers/settings_provider.dart | 6 +- lib/providers/store_provider.dart | 5 +- lib/providers/theme_provider.dart | 6 +- lib/screens/home_tab.dart | 258 +++++++++++++-------- lib/screens/settings/extensions_page.dart | 16 +- lib/screens/settings/log_screen.dart | 6 +- lib/screens/track_metadata_screen.dart | 7 +- lib/services/csv_import_service.dart | 3 +- lib/services/history_database.dart | 12 +- lib/services/palette_service.dart | 5 +- lib/services/share_intent_service.dart | 12 +- lib/utils/logger.dart | 8 +- lib/widgets/update_dialog.dart | 25 +- 17 files changed, 287 insertions(+), 155 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e98e8891..d57f4443 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,15 @@ - **Deezer**: Track position fallback to index+1 when API returns 0 - **Localization**: Fixed 16 ICU plural syntax warnings in Spanish & Portuguese +### Performance + +- **Home Feed**: Precomputed Quick Picks section flag and reduced per-page allocations; explore state now watched by field to cut rebuilds +- **Home Recent**: Cached recent-access aggregation and limited list allocations for recent downloads +- **Settings/Theme/Recent**: Cached SharedPreferences instance to avoid repeated `getInstance()` calls +- **History/DB**: Batched iOS path migration updates to reduce write overhead +- **Download Queue**: Reduced polling allocations and avoided double-load scheduling for history +- **Misc**: Precompiled regex in share intent, update dialog, extensions error parsing, log analysis, and LRC cleanup; faster palette cache hits and log filtering + --- ## [3.2.0] - 2026-01-22 diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 80e3f9f4..37da93dc 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -180,9 +180,9 @@ class DownloadHistoryNotifier extends Notifier { /// Synchronously schedule load - ensures it runs before any UI renders void _loadFromDatabaseSync() { if (_isLoaded) return; + _isLoaded = true; Future.microtask(() async { await _loadFromDatabase(); - _isLoaded = true; }); } @@ -475,10 +475,21 @@ class DownloadQueueNotifier extends Notifier { final currentItems = state.items; final itemsById = {}; final itemIndexById = {}; + int queuedCount = 0; + int downloadingCount = 0; + DownloadItem? firstDownloading; for (int i = 0; i < currentItems.length; i++) { final item = currentItems[i]; itemsById[item.id] = item; itemIndexById[item.id] = i; + if (item.status == DownloadStatus.downloading) { + downloadingCount++; + firstDownloading ??= item; + } + if (item.status == DownloadStatus.queued || + item.status == DownloadStatus.downloading) { + queuedCount++; + } } final progressUpdates = {}; @@ -600,15 +611,12 @@ class DownloadQueueNotifier extends Notifier { final bytesReceived = firstProgress['bytes_received'] as int? ?? 0; final bytesTotal = firstProgress['bytes_total'] as int? ?? 0; - final downloadingItems = state.items - .where((i) => i.status == DownloadStatus.downloading) - .toList(); - if (downloadingItems.isNotEmpty) { - final trackName = downloadingItems.length == 1 - ? downloadingItems.first.track.name - : '${downloadingItems.length} downloads'; - final artistName = downloadingItems.length == 1 - ? downloadingItems.first.track.artistName + if (downloadingCount > 0 && firstDownloading != null) { + final trackName = downloadingCount == 1 + ? firstDownloading.track.name + : '$downloadingCount downloads'; + final artistName = downloadingCount == 1 + ? firstDownloading.track.artistName : 'Downloading...'; int notifProgress = bytesReceived; @@ -630,11 +638,11 @@ class DownloadQueueNotifier extends Notifier { if (Platform.isAndroid) { PlatformBridge.updateDownloadServiceProgress( - trackName: downloadingItems.first.track.name, - artistName: downloadingItems.first.track.artistName, + trackName: firstDownloading.track.name, + artistName: firstDownloading.track.artistName, progress: notifProgress, total: notifTotal > 0 ? notifTotal : 1, - queueCount: state.queuedCount, + queueCount: queuedCount, ).catchError((_) {}); } } diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart index 3256542a..b4e4e718 100644 --- a/lib/providers/explore_provider.dart +++ b/lib/providers/explore_provider.dart @@ -55,21 +55,26 @@ class ExploreSection { final String uri; final String title; final List items; + final bool isYTMusicQuickPicks; const ExploreSection({ required this.uri, required this.title, required this.items, + this.isYTMusicQuickPicks = false, }); factory ExploreSection.fromJson(Map json) { final itemsList = json['items'] as List? ?? []; + final items = itemsList + .map((item) => ExploreItem.fromJson(item as Map)) + .toList(); + final isQuickPicks = _isYTMusicQuickPicksItems(items); return ExploreSection( uri: json['uri'] as String? ?? '', title: json['title'] as String? ?? '', - items: itemsList - .map((item) => ExploreItem.fromJson(item as Map)) - .toList(), + items: items, + isYTMusicQuickPicks: isQuickPicks, ); } } @@ -123,6 +128,17 @@ String _getLocalGreeting() { } } +bool _isYTMusicQuickPicksItems(List items) { + if (items.isEmpty) return false; + if (items.first.providerId != 'ytmusic-spotiflac') return false; + for (final item in items) { + if (item.type != 'track') { + return false; + } + } + return true; +} + /// Provider for explore/home feed state class ExploreNotifier extends Notifier { @override diff --git a/lib/providers/recent_access_provider.dart b/lib/providers/recent_access_provider.dart index ab0b1466..0070cc95 100644 --- a/lib/providers/recent_access_provider.dart +++ b/lib/providers/recent_access_provider.dart @@ -100,6 +100,8 @@ class RecentAccessState { /// Provider for managing recent access history class RecentAccessNotifier extends Notifier { + final Future _prefs = SharedPreferences.getInstance(); + @override RecentAccessState build() { _loadHistory(); @@ -107,7 +109,7 @@ class RecentAccessNotifier extends Notifier { } Future _loadHistory() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final json = prefs.getString(_recentAccessKey); final hiddenJson = prefs.getStringList(_hiddenDownloadsKey); @@ -132,13 +134,13 @@ class RecentAccessNotifier extends Notifier { } Future _saveHistory() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final json = jsonEncode(state.items.map((e) => e.toJson()).toList()); await prefs.setString(_recentAccessKey, json); } Future _saveHiddenDownloads() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList()); } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 1e7830f4..b7c9687f 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -10,6 +10,8 @@ const _migrationVersionKey = 'settings_migration_version'; const _currentMigrationVersion = 1; class SettingsNotifier extends Notifier { + final Future _prefs = SharedPreferences.getInstance(); + @override AppSettings build() { _loadSettings(); @@ -17,7 +19,7 @@ class SettingsNotifier extends Notifier { } Future _loadSettings() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final json = prefs.getString(_settingsKey); if (json != null) { state = AppSettings.fromJson(jsonDecode(json)); @@ -46,7 +48,7 @@ class SettingsNotifier extends Notifier { } Future _saveSettings() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setString(_settingsKey, jsonEncode(state.toJson())); } diff --git a/lib/providers/store_provider.dart b/lib/providers/store_provider.dart index 6a314cab..3fdd824f 100644 --- a/lib/providers/store_provider.dart +++ b/lib/providers/store_provider.dart @@ -5,12 +5,13 @@ import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; final _log = AppLogger('StoreProvider'); +final RegExp _leadingVersionPrefix = RegExp(r'^v'); /// Compare two semantic version strings /// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2 int compareVersions(String v1, String v2) { - final parts1 = v1.replaceAll(RegExp(r'^v'), '').split('.'); - final parts2 = v2.replaceAll(RegExp(r'^v'), '').split('.'); + final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.'); + final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.'); final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length; diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index f1a3e728..7c38b33c 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -10,6 +10,8 @@ final themeProvider = NotifierProvider(() { /// Notifier for managing theme settings with persistence class ThemeNotifier extends Notifier { + final Future _prefs = SharedPreferences.getInstance(); + @override ThemeSettings build() { // Load settings asynchronously on first access @@ -20,7 +22,7 @@ class ThemeNotifier extends Notifier { /// Load theme settings from SharedPreferences Future _loadFromStorage() async { try { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final modeString = prefs.getString(kThemeModeKey); final useDynamic = prefs.getBool(kUseDynamicColorKey); final seedColor = prefs.getInt(kSeedColorKey); @@ -40,7 +42,7 @@ class ThemeNotifier extends Notifier { /// Save current settings to SharedPreferences Future _saveToStorage() async { try { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; await prefs.setString(kThemeModeKey, state.themeMode.name); await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor); await prefs.setInt(kSeedColorKey, state.seedColorValue); diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index adc5917c..ce3c6d5f 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -29,6 +29,18 @@ class HomeTab extends ConsumerStatefulWidget { ConsumerState createState() => _HomeTabState(); } +class _RecentAccessView { + final List uniqueItems; + final List downloadItems; + final bool hasHiddenDownloads; + + const _RecentAccessView({ + required this.uniqueItems, + required this.downloadItems, + required this.hasHiddenDownloads, + }); +} + class _HomeTabState extends ConsumerState with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { final _urlController = TextEditingController(); bool _isTyping = false; @@ -51,6 +63,11 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient /// Debounce duration for live search static const Duration _liveSearchDelay = Duration(milliseconds: 800); + + List? _recentAccessHistoryCache; + List? _recentAccessItemsCache; + Set? _recentAccessHiddenIdsCache; + _RecentAccessView? _recentAccessViewCache; @override bool get wantKeepAlive => true; @@ -447,7 +464,12 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final error = ref.watch(trackProvider.select((s) => s.error)); final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore)); - final exploreState = ref.watch(exploreProvider); + final exploreSections = + ref.watch(exploreProvider.select((s) => s.sections)); + final exploreGreeting = + ref.watch(exploreProvider.select((s) => s.greeting)); + final exploreLoading = + ref.watch(exploreProvider.select((s) => s.isLoading)); final hasHomeFeedExtension = ref.watch(extensionProvider.select((s) => s.extensions.any((e) => e.enabled && e.hasHomeFeed) )); @@ -461,11 +483,17 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final topPadding = mediaQuery.padding.top; final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items)); final recentAccessItems = ref.watch(recentAccessProvider.select((s) => s.items)); + final hiddenDownloadIds = + ref.watch(recentAccessProvider.select((s) => s.hiddenDownloadIds)); final hasRecentItems = recentAccessItems.isNotEmpty || historyItems.isNotEmpty; final showRecentAccess = isShowingRecentAccess && hasRecentItems && !hasActualResults && !isLoading; + final recentAccessView = showRecentAccess + ? _getRecentAccessView(recentAccessItems, historyItems, hiddenDownloadIds) + : null; - final showExplore = !hasActualResults && !isLoading && !showRecentAccess && hasHomeFeedExtension && exploreState.hasContent; + final hasExploreContent = exploreSections.isNotEmpty; + final showExplore = !hasActualResults && !isLoading && !showRecentAccess && hasHomeFeedExtension && hasExploreContent; if (hasActualResults && isShowingRecentAccess) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -577,11 +605,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (showRecentAccess) SliverToBoxAdapter( - child: _buildRecentAccess( - recentAccessItems, - historyItems, - colorScheme, - ), + child: _buildRecentAccess(recentAccessView!, colorScheme), ), SliverToBoxAdapter( @@ -614,9 +638,9 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), if (showExplore) - ..._buildExploreSections(exploreState, colorScheme), + ..._buildExploreSections(exploreSections, exploreGreeting, colorScheme), - if (hasHomeFeedExtension && !hasActualResults && !isLoading && exploreState.isLoading) + if (hasHomeFeedExtension && !hasActualResults && !isLoading && exploreLoading) const SliverToBoxAdapter( child: Padding( padding: EdgeInsets.all(32), @@ -640,7 +664,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } Widget _buildRecentDownloads(List items, ColorScheme colorScheme) { - final displayItems = items.take(10).toList(); + final itemCount = items.length < 10 ? items.length : 10; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -658,9 +682,9 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient height: 130, child: ListView.builder( scrollDirection: Axis.horizontal, - itemCount: displayItems.length, + itemCount: itemCount, itemBuilder: (context, index) { - final item = displayItems[index]; + final item = items[index]; return KeyedSubtree( key: ValueKey(item.id), child: GestureDetector( @@ -711,10 +735,117 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } - List _buildExploreSections(ExploreState exploreState, ColorScheme colorScheme) { - final greeting = exploreState.greeting; + _RecentAccessView _getRecentAccessView( + List items, + List historyItems, + Set hiddenIds, + ) { + final cached = _recentAccessViewCache; + if (cached != null && + identical(historyItems, _recentAccessHistoryCache) && + identical(items, _recentAccessItemsCache) && + identical(hiddenIds, _recentAccessHiddenIdsCache)) { + return cached; + } + + final albumGroups = >{}; + for (final h in historyItems) { + final artistForKey = + (h.albumArtist != null && h.albumArtist!.isNotEmpty) + ? h.albumArtist! + : h.artistName; + final albumKey = '${h.albumName}|$artistForKey'; + albumGroups.putIfAbsent(albumKey, () => []).add(h); + } + + final downloadItems = []; + for (final entry in albumGroups.entries) { + final tracks = entry.value; + final mostRecent = tracks.reduce( + (a, b) => a.downloadedAt.isAfter(b.downloadedAt) ? a : b, + ); + final artistForKey = + (mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty) + ? mostRecent.albumArtist! + : mostRecent.artistName; + + if (tracks.length == 1) { + downloadItems.add( + RecentAccessItem( + id: mostRecent.spotifyId ?? mostRecent.id, + name: mostRecent.trackName, + subtitle: mostRecent.artistName, + imageUrl: mostRecent.coverUrl, + type: RecentAccessType.track, + accessedAt: mostRecent.downloadedAt, + providerId: 'download', + ), + ); + } else { + downloadItems.add( + RecentAccessItem( + id: '${mostRecent.albumName}|$artistForKey', + name: mostRecent.albumName, + subtitle: artistForKey, + imageUrl: mostRecent.coverUrl, + type: RecentAccessType.album, + accessedAt: mostRecent.downloadedAt, + providerId: 'download', + ), + ); + } + } + + downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); + + final visibleDownloads = []; + for (final item in downloadItems) { + if (!hiddenIds.contains(item.id)) { + visibleDownloads.add(item); + if (visibleDownloads.length >= 10) { + break; + } + } + } + + final allItems = [ + ...items, + ...visibleDownloads, + ]; + allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); + + final seen = {}; + final uniqueItems = []; + for (final item in allItems) { + final key = '${item.type.name}:${item.id}'; + if (seen.add(key)) { + uniqueItems.add(item); + if (uniqueItems.length >= 10) { + break; + } + } + } + + final view = _RecentAccessView( + uniqueItems: uniqueItems, + downloadItems: downloadItems, + hasHiddenDownloads: hiddenIds.isNotEmpty, + ); + + _recentAccessHistoryCache = historyItems; + _recentAccessItemsCache = items; + _recentAccessHiddenIdsCache = hiddenIds; + _recentAccessViewCache = view; + + return view; + } + + List _buildExploreSections( + List sections, + String? greeting, + ColorScheme colorScheme, + ) { final hasGreeting = greeting != null && greeting.isNotEmpty; - final sections = exploreState.sections; final sectionOffset = hasGreeting ? 1 : 0; final totalCount = sections.length + sectionOffset + 1; // + bottom padding @@ -749,9 +880,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } Widget _buildExploreSection(ExploreSection section, ColorScheme colorScheme) { - final isYTMusicQuickPicks = _isYTMusicQuickPicksSection(section); - - if (isYTMusicQuickPicks) { + if (section.isYTMusicQuickPicks) { return _buildYTMusicQuickPicksSection(section, colorScheme); } @@ -783,19 +912,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } - bool _isYTMusicQuickPicksSection(ExploreSection section) { - if (section.items.isEmpty) return false; - if (section.items.first.providerId != 'ytmusic-spotiflac') return false; - - for (final item in section.items) { - if (item.type != 'track') { - return false; - } - } - - return true; - } - /// Build YT Music "Quick picks" style swipeable pages section Widget _buildYTMusicQuickPicksSection(ExploreSection section, ColorScheme colorScheme) { const itemsPerPage = 5; @@ -1097,72 +1213,10 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } } - Widget _buildRecentAccess( - List items, - List historyItems, - ColorScheme colorScheme, - ) { - final albumGroups = >{}; - for (final h in historyItems) { - final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty) - ? h.albumArtist! - : h.artistName; - final albumKey = '${h.albumName}|$artistForKey'; - albumGroups.putIfAbsent(albumKey, () => []).add(h); - } - - final downloadItems = []; - for (final entry in albumGroups.entries) { - final tracks = entry.value; - final mostRecent = tracks.reduce((a, b) => - a.downloadedAt.isAfter(b.downloadedAt) ? a : b); - final artistForKey = (mostRecent.albumArtist != null && mostRecent.albumArtist!.isNotEmpty) - ? mostRecent.albumArtist! - : mostRecent.artistName; - - if (tracks.length == 1) { - downloadItems.add(RecentAccessItem( - id: mostRecent.spotifyId ?? mostRecent.id, - name: mostRecent.trackName, - subtitle: mostRecent.artistName, - imageUrl: mostRecent.coverUrl, - type: RecentAccessType.track, - accessedAt: mostRecent.downloadedAt, - providerId: 'download', - )); - } else { - downloadItems.add(RecentAccessItem( - id: '${mostRecent.albumName}|$artistForKey', - name: mostRecent.albumName, - subtitle: artistForKey, - imageUrl: mostRecent.coverUrl, - type: RecentAccessType.album, - accessedAt: mostRecent.downloadedAt, - providerId: 'download', - )); - } - } - - downloadItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); - - final hiddenIds = ref.watch(recentAccessProvider.select((s) => s.hiddenDownloadIds)); - final visibleDownloads = downloadItems - .where((item) => !hiddenIds.contains(item.id)) - .take(10) - .toList(); - - final allItems = [...items, ...visibleDownloads]; - allItems.sort((a, b) => b.accessedAt.compareTo(a.accessedAt)); - - final seen = {}; - final uniqueItems = allItems.where((item) { - final key = '${item.type.name}:${item.id}'; - if (seen.contains(key)) return false; - seen.add(key); - return true; - }).take(10).toList(); - - final hasHiddenDownloads = hiddenIds.isNotEmpty; + Widget _buildRecentAccess(_RecentAccessView view, ColorScheme colorScheme) { + final uniqueItems = view.uniqueItems; + final downloadItems = view.downloadItems; + final hasHiddenDownloads = view.hasHiddenDownloads; return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), @@ -2923,11 +2977,15 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> { }, itemBuilder: (context, pageIndex) { final startIndex = pageIndex * widget.itemsPerPage; - final endIndex = (startIndex + widget.itemsPerPage).clamp(0, widget.section.items.length); - final pageItems = widget.section.items.sublist(startIndex, endIndex); + final endIndex = + (startIndex + widget.itemsPerPage).clamp(0, widget.section.items.length); + final pageItemCount = endIndex - startIndex; return Column( - children: pageItems.map((item) => _buildQuickPickItem(item)).toList(), + children: List.generate(pageItemCount, (index) { + final item = widget.section.items[startIndex + index]; + return _buildQuickPickItem(item); + }), ); }, ), diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index d6308aa3..d979d5f9 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -19,6 +19,14 @@ class ExtensionsPage extends ConsumerStatefulWidget { } class _ExtensionsPageState extends ConsumerState { + static final RegExp _platformExceptionPattern = + RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),'); + static final RegExp _platformExceptionSimplePattern = + RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null'); + static final RegExp _trailingNullsPattern = + RegExp(r',\s*null\s*,\s*null\)?$'); + static final RegExp _leadingCommaPattern = RegExp(r'^\s*,\s*'); + @override void initState() { super.initState(); @@ -296,19 +304,19 @@ class _ExtensionsPageState extends ConsumerState { String message = error; if (message.contains('PlatformException')) { - final match = RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),').firstMatch(message); + final match = _platformExceptionPattern.firstMatch(message); if (match != null) { message = match.group(1)?.trim() ?? message; } else { - final simpleMatch = RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null').firstMatch(message); + final simpleMatch = _platformExceptionSimplePattern.firstMatch(message); if (simpleMatch != null) { message = simpleMatch.group(1)?.trim() ?? message; } } } - message = message.replaceAll(RegExp(r',\s*null\s*,\s*null\)?$'), ''); - message = message.replaceAll(RegExp(r'^\s*,\s*'), ''); + message = message.replaceAll(_trailingNullsPattern, ''); + message = message.replaceAll(_leadingCommaPattern, ''); return message; } diff --git a/lib/screens/settings/log_screen.dart b/lib/screens/settings/log_screen.dart index efe95a08..69ce1c62 100644 --- a/lib/screens/settings/log_screen.dart +++ b/lib/screens/settings/log_screen.dart @@ -5,6 +5,9 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +final RegExp _domainPattern = + RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false); + class LogScreen extends StatefulWidget { const LogScreen({super.key}); @@ -13,6 +16,7 @@ class LogScreen extends StatefulWidget { } class _LogScreenState extends State { + final ScrollController _scrollController = ScrollController(); final TextEditingController _searchController = TextEditingController(); String _selectedLevel = 'ALL'; @@ -633,7 +637,7 @@ class _LogSummaryCard extends StatelessWidget { combined.contains('connection refused')) { hasISPBlocking = true; - final domainMatch = RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false).firstMatch(combined); + final domainMatch = _domainPattern.firstMatch(combined); if (domainMatch != null) { blockedDomains.add(domainMatch.group(1)!); } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 41f3e7f6..53ceec2d 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -37,6 +37,8 @@ class _TrackMetadataScreenState extends ConsumerState { final ScrollController _scrollController = ScrollController(); static final RegExp _lrcTimestampPattern = RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]'); + static final RegExp _lrcMetadataPattern = + RegExp(r'^\[[a-zA-Z]+:.*\]$'); static const List _months = [ 'Jan', 'Feb', @@ -1045,14 +1047,11 @@ class _TrackMetadataScreenState extends ConsumerState { 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 trimmedLine = line.trim(); // Skip metadata tags - if (metadataPattern.hasMatch(trimmedLine)) { + if (_lrcMetadataPattern.hasMatch(trimmedLine)) { continue; } diff --git a/lib/services/csv_import_service.dart b/lib/services/csv_import_service.dart index 0687f385..49a4744a 100644 --- a/lib/services/csv_import_service.dart +++ b/lib/services/csv_import_service.dart @@ -6,6 +6,7 @@ import 'package:spotiflac_android/utils/logger.dart'; class CsvImportService { static final _log = AppLogger('CsvImportService'); + static final RegExp _lineSplitPattern = RegExp(r'\r\n|\r|\n'); static Future> pickAndParseCsv({ void Function(int current, int total)? onProgress, @@ -123,7 +124,7 @@ class CsvImportService { static List _parseCsv(String content) { final List tracks = []; - final lines = content.split(RegExp(r'\r\n|\r|\n')); + final lines = content.split(_lineSplitPattern); if (lines.isEmpty) return tracks; int startIdx = 0; diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 1cf1088d..de24ab6b 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -7,6 +7,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('HistoryDatabase'); +final Future _prefs = SharedPreferences.getInstance(); /// Cached current iOS container path for path normalization String? _currentContainerPath; @@ -135,7 +136,7 @@ class HistoryDatabase { await _initContainerPath(); if (_currentContainerPath == null) return false; - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final lastContainer = prefs.getString('ios_last_container_path'); // Skip if container hasn't changed @@ -152,6 +153,7 @@ class HistoryDatabase { // Get all items with iOS paths final rows = await db.query('history', columns: ['id', 'file_path']); int updatedCount = 0; + final batch = db.batch(); for (final row in rows) { final id = row['id'] as String; @@ -160,7 +162,7 @@ class HistoryDatabase { if (oldPath != null && _iosContainerPattern.hasMatch(oldPath)) { final newPath = _normalizeIosPath(oldPath); if (newPath != oldPath) { - await db.update( + batch.update( 'history', {'file_path': newPath}, where: 'id = ?', @@ -171,6 +173,10 @@ class HistoryDatabase { } } + if (updatedCount > 0) { + await batch.commit(noResult: true); + } + // Save current container path await prefs.setString('ios_last_container_path', _currentContainerPath!); @@ -185,7 +191,7 @@ class HistoryDatabase { /// Migrate data from SharedPreferences to SQLite /// Returns true if migration was performed, false if already migrated Future migrateFromSharedPreferences() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final migrationKey = 'history_migrated_to_sqlite'; if (prefs.getBool(migrationKey) == true) { diff --git a/lib/services/palette_service.dart b/lib/services/palette_service.dart index 4c5ab61c..045a83a9 100644 --- a/lib/services/palette_service.dart +++ b/lib/services/palette_service.dart @@ -19,8 +19,9 @@ class PaletteService { return null; } - if (_colorCache.containsKey(imageUrl)) { - return _colorCache[imageUrl]; + final cached = _colorCache[imageUrl]; + if (cached != null) { + return cached; } try { diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart index 36b032ec..fac3a9a6 100644 --- a/lib/services/share_intent_service.dart +++ b/lib/services/share_intent_service.dart @@ -9,6 +9,12 @@ class ShareIntentService { factory ShareIntentService() => _instance; ShareIntentService._internal(); + static final RegExp _spotifyUriPattern = + RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+'); + static final RegExp _spotifyUrlPattern = RegExp( + r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?', + ); + final _sharedUrlController = StreamController.broadcast(); StreamSubscription>? _mediaSubscription; bool _initialized = false; @@ -57,14 +63,12 @@ class ShareIntentService { String? _extractSpotifyUrl(String text) { if (text.isEmpty) return null; - final uriMatch = RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+').firstMatch(text); + final uriMatch = _spotifyUriPattern.firstMatch(text); if (uriMatch != null) { return uriMatch.group(0); } - final urlMatch = RegExp( - r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?', - ).firstMatch(text); + final urlMatch = _spotifyUrlPattern.firstMatch(text); if (urlMatch != null) { final fullUrl = urlMatch.group(0)!; final queryIndex = fullUrl.indexOf('?'); diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index 3e69757b..0208d070 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -159,15 +159,17 @@ class LogBuffer extends ChangeNotifier { } List filter({String? level, String? tag, String? search}) { + final tagLower = tag?.toLowerCase(); + final searchLower = search?.toLowerCase(); + return _entries.where((entry) { if (level != null && level != 'ALL' && entry.level != level) { return false; } - if (tag != null && !entry.tag.toLowerCase().contains(tag.toLowerCase())) { + if (tagLower != null && !entry.tag.toLowerCase().contains(tagLower)) { return false; } - if (search != null && search.isNotEmpty) { - final searchLower = search.toLowerCase(); + if (searchLower != null && searchLower.isNotEmpty) { return entry.message.toLowerCase().contains(searchLower) || entry.tag.toLowerCase().contains(searchLower) || (entry.error?.toLowerCase().contains(searchLower) ?? false); diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart index 63be0b0c..f46c4d04 100644 --- a/lib/widgets/update_dialog.dart +++ b/lib/widgets/update_dialog.dart @@ -26,6 +26,15 @@ class _UpdateDialogState extends State { bool _isDownloading = false; double _progress = 0; String _statusText = ''; + static final RegExp _whatsNewPattern = + RegExp(r"###?\s*What'?s\s*New\s*\n", caseSensitive: false); + static final RegExp _cutoffPattern = + RegExp(r'\n---|\n###?\s*Downloads', caseSensitive: false); + static final RegExp _sectionPattern = RegExp(r'^#{1,3}\s*(.+)$'); + static final RegExp _listPattern = RegExp(r'^[-*]\s+(.+)$'); + static final RegExp _subListPattern = RegExp(r'^\s+[-*]\s+(.+)$'); + static final RegExp _boldPattern = RegExp(r'\*\*([^*]+)\*\*'); + static final RegExp _codePattern = RegExp(r'`([^`]+)`'); Future _downloadAndInstall() async { final apkUrl = widget.updateInfo.apkDownloadUrl; @@ -293,12 +302,12 @@ class _UpdateDialogState extends State { String _formatChangelog(String changelog) { var content = changelog; - final whatsNewMatch = RegExp(r"###?\s*What'?s\s*New\s*\n", caseSensitive: false).firstMatch(content); + final whatsNewMatch = _whatsNewPattern.firstMatch(content); if (whatsNewMatch != null) { content = content.substring(whatsNewMatch.end); } - final cutoffMatch = RegExp(r'\n---|\n###?\s*Downloads', caseSensitive: false).firstMatch(content); + final cutoffMatch = _cutoffPattern.firstMatch(content); if (cutoffMatch != null) { content = content.substring(0, cutoffMatch.start); } @@ -310,7 +319,7 @@ class _UpdateDialogState extends State { line = line.trim(); if (line.isEmpty) continue; - final sectionMatch = RegExp(r'^#{1,3}\s*(.+)$').firstMatch(line); + final sectionMatch = _sectionPattern.firstMatch(line); if (sectionMatch != null) { final section = sectionMatch.group(1)?.trim(); if (section != null && section.isNotEmpty) { @@ -320,19 +329,19 @@ class _UpdateDialogState extends State { continue; } - final listMatch = RegExp(r'^[-*]\s+(.+)$').firstMatch(line); + final listMatch = _listPattern.firstMatch(line); if (listMatch != null) { var itemText = listMatch.group(1) ?? ''; - itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? ''); - itemText = itemText.replaceAllMapped(RegExp(r'`([^`]+)`'), (m) => m.group(1) ?? ''); + itemText = itemText.replaceAllMapped(_boldPattern, (m) => m.group(1) ?? ''); + itemText = itemText.replaceAllMapped(_codePattern, (m) => m.group(1) ?? ''); formattedLines.add('• $itemText'); continue; } - final subListMatch = RegExp(r'^\s+[-*]\s+(.+)$').firstMatch(line); + final subListMatch = _subListPattern.firstMatch(line); if (subListMatch != null) { var itemText = subListMatch.group(1) ?? ''; - itemText = itemText.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m.group(1) ?? ''); + itemText = itemText.replaceAllMapped(_boldPattern, (m) => m.group(1) ?? ''); formattedLines.add(' - $itemText'); continue; }