refactor: trakt/download hardening and cleanup sweep

This commit is contained in:
edde746
2026-04-21 13:57:03 +02:00
parent 93d196572f
commit ee020d81bd
66 changed files with 1526 additions and 2167 deletions
+136
View File
@@ -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.01.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),
);
}
}
+2 -35
View File
@@ -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() {
+44 -87
View File
@@ -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);
}
+4 -2
View File
@@ -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),
);
},