From ac3d51e2cd03bac72b381cdf002ee1359044f37a Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 21 Jan 2026 10:26:35 +0700 Subject: [PATCH] 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 ??