mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
Download row actions were wired only as swipe actions, and SwipeActionModifier has #if !os(iOS) overloads that return the view unmodified, so macOS had no way to pause, resume or cancel a download. The video context menu could not cover it either, since the .downloads context deliberately hides the built-in cancel item. Give active download rows their own context menu with pause when queued or downloading, resume when paused, retry when failed, and a destructive cancel. Right-click on macOS, long-press on iOS. Completed rows had a related bug: the delete item called an onDelete callback that DownloadsView never passed, so it rendered but did nothing on macOS. Drop the callback and delete via the download manager directly, which is what already made the same menu work in HomeView. Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
435 lines
14 KiB
Swift
435 lines
14 KiB
Swift
//
|
|
// DownloadRowView.swift
|
|
// Yattee
|
|
//
|
|
// Row view for displaying a download item.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
#if !os(tvOS)
|
|
/// Row view for displaying a download item.
|
|
/// Automatically handles DeArrow integration.
|
|
/// For completed downloads, uses VideoRowView with tap zone support (thumbnail plays, text opens info).
|
|
/// For active downloads, shows custom progress UI with no tap actions.
|
|
struct DownloadRowView: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
let download: Download
|
|
let isActive: Bool
|
|
|
|
// Queue context (optional, enables auto-play when provided)
|
|
var queueSource: QueueSource? = nil
|
|
var sourceLabel: String? = nil
|
|
var videoList: [Video]? = nil
|
|
var videoIndex: Int? = nil
|
|
var loadMoreVideos: LoadMoreVideosCallback? = nil
|
|
|
|
// Cache watch progress to avoid CoreData fetches on every re-render
|
|
@State private var cachedWatchProgress: Double?
|
|
@State private var cachedWatchedSeconds: TimeInterval?
|
|
@State private var hasLoadedWatchData = false
|
|
|
|
private var video: Video {
|
|
download.toVideo(downloadsDirectory: appEnvironment?.downloadManager.downloadsDirectory())
|
|
}
|
|
|
|
/// Watch progress for this video (0.0 to 1.0), or nil if not watched.
|
|
/// Uses cached value to avoid CoreData fetch on every re-render.
|
|
private var watchProgress: Double? {
|
|
guard !isActive else { return nil }
|
|
return cachedWatchProgress
|
|
}
|
|
|
|
/// Watch position in seconds for resume functionality.
|
|
/// Uses cached value to avoid CoreData fetch on every re-render.
|
|
private var watchedSeconds: TimeInterval? {
|
|
guard !isActive else { return nil }
|
|
return cachedWatchedSeconds
|
|
}
|
|
|
|
/// Loads watch data from CoreData once on appear.
|
|
private func loadWatchDataIfNeeded() {
|
|
guard !hasLoadedWatchData, !isActive else { return }
|
|
hasLoadedWatchData = true
|
|
|
|
guard let dataManager = appEnvironment?.dataManager else { return }
|
|
|
|
// Load watch progress
|
|
if let entry = dataManager.watchEntry(for: video.id.videoID) {
|
|
let progress = entry.progress
|
|
cachedWatchProgress = progress > 0 && progress < 1 ? progress : nil
|
|
}
|
|
|
|
// Load watched seconds
|
|
cachedWatchedSeconds = dataManager.watchProgress(for: video.id.videoID)
|
|
}
|
|
|
|
/// Metadata text for completed downloads (file size + download date)
|
|
private var downloadMetadata: String {
|
|
let sizeText = formatBytes(download.totalBytes)
|
|
|
|
if let completedAt = download.completedAt {
|
|
let dateText = RelativeDateFormatter.string(for: completedAt)
|
|
return "\(sizeText) • \(dateText)"
|
|
}
|
|
|
|
return sizeText
|
|
}
|
|
|
|
var body: some View {
|
|
if isActive {
|
|
// Active downloads: custom row with progress indicators, no tap actions
|
|
activeDownloadContent
|
|
.contextMenu {
|
|
activeDownloadMenuItems
|
|
}
|
|
} else {
|
|
// Completed downloads: use VideoRowView with tap zones (thumbnail plays, text opens info)
|
|
VideoRowView(
|
|
video: video,
|
|
style: .regular,
|
|
watchProgress: watchProgress,
|
|
customMetadata: downloadMetadata
|
|
)
|
|
.tappableVideo(
|
|
video,
|
|
startTime: watchedSeconds,
|
|
queueSource: queueSource,
|
|
sourceLabel: sourceLabel,
|
|
videoList: videoList,
|
|
videoIndex: videoIndex,
|
|
loadMoreVideos: loadMoreVideos
|
|
)
|
|
.videoContextMenu(
|
|
video: video,
|
|
customActions: [
|
|
VideoContextAction(
|
|
String(localized: "downloads.delete"),
|
|
systemImage: "trash",
|
|
role: .destructive,
|
|
action: {
|
|
Task { await appEnvironment?.downloadManager.delete(download) }
|
|
}
|
|
)
|
|
],
|
|
context: .downloads
|
|
)
|
|
.onAppear {
|
|
loadWatchDataIfNeeded()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Menu items for an in-progress download: pause/resume/retry plus cancel.
|
|
/// The `.downloads` video context menu hides the built-in cancel item, and swipe
|
|
/// actions are iOS-only, so this is the only cancel affordance on macOS.
|
|
@ViewBuilder
|
|
private var activeDownloadMenuItems: some View {
|
|
switch download.status {
|
|
case .queued, .downloading:
|
|
Button {
|
|
Task { await appEnvironment?.downloadManager.pause(download) }
|
|
} label: {
|
|
Label(String(localized: "downloads.pause"), systemImage: "pause.fill")
|
|
}
|
|
|
|
case .paused:
|
|
Button {
|
|
Task { await appEnvironment?.downloadManager.resume(download) }
|
|
} label: {
|
|
Label(String(localized: "downloads.resume"), systemImage: "play.fill")
|
|
}
|
|
|
|
case .failed:
|
|
Button {
|
|
Task { await appEnvironment?.downloadManager.resume(download) }
|
|
} label: {
|
|
Label(String(localized: "downloads.retry"), systemImage: "arrow.clockwise")
|
|
}
|
|
|
|
case .completed:
|
|
EmptyView()
|
|
}
|
|
|
|
Button(role: .destructive) {
|
|
Task { await appEnvironment?.downloadManager.cancel(download) }
|
|
} label: {
|
|
Label(String(localized: "downloads.cancel"), systemImage: "xmark.circle")
|
|
}
|
|
}
|
|
|
|
private var activeDownloadContent: some View {
|
|
HStack(spacing: 12) {
|
|
// Thumbnail (download status shown automatically)
|
|
DeArrowVideoThumbnail(
|
|
video: video,
|
|
duration: video.formattedDuration
|
|
)
|
|
.frame(width: 120, height: 68)
|
|
|
|
// Info
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(video.displayTitle(using: appEnvironment?.deArrowBrandingProvider))
|
|
.font(.subheadline)
|
|
.lineLimit(2)
|
|
|
|
Text(download.channelName)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
if isActive {
|
|
activeDownloadStatusView
|
|
} else {
|
|
Text(formatBytes(download.totalBytes))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
#if os(iOS)
|
|
private var isWaitingForWiFi: Bool {
|
|
guard let settings = appEnvironment?.downloadSettings,
|
|
let connectivity = appEnvironment?.connectivityMonitor else {
|
|
return false
|
|
}
|
|
return !settings.allowCellularDownloads && connectivity.isCellular
|
|
}
|
|
#endif
|
|
|
|
@ViewBuilder
|
|
private var activeDownloadStatusView: some View {
|
|
switch download.status {
|
|
case .queued:
|
|
#if os(iOS)
|
|
if isWaitingForWiFi {
|
|
Label(String(localized: "downloads.status.waitingForWiFi"), systemImage: "wifi")
|
|
.font(.caption)
|
|
.foregroundStyle(.orange)
|
|
} else {
|
|
Label(String(localized: "downloads.status.queued"), systemImage: "clock")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
#else
|
|
Label(String(localized: "downloads.status.queued"), systemImage: "clock")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
#endif
|
|
|
|
case .downloading:
|
|
#if os(iOS)
|
|
if isWaitingForWiFi {
|
|
waitingForWiFiProgressView
|
|
} else {
|
|
streamProgressView
|
|
}
|
|
#else
|
|
streamProgressView
|
|
#endif
|
|
|
|
case .paused:
|
|
Label(String(localized: "downloads.status.paused"), systemImage: "pause.circle")
|
|
.font(.caption)
|
|
.foregroundStyle(.orange)
|
|
|
|
case .failed:
|
|
Label(String(localized: "downloads.status.failed"), systemImage: "exclamationmark.circle")
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
|
|
case .completed:
|
|
EmptyView()
|
|
}
|
|
}
|
|
|
|
#if os(iOS)
|
|
@ViewBuilder
|
|
private var waitingForWiFiProgressView: some View {
|
|
let hasAudio = download.audioStreamURL != nil
|
|
let hasCaption = download.captionURL != nil
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
// Video progress with waiting indicator
|
|
waitingProgressRow(
|
|
icon: "film",
|
|
progress: download.videoProgress,
|
|
isCompleted: download.videoProgress >= 1.0
|
|
)
|
|
|
|
// Audio progress (if applicable)
|
|
if hasAudio {
|
|
waitingProgressRow(
|
|
icon: "waveform",
|
|
progress: download.audioProgress,
|
|
isCompleted: download.audioProgress >= 1.0
|
|
)
|
|
}
|
|
|
|
// Caption progress (if applicable)
|
|
if hasCaption {
|
|
waitingProgressRow(
|
|
icon: "captions.bubble",
|
|
progress: download.captionProgress,
|
|
isCompleted: download.captionProgress >= 1.0
|
|
)
|
|
}
|
|
|
|
// Waiting for WiFi label
|
|
Label(String(localized: "downloads.status.waitingForWiFi"), systemImage: "wifi")
|
|
.font(.caption2)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func waitingProgressRow(
|
|
icon: String,
|
|
progress: Double,
|
|
isCompleted: Bool
|
|
) -> some View {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: icon)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 16)
|
|
|
|
if isCompleted {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.caption2)
|
|
.foregroundStyle(.green)
|
|
} else {
|
|
ProgressView(value: progress)
|
|
.frame(width: 50)
|
|
.tint(.orange)
|
|
|
|
Text("\(Int(progress * 100))%")
|
|
.font(.caption2.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize()
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
@ViewBuilder
|
|
private var streamProgressView: some View {
|
|
let hasAudio = download.audioStreamURL != nil
|
|
let hasCaption = download.captionURL != nil
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
// Video progress
|
|
streamProgressRow(
|
|
icon: "film",
|
|
progress: download.videoProgress,
|
|
speed: download.videoDownloadSpeed,
|
|
isActive: download.videoProgress < 1.0,
|
|
isCompleted: download.videoProgress >= 1.0,
|
|
isSizeUnknown: download.videoSizeUnknown,
|
|
downloadedBytes: download.videoDownloadedBytes
|
|
)
|
|
|
|
// Audio progress (if applicable)
|
|
if hasAudio {
|
|
streamProgressRow(
|
|
icon: "waveform",
|
|
progress: download.audioProgress,
|
|
speed: download.audioDownloadSpeed,
|
|
isActive: download.audioProgress < 1.0,
|
|
isCompleted: download.audioProgress >= 1.0,
|
|
isSizeUnknown: download.audioSizeUnknown,
|
|
downloadedBytes: download.audioDownloadedBytes
|
|
)
|
|
}
|
|
|
|
// Caption progress (if applicable)
|
|
if hasCaption {
|
|
streamProgressRow(
|
|
icon: "captions.bubble",
|
|
progress: download.captionProgress,
|
|
speed: download.captionDownloadSpeed,
|
|
isActive: download.captionProgress < 1.0,
|
|
isCompleted: download.captionProgress >= 1.0,
|
|
isSizeUnknown: download.captionSizeUnknown,
|
|
downloadedBytes: download.captionDownloadedBytes
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func streamProgressRow(
|
|
icon: String,
|
|
progress: Double,
|
|
speed: Int64,
|
|
isActive: Bool,
|
|
isCompleted: Bool,
|
|
isSizeUnknown: Bool = false,
|
|
downloadedBytes: Int64 = 0
|
|
) -> some View {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: icon)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 16)
|
|
|
|
if isCompleted {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.caption2)
|
|
.foregroundStyle(.green)
|
|
} else if isSizeUnknown {
|
|
// Indeterminate: show bytes downloaded instead of percentage
|
|
Text(formatBytes(downloadedBytes))
|
|
.font(.caption2.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize()
|
|
|
|
if isActive && speed > 0 {
|
|
Text(formatSpeed(speed))
|
|
.font(.caption2.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize()
|
|
}
|
|
} else {
|
|
ProgressView(value: progress)
|
|
.frame(width: 50)
|
|
|
|
Text("\(Int(progress * 100))%")
|
|
.font(.caption2.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize()
|
|
|
|
if isActive && speed > 0 {
|
|
Text(formatSpeed(speed))
|
|
.font(.caption2.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func formatBytes(_ bytes: Int64) -> String {
|
|
let formatter = ByteCountFormatter()
|
|
formatter.countStyle = .file
|
|
return formatter.string(fromByteCount: bytes)
|
|
}
|
|
|
|
private func formatSpeed(_ bytesPerSecond: Int64) -> String {
|
|
let formatter = ByteCountFormatter()
|
|
formatter.countStyle = .file
|
|
formatter.allowedUnits = [.useKB, .useMB, .useGB]
|
|
return formatter.string(fromByteCount: bytesPerSecond) + "/s"
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
// Note: Preview requires a Video object for Download initialization.
|
|
// See DownloadsView.swift for usage examples.
|
|
#endif
|