import SwiftUI /// One item: what it is, what to do with it, its files, and its notes. struct ItemDetailView: View { @ObservedObject var store: LibraryStore @ObservedObject var playback: Playback let entry: IPX.Entry var play: (IPX.Entry, IPX.Enclosure?) -> Void @Environment(\.api) private var api @State private var notesHeight: CGFloat = 1 @State private var confirmingDelete: IPX.Enclosure? private var current: IPX.Entry { store.entries.first { $0.id == entry.id } ?? entry } private var feed: IPX.Feed? { store.feed(entry.feedId) } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { heading meta files notes } .padding(16) } .background(Glass.Wash()) .navigationTitle(feed?.name ?? entry.feedId) #if !targetEnvironment(macCatalyst) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { toolbar } .confirmationDialog("Delete this file?", isPresented: Binding(get: { confirmingDelete != nil }, set: { if !$0 { confirmingDelete = nil } }), titleVisibility: .visible) { Button("Delete", role: .destructive) { if let file = confirmingDelete { delete(file) } confirmingDelete = nil } } message: { // One file serves everyone reading the feed, so deleting is not a private act. Text((feed?.subscribers ?? 1) > 1 ? "Other people reading this feed share this file." : "The file is removed from the server.") } } private var heading: some View { HStack(alignment: .top, spacing: 12) { FeedArt(feed: feed, size: 56) Text(current.title ?? "(untitled)") .font(.title3.weight(.semibold)) .foregroundStyle(Glass.text) .fixedSize(horizontal: false, vertical: true) } } private var meta: some View { let number = [current.season.map { "S\($0)" }, current.episode.map { "E\($0)" }] .compactMap { $0 }.joined() let date = current.published.map { Date(timeIntervalSince1970: TimeInterval($0)).formatted(date: .long, time: .omitted) } let length = current.duration.map { clock($0) } return Text([feed?.name, number.isEmpty ? nil : number, date, length] .compactMap { $0 }.joined(separator: " ยท ")) .font(.caption).foregroundStyle(Glass.faint) } @ViewBuilder private var files: some View { if !current.enclosures.isEmpty { VStack(spacing: 8) { ForEach(current.enclosures) { file in row(file) } } } } private func row(_ file: IPX.Enclosure) -> some View { HStack(spacing: 10) { Image(systemName: symbol(file)) .foregroundStyle(file.isDownloaded ? Glass.good : file.state == "error" ? Glass.bad : Glass.faint) Text(size(file)).font(.caption).foregroundStyle(Glass.dim) if let error = file.lastError, file.state == "error" { Text(error).font(.caption2).foregroundStyle(Glass.bad).lineLimit(1) } Spacer() if file.isPlayable { Button { play(current, file) } label: { Image(systemName: "play.fill") } .buttonStyle(.borderless).accessibilityLabel("Play") } else if !file.isDownloaded { Button { download(file) } label: { Image(systemName: "arrow.down.circle") } .buttonStyle(.borderless).accessibilityLabel("Download to the server") } if file.isDownloaded { Button { confirmingDelete = file } label: { Image(systemName: "trash") } .buttonStyle(.borderless).foregroundStyle(Glass.bad) .accessibilityLabel("Delete file") } } .padding(.horizontal, 12).padding(.vertical, 9) .glassPane() .clipShape(RoundedRectangle(cornerRadius: Glass.Radius.control, style: .continuous)) } private func symbol(_ file: IPX.Enclosure) -> String { let mime = (file.mime ?? "").lowercased() if mime.hasPrefix("audio/") { return "headphones" } if mime.hasPrefix("video/") { return "film" } if mime.hasPrefix("image/") { return "photo" } if mime.contains("pdf") { return "doc.richtext" } if mime.contains("torrent") { return "link" } return "doc" } private func size(_ file: IPX.Enclosure) -> String { guard let bytes = file.length, bytes > 0 else { return file.state } return ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file) } @ViewBuilder private var notes: some View { let html = (current.description ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if html.isEmpty { Text("No show notes.").font(.callout).italic().foregroundStyle(Glass.faint) } else { ShowNotesView(html: html, baseURL: ServerSettings.base, height: $notesHeight) .frame(height: notesHeight) } } @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItemGroup { Button { store.setRead(current, !current.read) } label: { Image(systemName: current.read ? "envelope.badge" : "checkmark.circle") } .help(current.read ? "Mark unread" : "Mark read") .accessibilityLabel(current.read ? "Mark unread" : "Mark read") Button { store.setPinned(current, !current.flagged) } label: { Image(systemName: current.flagged ? "pin.fill" : "pin") } .help(current.flagged ? "Unpin" : "Pin, so it is never deleted") .accessibilityLabel(current.flagged ? "Unpin" : "Pin") if let link = current.link, let url = URL(string: link) { Link(destination: url) { Image(systemName: "safari") } .help("Open the original") } } } private func download(_ file: IPX.Enclosure) { Task { try? await api.download(enclosure: file.id) } } private func delete(_ file: IPX.Enclosure) { Task { try? await api.deleteFile(enclosure: file.id, force: true) } } private func clock(_ seconds: Int) -> String { let (h, m, s) = (seconds / 3600, (seconds % 3600) / 60, seconds % 60) return h > 0 ? String(format: "%d:%02d:%02d", h, m, s) : String(format: "%d:%02d", m, s) } }