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), ),