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.
This commit is contained in:
@@ -680,26 +680,18 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
' - Title: ${meta?.title}',
|
' - Title: ${meta?.title}',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get total episode count - Use metadata.leafCount as primary source
|
// The progress ring reflects only the episodes the user actually queued for
|
||||||
int totalEpisodes;
|
// this show/season — not the show's full episode count. _getEpisodeDownloads
|
||||||
String countSource;
|
// 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) {
|
if (totalEpisodes == 0) {
|
||||||
totalEpisodes = metadataLeafCount;
|
appLogger.d('⚠️ No queued downloads for $entityType $ratingKey, returning null');
|
||||||
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');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,8 +700,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
int downloadingCount = 0;
|
int downloadingCount = 0;
|
||||||
int queuedCount = 0;
|
int queuedCount = 0;
|
||||||
int failedCount = 0;
|
int failedCount = 0;
|
||||||
|
int summedProgress = 0; // sum of per-episode progress (completed counts as 100)
|
||||||
|
|
||||||
for (final ep in episodes) {
|
for (final ep in episodes) {
|
||||||
|
summedProgress += ep.status == DownloadStatus.completed ? 100 : ep.progress;
|
||||||
switch (ep.status) {
|
switch (ep.status) {
|
||||||
case DownloadStatus.completed:
|
case DownloadStatus.completed:
|
||||||
completedCount++;
|
completedCount++;
|
||||||
@@ -740,8 +734,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate overall progress percentage based on TOTAL episodes
|
// Smooth percentage across the queued episodes: an in-flight episode
|
||||||
final int overallProgress = totalEpisodes > 0 ? ((completedCount * 100) / totalEpisodes).round() : 0;
|
// 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(
|
appLogger.d(
|
||||||
'Aggregate progress for $entityType $ratingKey: $overallProgress% '
|
'Aggregate progress for $entityType $ratingKey: $overallProgress% '
|
||||||
|
|||||||
@@ -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<void> 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(
|
return IconButton.filledTonal(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
// Show delete download confirmation
|
// Movies/episodes: nothing more to download, so delete directly.
|
||||||
final confirmed = await showDeleteConfirmation(
|
if (!canDownloadMore) {
|
||||||
context,
|
await confirmAndDelete();
|
||||||
title: t.downloads.deleteDownload,
|
return;
|
||||||
message: t.downloads.deleteConfirm(title: metadata.displayTitle),
|
}
|
||||||
);
|
// Shows/seasons: reopen the download options menu so the user can
|
||||||
|
// grab more episodes (or switch to sync), with delete as a row.
|
||||||
if (confirmed && context.mounted) {
|
final client = _getMediaClientForMetadata(context);
|
||||||
await downloadProvider.deleteDownload(globalKey);
|
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) {
|
if (context.mounted) {
|
||||||
showSuccessSnackBar(context, t.downloads.downloadDeleted);
|
showErrorSnackBar(context, t.settings.cellularDownloadBlocked);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: const AppIcon(Symbols.file_download_done_rounded, fill: 1),
|
icon: const AppIcon(Symbols.download_rounded, fill: 1),
|
||||||
tooltip: t.downloads.deleteDownload,
|
tooltip: canDownloadMore ? t.downloads.manage : t.downloads.deleteDownload,
|
||||||
iconSize: iconSize,
|
iconSize: iconSize,
|
||||||
style: actionButtonStyle(foregroundColor: Colors.green),
|
style: actionButtonStyle(foregroundColor: Colors.orange),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import 'download_version_utils.dart';
|
|||||||
import 'snackbar_helper.dart';
|
import 'snackbar_helper.dart';
|
||||||
|
|
||||||
/// Dialog option for the download picker. Typed to avoid stringly-typed values.
|
/// 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.
|
/// Whether the user chose a one-time download or a persistent sync rule.
|
||||||
enum _SyncChoice { downloadOnce, keepSynced }
|
enum _SyncChoice { downloadOnce, keepSynced }
|
||||||
@@ -49,11 +49,17 @@ class DownloadResult {
|
|||||||
/// Shows download options dialog for shows/seasons, then queues the download.
|
/// Shows download options dialog for shows/seasons, then queues the download.
|
||||||
/// For movies/episodes, queues directly without a dialog.
|
/// For movies/episodes, queues directly without a dialog.
|
||||||
/// Returns a [DownloadResult], or null if cancelled.
|
/// 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<DownloadResult?> showDownloadOptionsAndQueue(
|
Future<DownloadResult?> showDownloadOptionsAndQueue(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required MediaItem metadata,
|
required MediaItem metadata,
|
||||||
required MediaServerClient client,
|
required MediaServerClient client,
|
||||||
required DownloadProvider downloadProvider,
|
required DownloadProvider downloadProvider,
|
||||||
|
Future<void> Function()? onDelete,
|
||||||
}) async {
|
}) async {
|
||||||
final kind = metadata.kind;
|
final kind = metadata.kind;
|
||||||
|
|
||||||
@@ -63,20 +69,21 @@ Future<DownloadResult?> showDownloadOptionsAndQueue(
|
|||||||
|
|
||||||
if (kind == MediaKind.show || kind == MediaKind.season) {
|
if (kind == MediaKind.show || kind == MediaKind.season) {
|
||||||
int? customCount;
|
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>(
|
final selected = await showOptionPickerDialog<_DownloadChoice>(
|
||||||
context,
|
context,
|
||||||
title: t.downloads.downloadNow,
|
title: t.downloads.downloadNow,
|
||||||
options: [
|
options: 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),
|
|
||||||
],
|
|
||||||
onBeforeClose: (value) async {
|
onBeforeClose: (value) async {
|
||||||
if (value != _DownloadChoice.custom) return value;
|
if (value != _DownloadChoice.custom) return value;
|
||||||
customCount = await _showEpisodeCountDialog(context);
|
customCount = await _showEpisodeCountDialog(context);
|
||||||
@@ -100,6 +107,9 @@ Future<DownloadResult?> showDownloadOptionsAndQueue(
|
|||||||
case _DownloadChoice.custom:
|
case _DownloadChoice.custom:
|
||||||
filter = DownloadFilter.unwatched;
|
filter = DownloadFilter.unwatched;
|
||||||
maxCount = customCount;
|
maxCount = customCount;
|
||||||
|
case _DownloadChoice.delete:
|
||||||
|
if (onDelete != null) await onDelete();
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter == DownloadFilter.unwatched && kind == MediaKind.show && context.mounted) {
|
if (filter == DownloadFilter.unwatched && kind == MediaKind.show && context.mounted) {
|
||||||
|
|||||||
Reference in New Issue
Block a user