Files
ipodderx-app/ios/Sources/ItemDetailView.swift
Ray Slakinski 292941524e A native item pane, with the show notes still HTML (#4)
The reading pane: the item, its files with play, download and delete, read
and pin in the toolbar, a link to the original, and the notes.

The notes are a web view, deliberately. They are feed-supplied HTML that
ipx has already run through ammonia, and there is no good native renderer
for that -- NSAttributedString(html:) is slow, single-threaded and ugly,
and writing a real one is a project. So this carries only the notes, with
just enough stylesheet to belong to the app: the system font at body size,
the label colours, links in the accent. Nothing else is imposed, because
the markup is the publisher's. A link opens in Safari rather than inside
the view, where it would replace the notes with somebody's website and
leave no way back.

Deleting asks first, and says when the file is shared: one file serves
everyone reading the feed, so removing it is not a private act.

Two goes at the navigation. A row with a selection binding on the list
highlighted and went nowhere -- the binding takes the tap before the link
sees it. Without the binding, NavigationLink(value:) with a matching
navigationDestination still pushed nothing and gave no reason, so the link
carries its own destination now, which has nothing to get wrong.

Fourteen tests pass. The new one opens an item and looks for its notes, its
delete button and its read control, so a pane that renders empty fails
rather than passing quietly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 09:34:34 -04:00

166 lines
6.7 KiB
Swift

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)
}
}