Files
yattee/Yattee/Views/Downloads/DownloadRowView.swift
2026-02-08 18:33:56 +01:00

407 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 {
let download: Download
let isActive: Bool
var onDelete: (() -> Void)? = nil
// 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
@Environment(\.appEnvironment) private var appEnvironment
// 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()
}
/// 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
.if(onDelete != nil) { view in
view.videoContextMenu(
video: video,
customActions: [
VideoContextAction(
String(localized: "downloads.delete"),
systemImage: "trash",
role: .destructive,
action: { onDelete?() }
)
],
context: .downloads
)
}
} 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: { onDelete?() }
)
],
context: .downloads
)
.onAppear {
loadWatchDataIfNeeded()
}
}
}
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