refactor: trakt/download hardening and cleanup sweep
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../models/download_models.dart';
|
||||
import '../widgets/app_icon.dart';
|
||||
|
||||
/// Visual weight preset.
|
||||
///
|
||||
/// - [muted] blends the status color with the surrounding muted text color
|
||||
/// (used in compact list contexts like episode rows where a saturated color
|
||||
/// would fight the primary content).
|
||||
/// - [saturated] uses the status color directly (used in tree view / expanded
|
||||
/// contexts where the status is the primary signal).
|
||||
enum DownloadStatusIconVariant { muted, saturated }
|
||||
|
||||
/// Renders a compact status indicator for a download: queued/paused/failed/etc.
|
||||
/// When [status] is [DownloadStatus.downloading] and [progress] is non-null,
|
||||
/// renders a dual-ring progress indicator instead of a static icon.
|
||||
///
|
||||
/// Returns `SizedBox.shrink()` for any status that shouldn't have a visible
|
||||
/// indicator in the caller's context (e.g. partial).
|
||||
class DownloadStatusIcon extends StatelessWidget {
|
||||
final DownloadStatus? status;
|
||||
final double size;
|
||||
final DownloadStatusIconVariant variant;
|
||||
|
||||
/// Optional 0.0–1.0 progress for the downloading state's ring.
|
||||
/// If null while downloading, shows an indeterminate spinner.
|
||||
final double? progress;
|
||||
|
||||
/// Color to blend with in [DownloadStatusIconVariant.muted]. Required when
|
||||
/// variant=muted (usually `tokens(context).textMuted`).
|
||||
final Color? mutedBase;
|
||||
|
||||
/// Optional override for the primary color (e.g. used in downloading
|
||||
/// variant=muted to pick the theme's primary rather than a fixed blue).
|
||||
final Color? overrideColor;
|
||||
|
||||
const DownloadStatusIcon({
|
||||
super.key,
|
||||
required this.status,
|
||||
this.size = 16,
|
||||
this.variant = DownloadStatusIconVariant.saturated,
|
||||
this.progress,
|
||||
this.mutedBase,
|
||||
this.overrideColor,
|
||||
});
|
||||
|
||||
Color _tint(Color base) {
|
||||
if (variant == DownloadStatusIconVariant.saturated || mutedBase == null) return base;
|
||||
return Color.lerp(mutedBase, base, 0.3) ?? base;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = status;
|
||||
if (s == null) return const SizedBox.shrink();
|
||||
|
||||
switch (s) {
|
||||
case DownloadStatus.queued:
|
||||
return AppIcon(Symbols.schedule_rounded, fill: 1, size: size, color: _tint(Colors.orange));
|
||||
case DownloadStatus.downloading:
|
||||
// No progress value — render a static "downloading" icon (callers
|
||||
// without per-item progress, e.g. the download tree view).
|
||||
if (progress == null) {
|
||||
return AppIcon(Symbols.downloading_rounded, fill: 1, size: size, color: _tint(overrideColor ?? Colors.blue));
|
||||
}
|
||||
final primary = overrideColor ?? Theme.of(context).colorScheme.primary;
|
||||
final tinted = _tint(primary);
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: 1.0,
|
||||
strokeWidth: size * 0.1,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(tinted.withValues(alpha: 0.3)),
|
||||
),
|
||||
CircularProgressIndicator(
|
||||
value: progress,
|
||||
strokeWidth: size * 0.1,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(tinted),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
case DownloadStatus.paused:
|
||||
return AppIcon(
|
||||
Symbols.pause_circle_outline_rounded,
|
||||
fill: 1,
|
||||
size: size,
|
||||
color: _tint(variant == DownloadStatusIconVariant.muted ? Colors.amber : Colors.grey),
|
||||
);
|
||||
case DownloadStatus.failed:
|
||||
return AppIcon(
|
||||
variant == DownloadStatusIconVariant.muted ? Symbols.error_outline_rounded : Symbols.error_rounded,
|
||||
fill: 1,
|
||||
size: size,
|
||||
color: _tint(Colors.red),
|
||||
);
|
||||
case DownloadStatus.cancelled:
|
||||
return AppIcon(Symbols.cancel_rounded, fill: 1, size: size, color: _tint(Colors.grey));
|
||||
case DownloadStatus.completed:
|
||||
return AppIcon(
|
||||
variant == DownloadStatusIconVariant.muted
|
||||
? Symbols.file_download_done_rounded
|
||||
: Symbols.check_circle_rounded,
|
||||
fill: 1,
|
||||
size: size,
|
||||
color: _tint(Colors.green),
|
||||
);
|
||||
case DownloadStatus.partial:
|
||||
return AppIcon(Symbols.downloading_rounded, fill: 1, size: size, color: _tint(Colors.orange));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indeterminate spinner used while the download is being queued (pre-status).
|
||||
/// Separate widget because it doesn't correspond to a [DownloadStatus] value.
|
||||
class DownloadQueueingSpinner extends StatelessWidget {
|
||||
final double size;
|
||||
final Color? color;
|
||||
|
||||
const DownloadQueueingSpinner({super.key, this.size = 12, this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CircularProgressIndicator(strokeWidth: size * 0.125, color: color),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import '../models/download_models.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import 'download_status_icon.dart';
|
||||
|
||||
/// Represents a node in the download tree
|
||||
class DownloadTreeNode {
|
||||
@@ -707,41 +708,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
}
|
||||
|
||||
Widget _buildStatusIcon(DownloadStatus status) {
|
||||
IconData iconData;
|
||||
Color? color;
|
||||
|
||||
switch (status) {
|
||||
case DownloadStatus.downloading:
|
||||
iconData = Symbols.downloading_rounded;
|
||||
color = Colors.blue;
|
||||
break;
|
||||
case DownloadStatus.queued:
|
||||
iconData = Symbols.schedule_rounded;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case DownloadStatus.paused:
|
||||
iconData = Symbols.pause_circle_outline_rounded;
|
||||
color = Colors.grey;
|
||||
break;
|
||||
case DownloadStatus.completed:
|
||||
iconData = Symbols.check_circle_rounded;
|
||||
color = Colors.green;
|
||||
break;
|
||||
case DownloadStatus.failed:
|
||||
iconData = Symbols.error_rounded;
|
||||
color = Colors.red;
|
||||
break;
|
||||
case DownloadStatus.cancelled:
|
||||
iconData = Symbols.cancel_rounded;
|
||||
color = Colors.grey;
|
||||
break;
|
||||
case DownloadStatus.partial:
|
||||
iconData = Symbols.downloading_rounded;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
}
|
||||
|
||||
return AppIcon(iconData, fill: 1, size: 20, color: color);
|
||||
return DownloadStatusIcon(status: status, size: 20);
|
||||
}
|
||||
|
||||
String _getNodeSummary() {
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../providers/download_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../widgets/collapsible_text.dart';
|
||||
import '../widgets/download_status_icon.dart';
|
||||
import '../widgets/plex_optimized_image.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
@@ -238,99 +239,30 @@ class _EpisodeCardState extends State<EpisodeCard> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Episode number and title with download status
|
||||
Consumer<DownloadProvider>(
|
||||
builder: (context, downloadProvider, _) {
|
||||
Selector<DownloadProvider, _DownloadSlice>(
|
||||
selector: (_, p) => _DownloadSlice.from(
|
||||
p.getProgress(widget.episode.globalKey),
|
||||
p.isQueueing(widget.episode.globalKey),
|
||||
),
|
||||
builder: (context, slice, _) {
|
||||
// Build download status icon based on state
|
||||
Widget? downloadStatusIcon;
|
||||
|
||||
// Only show download status in online mode
|
||||
if (!widget.isOffline && widget.episode.serverId != null) {
|
||||
final globalKey = widget.episode.globalKey;
|
||||
final progress = downloadProvider.getProgress(globalKey);
|
||||
final isQueueing = downloadProvider.isQueueing(globalKey);
|
||||
final status = slice.status;
|
||||
final mutedBase = tokens(context).textMuted;
|
||||
|
||||
// Helper to get status-specific muted color
|
||||
Color getMutedColor(Color baseColor) {
|
||||
return Color.lerp(
|
||||
tokens(context).textMuted,
|
||||
baseColor,
|
||||
0.3, // 30% of the status color, 70% muted
|
||||
)!;
|
||||
}
|
||||
|
||||
if (isQueueing) {
|
||||
// Queueing state - building queue
|
||||
downloadStatusIcon = SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(strokeWidth: 1.5, color: tokens(context).textMuted),
|
||||
);
|
||||
} else if (progress?.status == DownloadStatus.queued) {
|
||||
// Queued state - waiting to download
|
||||
downloadStatusIcon = AppIcon(
|
||||
Symbols.schedule_rounded,
|
||||
fill: 1,
|
||||
size: 12,
|
||||
color: getMutedColor(Colors.orange),
|
||||
);
|
||||
} else if (progress?.status == DownloadStatus.downloading) {
|
||||
// Downloading state - active download with radial progress
|
||||
downloadStatusIcon = SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// Background circle
|
||||
CircularProgressIndicator(
|
||||
value: 1.0,
|
||||
strokeWidth: 1.5,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
getMutedColor(Theme.of(context).colorScheme.primary).withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
// Progress circle
|
||||
CircularProgressIndicator(
|
||||
value: progress?.progressPercent,
|
||||
strokeWidth: 1.5,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
getMutedColor(Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (progress?.status == DownloadStatus.paused) {
|
||||
// Paused state - download paused
|
||||
downloadStatusIcon = AppIcon(
|
||||
Symbols.pause_circle_outline_rounded,
|
||||
fill: 1,
|
||||
size: 12,
|
||||
color: getMutedColor(Colors.amber),
|
||||
);
|
||||
} else if (progress?.status == DownloadStatus.failed) {
|
||||
// Failed state - download failed
|
||||
downloadStatusIcon = AppIcon(
|
||||
Symbols.error_outline_rounded,
|
||||
fill: 1,
|
||||
size: 12,
|
||||
color: getMutedColor(Colors.red),
|
||||
);
|
||||
} else if (progress?.status == DownloadStatus.cancelled) {
|
||||
// Cancelled state - download cancelled
|
||||
downloadStatusIcon = AppIcon(
|
||||
Symbols.cancel_rounded,
|
||||
fill: 1,
|
||||
size: 12,
|
||||
color: getMutedColor(Colors.grey),
|
||||
);
|
||||
} else if (progress?.status == DownloadStatus.completed) {
|
||||
// Completed state - download complete
|
||||
downloadStatusIcon = AppIcon(
|
||||
Symbols.file_download_done_rounded,
|
||||
fill: 1,
|
||||
size: 12,
|
||||
color: getMutedColor(Colors.green),
|
||||
if (slice.isQueueing) {
|
||||
downloadStatusIcon = DownloadQueueingSpinner(size: 12, color: mutedBase);
|
||||
} else if (status != null) {
|
||||
final iconSize = status == DownloadStatus.downloading ? 14.0 : 12.0;
|
||||
downloadStatusIcon = DownloadStatusIcon(
|
||||
status: status,
|
||||
size: iconSize,
|
||||
variant: DownloadStatusIconVariant.muted,
|
||||
mutedBase: mutedBase,
|
||||
progress: slice.progressPercent,
|
||||
);
|
||||
}
|
||||
// Note: No icon shown if not downloaded (null)
|
||||
@@ -437,3 +369,28 @@ class _EpisodeCardState extends State<EpisodeCard> {
|
||||
return const PlaceholderContainer(child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32));
|
||||
}
|
||||
}
|
||||
|
||||
/// Captures only primitives so Selector equality avoids rebuilds on unrelated
|
||||
/// download ticks (e.g. other episodes, unused `DownloadProgress` fields).
|
||||
class _DownloadSlice {
|
||||
final DownloadStatus? status;
|
||||
final double? progressPercent;
|
||||
final bool isQueueing;
|
||||
|
||||
const _DownloadSlice({required this.status, required this.progressPercent, required this.isQueueing});
|
||||
|
||||
factory _DownloadSlice.from(DownloadProgress? p, bool isQueueing) =>
|
||||
_DownloadSlice(status: p?.status, progressPercent: p?.progressPercent, isQueueing: isQueueing);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is _DownloadSlice &&
|
||||
other.status == status &&
|
||||
other.progressPercent == progressPercent &&
|
||||
other.isQueueing == isQueueing;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(status, progressPercent, isQueueing);
|
||||
}
|
||||
|
||||
@@ -881,7 +881,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
|
||||
if (sectionId == null) {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, 'Unable to determine library section for this item');
|
||||
showErrorSnackBar(context, t.messages.unableToDetermineLibrarySection);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1514,7 +1514,9 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog>
|
||||
return ListTile(
|
||||
leading: const AppIcon(Symbols.collections_rounded, fill: 1),
|
||||
title: Text(collection.title!),
|
||||
subtitle: collection.childCount != null ? Text('${collection.childCount} items') : null,
|
||||
subtitle: collection.childCount != null
|
||||
? Text(t.playlists.itemCount(count: collection.childCount!))
|
||||
: null,
|
||||
onTap: () => Navigator.pop(context, collection.ratingKey),
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user