mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
407 lines
14 KiB
Swift
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
|