mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
Yattee v2 rewrite
This commit is contained in:
406
Yattee/Views/Downloads/DownloadRowView.swift
Normal file
406
Yattee/Views/Downloads/DownloadRowView.swift
Normal file
@@ -0,0 +1,406 @@
|
||||
//
|
||||
// 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
|
||||
Reference in New Issue
Block a user