From 4148c1c8ff4d38bb7ef56e7134866a41dea1897e Mon Sep 17 00:00:00 2001 From: Darkmadda Date: Sat, 30 May 2026 00:40:17 -0600 Subject: [PATCH] fix(downloads): scope show progress to queued episodes; manage completed downloads (#1195) Progress ring now measures completion against the episodes actually queued (episodes.length) instead of the show's full leafCount, and advances smoothly using per-episode progress. The completed show/season download button reopens the download-options dialog (with a Delete row) so users can fetch more episodes or switch to sync instead of only deleting. --- lib/providers/download_provider.dart | 38 ++++++-------- lib/screens/media_detail/action_buttons.dart | 54 +++++++++++++++----- lib/utils/download_utils.dart | 34 +++++++----- 3 files changed, 80 insertions(+), 46 deletions(-) diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 81812a3f..a0754809 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -680,26 +680,18 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin ' - Title: ${meta?.title}', ); - // Get total episode count - Use metadata.leafCount as primary source - int totalEpisodes; - String countSource; + // The progress ring reflects only the episodes the user actually queued for + // this show/season — not the show's full episode count. _getEpisodeDownloads + // returns just the owned download records, so episodes.length IS the queued + // count. Downloading 5 of a 50-episode show therefore reaches 100% at 5/5. + // + // NOTE: metadataLeafCount and _totalEpisodeCounts are intentionally no longer + // used as the denominator. _totalEpisodeCounts is now unused app-wide. + // TODO: remove the _totalEpisodeCounts plumbing in a dedicated cleanup. + final int totalEpisodes = downloadedCount; - if (metadataLeafCount != null && metadataLeafCount > 0) { - totalEpisodes = metadataLeafCount; - countSource = 'metadata.leafCount'; - } else if (storedCount != null && storedCount > 0) { - totalEpisodes = storedCount; - countSource = 'stored count (StorageService)'; - } else { - totalEpisodes = downloadedCount; - countSource = 'downloaded episodes (fallback)'; - } - - appLogger.d('✅ Using totalEpisodes=$totalEpisodes from [$countSource] for $entityType $ratingKey'); - - // If we have stored count but no downloads, check if it's a valid partial state - if (totalEpisodes == 0 || (episodes.isEmpty && totalEpisodes > 0)) { - appLogger.d('⚠️ No valid downloads for $entityType $ratingKey, returning null'); + if (totalEpisodes == 0) { + appLogger.d('⚠️ No queued downloads for $entityType $ratingKey, returning null'); return null; } @@ -708,8 +700,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin int downloadingCount = 0; int queuedCount = 0; int failedCount = 0; + int summedProgress = 0; // sum of per-episode progress (completed counts as 100) for (final ep in episodes) { + summedProgress += ep.status == DownloadStatus.completed ? 100 : ep.progress; switch (ep.status) { case DownloadStatus.completed: completedCount++; @@ -740,8 +734,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin return null; } - // Calculate overall progress percentage based on TOTAL episodes - final int overallProgress = totalEpisodes > 0 ? ((completedCount * 100) / totalEpisodes).round() : 0; + // Smooth percentage across the queued episodes: an in-flight episode + // contributes its partial progress so the ring advances continuously, + // rather than jumping only when whole episodes complete. + final int overallProgress = (summedProgress / totalEpisodes).round(); appLogger.d( 'Aggregate progress for $entityType $ratingKey: $overallProgress% ' diff --git a/lib/screens/media_detail/action_buttons.dart b/lib/screens/media_detail/action_buttons.dart index 04c5ba24..088b04eb 100644 --- a/lib/screens/media_detail/action_buttons.dart +++ b/lib/screens/media_detail/action_buttons.dart @@ -487,26 +487,54 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { ); } + // Shows/seasons may have more episodes to fetch; movies/episodes don't. + final canDownloadMore = metadata.isShow || metadata.isSeason; + + Future confirmAndDelete() async { + final confirmed = await showDeleteConfirmation( + context, + title: t.downloads.deleteDownload, + message: t.downloads.deleteConfirm(title: metadata.displayTitle), + ); + if (confirmed && context.mounted) { + await downloadProvider.deleteDownload(globalKey); + if (context.mounted) { + showSuccessSnackBar(context, t.downloads.downloadDeleted); + } + } + } + return IconButton.filledTonal( onPressed: () async { - // Show delete download confirmation - final confirmed = await showDeleteConfirmation( - context, - title: t.downloads.deleteDownload, - message: t.downloads.deleteConfirm(title: metadata.displayTitle), - ); - - if (confirmed && context.mounted) { - await downloadProvider.deleteDownload(globalKey); + // Movies/episodes: nothing more to download, so delete directly. + if (!canDownloadMore) { + await confirmAndDelete(); + return; + } + // Shows/seasons: reopen the download options menu so the user can + // grab more episodes (or switch to sync), with delete as a row. + final client = _getMediaClientForMetadata(context); + if (client == null) return; + try { + final result = await showDownloadOptionsAndQueue( + context, + metadata: metadata, + client: client, + downloadProvider: downloadProvider, + onDelete: confirmAndDelete, + ); + if (result == null || !context.mounted) return; + showSuccessSnackBar(context, result.toSnackBarMessage()); + } on CellularDownloadBlockedException { if (context.mounted) { - showSuccessSnackBar(context, t.downloads.downloadDeleted); + showErrorSnackBar(context, t.settings.cellularDownloadBlocked); } } }, - icon: const AppIcon(Symbols.file_download_done_rounded, fill: 1), - tooltip: t.downloads.deleteDownload, + icon: const AppIcon(Symbols.download_rounded, fill: 1), + tooltip: canDownloadMore ? t.downloads.manage : t.downloads.deleteDownload, iconSize: iconSize, - style: actionButtonStyle(foregroundColor: Colors.green), + style: actionButtonStyle(foregroundColor: Colors.orange), ); } diff --git a/lib/utils/download_utils.dart b/lib/utils/download_utils.dart index 5c713f91..a127ddec 100644 --- a/lib/utils/download_utils.dart +++ b/lib/utils/download_utils.dart @@ -14,7 +14,7 @@ import 'download_version_utils.dart'; import 'snackbar_helper.dart'; /// Dialog option for the download picker. Typed to avoid stringly-typed values. -enum _DownloadChoice { all, unwatched, next5, next10, custom } +enum _DownloadChoice { all, unwatched, next5, next10, custom, delete } /// Whether the user chose a one-time download or a persistent sync rule. enum _SyncChoice { downloadOnce, keepSynced } @@ -49,11 +49,17 @@ class DownloadResult { /// Shows download options dialog for shows/seasons, then queues the download. /// For movies/episodes, queues directly without a dialog. /// Returns a [DownloadResult], or null if cancelled. +/// +/// When [onDelete] is provided (i.e. the item already has downloads), a +/// "Delete download" row is appended to the show/season options dialog so the +/// completed-download button can double as a "download more / delete" menu. +/// Selecting it runs [onDelete] and returns null. Future showDownloadOptionsAndQueue( BuildContext context, { required MediaItem metadata, required MediaServerClient client, required DownloadProvider downloadProvider, + Future Function()? onDelete, }) async { final kind = metadata.kind; @@ -63,20 +69,21 @@ Future showDownloadOptionsAndQueue( if (kind == MediaKind.show || kind == MediaKind.season) { int? customCount; + final options = <({IconData? icon, String label, _DownloadChoice value})>[ + (icon: Symbols.download_rounded, label: t.downloads.allEpisodes, value: _DownloadChoice.all), + (icon: Symbols.visibility_off_rounded, label: t.downloads.unwatchedOnly, value: _DownloadChoice.unwatched), + (icon: Symbols.filter_5_rounded, label: t.downloads.nextNUnwatched(count: 5), value: _DownloadChoice.next5), + (icon: Symbols.filter_9_plus_rounded, label: t.downloads.nextNUnwatched(count: 10), value: _DownloadChoice.next10), + (icon: Symbols.tune_rounded, label: t.downloads.customAmount, value: _DownloadChoice.custom), + ]; + // Already-downloaded show/season: offer deletion as the last row. + if (onDelete != null) { + options.add((icon: Symbols.delete_rounded, label: t.downloads.deleteDownload, value: _DownloadChoice.delete)); + } final selected = await showOptionPickerDialog<_DownloadChoice>( context, title: t.downloads.downloadNow, - options: [ - (icon: Symbols.download_rounded, label: t.downloads.allEpisodes, value: _DownloadChoice.all), - (icon: Symbols.visibility_off_rounded, label: t.downloads.unwatchedOnly, value: _DownloadChoice.unwatched), - (icon: Symbols.filter_5_rounded, label: t.downloads.nextNUnwatched(count: 5), value: _DownloadChoice.next5), - ( - icon: Symbols.filter_9_plus_rounded, - label: t.downloads.nextNUnwatched(count: 10), - value: _DownloadChoice.next10, - ), - (icon: Symbols.tune_rounded, label: t.downloads.customAmount, value: _DownloadChoice.custom), - ], + options: options, onBeforeClose: (value) async { if (value != _DownloadChoice.custom) return value; customCount = await _showEpisodeCountDialog(context); @@ -100,6 +107,9 @@ Future showDownloadOptionsAndQueue( case _DownloadChoice.custom: filter = DownloadFilter.unwatched; maxCount = customCount; + case _DownloadChoice.delete: + if (onDelete != null) await onDelete(); + return null; } if (filter == DownloadFilter.unwatched && kind == MediaKind.show && context.mounted) {