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

253 lines
8.1 KiB
Swift

//
// 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