mirror of
https://github.com/yattee/yattee.git
synced 2026-04-11 01:56:57 +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
|
||||
252
Yattee/Views/Downloads/DownloadsStorageView.swift
Normal file
252
Yattee/Views/Downloads/DownloadsStorageView.swift
Normal file
@@ -0,0 +1,252 @@
|
||||
//
|
||||
// DownloadsStorageView.swift
|
||||
// Yattee
|
||||
//
|
||||
// View for managing downloaded video storage.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if !os(tvOS)
|
||||
struct DownloadsStorageView: View {
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
@State private var showingDeleteWatchedConfirmation = false
|
||||
@State private var showingDeleteAllConfirmation = false
|
||||
|
||||
private var downloadManager: DownloadManager? {
|
||||
appEnvironment?.downloadManager
|
||||
}
|
||||
|
||||
private var dataManager: DataManager? {
|
||||
appEnvironment?.dataManager
|
||||
}
|
||||
|
||||
/// Downloads sorted by file size (largest first).
|
||||
private var completedDownloads: [Download] {
|
||||
(downloadManager?.completedDownloads ?? []).sorted { $0.totalBytes > $1.totalBytes }
|
||||
}
|
||||
|
||||
/// List style from centralized settings.
|
||||
private var listStyle: VideoListStyle {
|
||||
appEnvironment?.settingsManager.listStyle ?? .inset
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// Downloads that have been fully watched.
|
||||
private var watchedDownloads: [Download] {
|
||||
completedDownloads.filter { download in
|
||||
dataManager?.watchEntry(for: download.videoID.videoID)?.isFinished ?? false
|
||||
}
|
||||
}
|
||||
|
||||
/// Total size of watched downloads in bytes.
|
||||
private var watchedDownloadsSize: Int64 {
|
||||
watchedDownloads.reduce(0) { $0 + $1.totalBytes }
|
||||
}
|
||||
|
||||
/// Total size of all downloads in bytes.
|
||||
private var allDownloadsSize: Int64 {
|
||||
completedDownloads.reduce(0) { $0 + $1.totalBytes }
|
||||
}
|
||||
|
||||
/// Set of watched video IDs for bulk deletion.
|
||||
private var watchedVideoIDs: Set<String> {
|
||||
Set(watchedDownloads.map { $0.videoID.videoID })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if completedDownloads.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
downloadsList
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "settings.downloads.storage.title"))
|
||||
.toolbarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
if !completedDownloads.isEmpty {
|
||||
deleteMenu
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
String(localized: "settings.downloads.storage.deleteWatched"),
|
||||
isPresented: $showingDeleteWatchedConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: "settings.downloads.storage.deleteWatched"), role: .destructive) {
|
||||
Task {
|
||||
await downloadManager?.deleteWatchedDownloads(watchedVideoIDs: watchedVideoIDs)
|
||||
}
|
||||
}
|
||||
Button(String(localized: "common.cancel"), role: .cancel) {}
|
||||
} message: {
|
||||
Text("settings.downloads.storage.deleteWatched.message \(watchedDownloads.count) \(formatBytes(watchedDownloadsSize))")
|
||||
}
|
||||
.confirmationDialog(
|
||||
String(localized: "settings.downloads.storage.deleteAll"),
|
||||
isPresented: $showingDeleteAllConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: "settings.downloads.storage.deleteAll"), role: .destructive) {
|
||||
Task {
|
||||
await downloadManager?.deleteAllCompleted()
|
||||
}
|
||||
}
|
||||
Button(String(localized: "common.cancel"), role: .cancel) {}
|
||||
} message: {
|
||||
Text("settings.downloads.storage.deleteAll.message \(completedDownloads.count) \(formatBytes(allDownloadsSize))")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delete Menu
|
||||
|
||||
@ViewBuilder
|
||||
private var deleteMenu: some View {
|
||||
Menu {
|
||||
if !watchedDownloads.isEmpty {
|
||||
Button(role: .destructive) {
|
||||
showingDeleteWatchedConfirmation = true
|
||||
} label: {
|
||||
Label(
|
||||
String(localized: "settings.downloads.storage.deleteWatched") + " (\(watchedDownloads.count), \(formatBytes(watchedDownloadsSize)))",
|
||||
systemImage: "eye.fill"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
showingDeleteAllConfirmation = true
|
||||
} label: {
|
||||
Label(
|
||||
String(localized: "settings.downloads.storage.deleteAll") + " (\(completedDownloads.count), \(formatBytes(allDownloadsSize)))",
|
||||
systemImage: "trash"
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Label(String(localized: "common.delete"), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Downloads List
|
||||
|
||||
@ViewBuilder
|
||||
private var downloadsList: some View {
|
||||
let backgroundStyle: ListBackgroundStyle = listStyle == .inset ? .grouped : .plain
|
||||
|
||||
backgroundStyle.color
|
||||
.ignoresSafeArea()
|
||||
.overlay(
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
Spacer().frame(height: 16)
|
||||
|
||||
VideoListContent(listStyle: listStyle) {
|
||||
ForEach(Array(completedDownloads.enumerated()), id: \.element.id) { index, download in
|
||||
storageRow(download, isLast: index == completedDownloads.count - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Storage Row
|
||||
|
||||
@ViewBuilder
|
||||
private func storageRow(_ download: Download, isLast: Bool) -> some View {
|
||||
let video = download.toVideo()
|
||||
let isWatched = dataManager?.watchEntry(for: download.videoID.videoID)?.isFinished ?? false
|
||||
|
||||
VideoListRow(
|
||||
isLast: isLast,
|
||||
rowStyle: .regular,
|
||||
listStyle: listStyle
|
||||
) {
|
||||
HStack(spacing: 12) {
|
||||
// Thumbnail with watched checkmark overlay
|
||||
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)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text(formatBytes(download.totalBytes))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if isWatched {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.swipeActions(actionsArray: [
|
||||
SwipeAction(
|
||||
symbolImage: "trash.fill",
|
||||
tint: .white,
|
||||
background: .red
|
||||
) { reset in
|
||||
Task { await downloadManager?.delete(download) }
|
||||
reset()
|
||||
}
|
||||
])
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
Task { await downloadManager?.delete(download) }
|
||||
} label: {
|
||||
Label(String(localized: "common.delete"), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
|
||||
@ViewBuilder
|
||||
private var emptyStateView: some View {
|
||||
ContentUnavailableView {
|
||||
Label(String(localized: "settings.downloads.storage.empty"), systemImage: "arrow.down.circle")
|
||||
} description: {
|
||||
Text(String(localized: "settings.downloads.storage.empty.description"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func formatBytes(_ bytes: Int64) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
DownloadsStorageView()
|
||||
}
|
||||
.appEnvironment(.preview)
|
||||
}
|
||||
#endif
|
||||
630
Yattee/Views/Downloads/DownloadsView.swift
Normal file
630
Yattee/Views/Downloads/DownloadsView.swift
Normal file
@@ -0,0 +1,630 @@
|
||||
//
|
||||
// DownloadsView.swift
|
||||
// Yattee
|
||||
//
|
||||
// Downloads management view.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if !os(tvOS)
|
||||
struct DownloadsView: View {
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
@State private var selectedDownload: Download?
|
||||
@State private var failedDownloadToShow: Download?
|
||||
@State private var searchText = ""
|
||||
|
||||
private var downloadManager: DownloadManager? {
|
||||
appEnvironment?.downloadManager
|
||||
}
|
||||
|
||||
private var downloadSettings: DownloadSettings? {
|
||||
appEnvironment?.downloadSettings
|
||||
}
|
||||
|
||||
/// List style from centralized settings.
|
||||
private var listStyle: VideoListStyle {
|
||||
appEnvironment?.settingsManager.listStyle ?? .inset
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let manager = downloadManager, let settings = downloadSettings {
|
||||
downloadsList(manager, settings: settings)
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "downloads.title"))
|
||||
.toolbarTitleDisplayMode(.inlineLarge)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
if let settings = downloadSettings {
|
||||
sortAndGroupMenu(settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Downloads List
|
||||
|
||||
@ViewBuilder
|
||||
private func downloadsList(_ manager: DownloadManager, settings: DownloadSettings) -> some View {
|
||||
let hasGroupedContent = settings.groupByChannel || settings.sortOption == .name
|
||||
|
||||
Group {
|
||||
if hasGroupedContent {
|
||||
// Grouped content needs separate cards per group
|
||||
groupedDownloadsList(manager, settings: settings)
|
||||
} else {
|
||||
// Flat list uses single card
|
||||
flatDownloadsList(manager, settings: settings)
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, prompt: Text("Search downloads"))
|
||||
.onChange(of: selectedDownload) { _, newValue in
|
||||
if let download = newValue {
|
||||
if let result = downloadManager?.videoAndStream(for: download) {
|
||||
appEnvironment?.playerService.openVideo(result.video, stream: result.stream, audioStream: result.audioStream, download: download)
|
||||
}
|
||||
selectedDownload = nil
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
String(localized: "downloads.status.failed"),
|
||||
isPresented: .init(
|
||||
get: { failedDownloadToShow != nil },
|
||||
set: { if !$0 { failedDownloadToShow = nil } }
|
||||
)
|
||||
) {
|
||||
Button(String(localized: "downloads.retry")) {
|
||||
if let download = failedDownloadToShow {
|
||||
Task { await manager.resume(download) }
|
||||
}
|
||||
}
|
||||
Button(String(localized: "common.cancel"), role: .cancel) {}
|
||||
} message: {
|
||||
if let download = failedDownloadToShow {
|
||||
Text(download.error ?? String(localized: "downloads.error.unknown"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Flat Downloads List (Separate Cards for Active/Completed)
|
||||
|
||||
@ViewBuilder
|
||||
private func flatDownloadsList(_ manager: DownloadManager, settings: DownloadSettings) -> some View {
|
||||
// NOTE: Active and Completed sections are separate views to isolate observation scopes.
|
||||
// This prevents completed section from re-rendering when active downloads progress updates.
|
||||
// Active and completed downloads are in separate cards for visual clarity.
|
||||
let backgroundStyle: ListBackgroundStyle = listStyle == .inset ? .grouped : .plain
|
||||
|
||||
backgroundStyle.color
|
||||
.ignoresSafeArea()
|
||||
.overlay(
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
Spacer().frame(height: 16)
|
||||
|
||||
// Active downloads in its own card
|
||||
ActiveDownloadsSectionContentView(
|
||||
manager: manager,
|
||||
searchText: searchText,
|
||||
listStyle: listStyle,
|
||||
isGroupedMode: true, // Always use card mode for active downloads
|
||||
failedDownloadToShow: $failedDownloadToShow
|
||||
)
|
||||
|
||||
// Completed downloads in its own card
|
||||
CompletedDownloadsSectionContentView(
|
||||
manager: manager,
|
||||
settings: settings,
|
||||
searchText: searchText,
|
||||
listStyle: listStyle,
|
||||
isGroupedMode: false
|
||||
)
|
||||
|
||||
// Empty state
|
||||
DownloadsEmptyStateView(
|
||||
manager: manager,
|
||||
searchText: searchText
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Grouped Downloads List (Separate Cards per Group)
|
||||
|
||||
@ViewBuilder
|
||||
private func groupedDownloadsList(_ manager: DownloadManager, settings: DownloadSettings) -> some View {
|
||||
let backgroundStyle: ListBackgroundStyle = listStyle == .inset ? .grouped : .plain
|
||||
|
||||
backgroundStyle.color
|
||||
.ignoresSafeArea()
|
||||
.overlay(
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
Spacer().frame(height: 16)
|
||||
|
||||
// Active downloads in its own card (if any)
|
||||
ActiveDownloadsSectionContentView(
|
||||
manager: manager,
|
||||
searchText: searchText,
|
||||
listStyle: listStyle,
|
||||
isGroupedMode: true,
|
||||
failedDownloadToShow: $failedDownloadToShow
|
||||
)
|
||||
|
||||
// Completed downloads with separate cards per group
|
||||
CompletedDownloadsSectionContentView(
|
||||
manager: manager,
|
||||
settings: settings,
|
||||
searchText: searchText,
|
||||
listStyle: listStyle,
|
||||
isGroupedMode: true
|
||||
)
|
||||
|
||||
// Empty state
|
||||
DownloadsEmptyStateView(
|
||||
manager: manager,
|
||||
searchText: searchText
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Sort and Group Menu
|
||||
|
||||
@ViewBuilder
|
||||
private func sortAndGroupMenu(_ settings: DownloadSettings) -> some View {
|
||||
Menu {
|
||||
// Sort options
|
||||
Section {
|
||||
Picker(selection: Binding(
|
||||
get: { settings.sortOption },
|
||||
set: { settings.sortOption = $0 }
|
||||
)) {
|
||||
ForEach(DownloadSortOption.allCases, id: \.self) { option in
|
||||
Label(option.displayName, systemImage: option.systemImage)
|
||||
.tag(option)
|
||||
}
|
||||
} label: {
|
||||
Label(String(localized: "downloads.sort.title"), systemImage: "arrow.up.arrow.down")
|
||||
}
|
||||
|
||||
// Sort direction
|
||||
Button {
|
||||
settings.sortDirection.toggle()
|
||||
} label: {
|
||||
Label(
|
||||
settings.sortDirection == .ascending
|
||||
? String(localized: "downloads.sort.ascending")
|
||||
: String(localized: "downloads.sort.descending"),
|
||||
systemImage: settings.sortDirection.systemImage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Grouping
|
||||
Section {
|
||||
Toggle(isOn: Binding(
|
||||
get: { settings.groupByChannel },
|
||||
set: { settings.groupByChannel = $0 }
|
||||
)) {
|
||||
Label(String(localized: "downloads.groupByChannel"), systemImage: "person.2")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(String(localized: "downloads.sortAndGroup"), systemImage: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Active Downloads Section Content View (Isolated Observation Scope)
|
||||
|
||||
/// Separate view for active downloads to isolate @Observable tracking.
|
||||
/// Only accesses manager.activeDownloads, so won't re-render when completedDownloads changes.
|
||||
/// Generates rows directly for VideoListContainer (no Section wrapper).
|
||||
private struct ActiveDownloadsSectionContentView: View {
|
||||
let manager: DownloadManager
|
||||
let searchText: String
|
||||
let listStyle: VideoListStyle
|
||||
let isGroupedMode: Bool
|
||||
@Binding var failedDownloadToShow: Download?
|
||||
|
||||
private var activeFiltered: [Download] {
|
||||
guard !searchText.isEmpty else { return manager.activeDownloads }
|
||||
let query = searchText.lowercased()
|
||||
return manager.activeDownloads.filter { download in
|
||||
download.title.lowercased().contains(query) ||
|
||||
download.channelName.lowercased().contains(query) ||
|
||||
download.videoID.videoID.lowercased().contains(query)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if !activeFiltered.isEmpty {
|
||||
if isGroupedMode {
|
||||
// Grouped mode: rows in their own card
|
||||
VideoListContent(listStyle: listStyle) {
|
||||
activeDownloadRows
|
||||
}
|
||||
} else {
|
||||
// Flat mode: rows inline
|
||||
activeDownloadRows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var activeDownloadRows: some View {
|
||||
ForEach(Array(activeFiltered.enumerated()), id: \.element.id) { index, download in
|
||||
VideoListRow(
|
||||
isLast: index == activeFiltered.count - 1,
|
||||
rowStyle: .regular,
|
||||
listStyle: listStyle
|
||||
) {
|
||||
DownloadRowView(
|
||||
download: download,
|
||||
isActive: true
|
||||
)
|
||||
.onTapGesture {
|
||||
if download.status == .failed {
|
||||
failedDownloadToShow = download
|
||||
}
|
||||
}
|
||||
}
|
||||
.swipeActions(actionsArray: swipeActionsFor(download))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Section Header
|
||||
|
||||
private func sectionHeader(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, listStyle == .inset ? 16 : 16)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
// MARK: - Swipe Actions
|
||||
|
||||
private func swipeActionsFor(_ download: Download) -> [SwipeAction] {
|
||||
var actions: [SwipeAction] = []
|
||||
|
||||
// Pause/Resume action based on status
|
||||
if download.status == .downloading {
|
||||
actions.append(SwipeAction(
|
||||
symbolImage: "pause.fill",
|
||||
tint: .white,
|
||||
background: .orange
|
||||
) { reset in
|
||||
Task { await manager.pause(download) }
|
||||
reset()
|
||||
})
|
||||
} else if download.status == .paused || download.status == .failed {
|
||||
actions.append(SwipeAction(
|
||||
symbolImage: "play.fill",
|
||||
tint: .white,
|
||||
background: .green
|
||||
) { reset in
|
||||
Task { await manager.resume(download) }
|
||||
reset()
|
||||
})
|
||||
}
|
||||
|
||||
// Cancel action (always present)
|
||||
actions.append(SwipeAction(
|
||||
symbolImage: "xmark.circle.fill",
|
||||
tint: .white,
|
||||
background: .red
|
||||
) { reset in
|
||||
Task { await manager.cancel(download) }
|
||||
reset()
|
||||
})
|
||||
|
||||
return actions
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Completed Downloads Section Content View (Isolated Observation Scope)
|
||||
|
||||
/// Separate view for completed downloads to isolate @Observable tracking.
|
||||
/// Only accesses manager.completedDownloads, so won't re-render when activeDownloads progress updates.
|
||||
/// Generates rows directly for VideoListContainer (no Section wrapper).
|
||||
private struct CompletedDownloadsSectionContentView: View {
|
||||
let manager: DownloadManager
|
||||
let settings: DownloadSettings
|
||||
let searchText: String
|
||||
let listStyle: VideoListStyle
|
||||
let isGroupedMode: Bool
|
||||
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
private var completedFiltered: [Download] {
|
||||
guard !searchText.isEmpty else { return manager.completedDownloads }
|
||||
let query = searchText.lowercased()
|
||||
return manager.completedDownloads.filter { download in
|
||||
download.title.lowercased().contains(query) ||
|
||||
download.channelName.lowercased().contains(query) ||
|
||||
download.videoID.videoID.lowercased().contains(query)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if !completedFiltered.isEmpty {
|
||||
if settings.groupByChannel {
|
||||
groupedByChannelContent(completedFiltered)
|
||||
} else if settings.sortOption == .name {
|
||||
groupedByLetterContent(completedFiltered)
|
||||
} else {
|
||||
flatListContent(completedFiltered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Grouped by Channel
|
||||
|
||||
@ViewBuilder
|
||||
private func groupedByChannelContent(_ downloads: [Download]) -> some View {
|
||||
let grouped = settings.groupedByChannel(downloads)
|
||||
let allDownloadsInOrder = grouped.flatMap { $0.downloads }
|
||||
let videoList = allDownloadsInOrder.map { $0.toVideo() }
|
||||
var runningIndex = 0
|
||||
|
||||
ForEach(Array(grouped.enumerated()), id: \.element.channelID) { groupIndex, group in
|
||||
let baseIndex = runningIndex
|
||||
let _ = { runningIndex += group.downloads.count }()
|
||||
|
||||
// Channel header OUTSIDE the card
|
||||
channelSectionHeader(group.channel, channelID: group.channelID, downloads: group.downloads)
|
||||
|
||||
if isGroupedMode {
|
||||
// Each group in its own VideoListContent card
|
||||
VideoListContent(listStyle: listStyle) {
|
||||
ForEach(Array(group.downloads.enumerated()), id: \.element.id) { localIndex, download in
|
||||
completedDownloadRow(
|
||||
download,
|
||||
videoList: videoList,
|
||||
index: baseIndex + localIndex,
|
||||
isLast: localIndex == group.downloads.count - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Flat mode: rows inline
|
||||
ForEach(Array(group.downloads.enumerated()), id: \.element.id) { localIndex, download in
|
||||
let isLastInGroup = localIndex == group.downloads.count - 1
|
||||
let isLastGroup = groupIndex == grouped.count - 1
|
||||
completedDownloadRow(
|
||||
download,
|
||||
videoList: videoList,
|
||||
index: baseIndex + localIndex,
|
||||
isLast: isLastInGroup && isLastGroup
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Grouped by Letter
|
||||
|
||||
@ViewBuilder
|
||||
private func groupedByLetterContent(_ downloads: [Download]) -> some View {
|
||||
let sortedDownloads = settings.sorted(downloads)
|
||||
let groupedByLetter = groupDownloadsByFirstLetter(sortedDownloads, ascending: settings.sortDirection == .ascending)
|
||||
let allDownloadsInOrder = groupedByLetter.flatMap { $0.downloads }
|
||||
let videoList = allDownloadsInOrder.map { $0.toVideo() }
|
||||
var runningIndex = 0
|
||||
|
||||
ForEach(Array(groupedByLetter.enumerated()), id: \.element.letter) { groupIndex, group in
|
||||
let baseIndex = runningIndex
|
||||
let _ = { runningIndex += group.downloads.count }()
|
||||
|
||||
// Letter header OUTSIDE the card
|
||||
letterSectionHeader(group.letter)
|
||||
|
||||
if isGroupedMode {
|
||||
// Each group in its own VideoListContent card
|
||||
VideoListContent(listStyle: listStyle) {
|
||||
ForEach(Array(group.downloads.enumerated()), id: \.element.id) { localIndex, download in
|
||||
completedDownloadRow(
|
||||
download,
|
||||
videoList: videoList,
|
||||
index: baseIndex + localIndex,
|
||||
isLast: localIndex == group.downloads.count - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Flat mode: rows inline
|
||||
ForEach(Array(group.downloads.enumerated()), id: \.element.id) { localIndex, download in
|
||||
let isLastInGroup = localIndex == group.downloads.count - 1
|
||||
let isLastGroup = groupIndex == groupedByLetter.count - 1
|
||||
completedDownloadRow(
|
||||
download,
|
||||
videoList: videoList,
|
||||
index: baseIndex + localIndex,
|
||||
isLast: isLastInGroup && isLastGroup
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Flat List
|
||||
|
||||
@ViewBuilder
|
||||
private func flatListContent(_ downloads: [Download]) -> some View {
|
||||
let sortedDownloads = settings.sorted(downloads)
|
||||
let videoList = sortedDownloads.map { $0.toVideo() }
|
||||
|
||||
// Flat list content wrapped in its own card
|
||||
VideoListContent(listStyle: listStyle) {
|
||||
ForEach(Array(sortedDownloads.enumerated()), id: \.element.id) { index, download in
|
||||
completedDownloadRow(
|
||||
download,
|
||||
videoList: videoList,
|
||||
index: index,
|
||||
isLast: index == sortedDownloads.count - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Section Headers
|
||||
|
||||
private func letterSectionHeader(_ letter: String) -> some View {
|
||||
Text(letter)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, listStyle == .inset ? 16 : 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func channelSectionHeader(_ channel: String, channelID: String, downloads: [Download]) -> some View {
|
||||
let contentSource = downloads.first?.toVideo().id.source ?? .global(provider: ContentSource.youtubeProvider)
|
||||
NavigationLink(value: NavigationDestination.channel(channelID, contentSource)) {
|
||||
HStack(spacing: 4) {
|
||||
Text(channel)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
.zoomTransitionSource(id: channelID)
|
||||
.buttonStyle(.plain)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, listStyle == .inset ? 16 : 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
// MARK: - Row and Footer
|
||||
|
||||
@ViewBuilder
|
||||
private func completedDownloadRow(_ download: Download, videoList: [Video], index: Int, isLast: Bool) -> some View {
|
||||
VideoListRow(
|
||||
isLast: isLast,
|
||||
rowStyle: .regular,
|
||||
listStyle: listStyle
|
||||
) {
|
||||
DownloadRowView(
|
||||
download: download,
|
||||
isActive: false,
|
||||
queueSource: .manual,
|
||||
sourceLabel: String(localized: "queue.source.downloads"),
|
||||
videoList: videoList,
|
||||
videoIndex: index,
|
||||
loadMoreVideos: loadMoreDownloadsCallback
|
||||
)
|
||||
}
|
||||
.videoSwipeActions(
|
||||
video: download.toVideo(),
|
||||
fixedActions: [
|
||||
SwipeAction(
|
||||
symbolImage: "trash.fill",
|
||||
tint: .white,
|
||||
background: .red
|
||||
) { reset in
|
||||
Task { await manager.delete(download) }
|
||||
reset()
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@Sendable
|
||||
private func loadMoreDownloadsCallback() async throws -> ([Video], String?) {
|
||||
return ([], nil)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func sectionIndexLabel(for text: String) -> String {
|
||||
guard let firstChar = text.first else { return "#" }
|
||||
let uppercased = String(firstChar).uppercased()
|
||||
return uppercased.first?.isLetter == true ? uppercased : "#"
|
||||
}
|
||||
|
||||
private func groupDownloadsByFirstLetter(_ downloads: [Download], ascending: Bool) -> [(letter: String, downloads: [Download])] {
|
||||
let grouped = Dictionary(grouping: downloads) { download -> String in
|
||||
sectionIndexLabel(for: download.title)
|
||||
}
|
||||
|
||||
return grouped.map { (letter, downloads) in
|
||||
(letter: letter, downloads: downloads)
|
||||
}
|
||||
.sorted { ascending ? $0.letter < $1.letter : $0.letter > $1.letter }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Downloads Empty State View (Isolated Observation Scope)
|
||||
|
||||
/// Separate view for empty state to avoid re-rendering main content when checking isEmpty.
|
||||
private struct DownloadsEmptyStateView: View {
|
||||
let manager: DownloadManager
|
||||
let searchText: String
|
||||
|
||||
private var hasActiveDownloads: Bool {
|
||||
!manager.activeDownloads.isEmpty
|
||||
}
|
||||
|
||||
private var hasCompletedDownloads: Bool {
|
||||
!manager.completedDownloads.isEmpty
|
||||
}
|
||||
|
||||
private var isEmpty: Bool {
|
||||
if searchText.isEmpty {
|
||||
return !hasActiveDownloads && !hasCompletedDownloads
|
||||
}
|
||||
let query = searchText.lowercased()
|
||||
let hasMatchingActive = manager.activeDownloads.contains { download in
|
||||
download.title.lowercased().contains(query) ||
|
||||
download.channelName.lowercased().contains(query) ||
|
||||
download.videoID.videoID.lowercased().contains(query)
|
||||
}
|
||||
let hasMatchingCompleted = manager.completedDownloads.contains { download in
|
||||
download.title.lowercased().contains(query) ||
|
||||
download.channelName.lowercased().contains(query) ||
|
||||
download.videoID.videoID.lowercased().contains(query)
|
||||
}
|
||||
return !hasMatchingActive && !hasMatchingCompleted
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if isEmpty {
|
||||
if !searchText.isEmpty {
|
||||
ContentUnavailableView.search(text: searchText)
|
||||
.padding(.top, 60)
|
||||
} else {
|
||||
ContentUnavailableView {
|
||||
Label(String(localized: "downloads.empty.title"), systemImage: "arrow.down.circle")
|
||||
} description: {
|
||||
Text(String(localized: "downloads.empty.description"))
|
||||
}
|
||||
.padding(.top, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
DownloadsView()
|
||||
}
|
||||
.appEnvironment(.preview)
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user