mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user