diff --git a/README.md b/README.md index 82cacd0..896a42e 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ For automation, `-ipx.server ` as a launch argument overrides the stored va | `ios/Sources/LibraryStore.swift` | what the lists show, and the optimistic writes | | `ios/Sources/FeedListView.swift` | the sidebar: places, feeds, OPML folders | | `ios/Sources/ItemListView.swift` | the item table: filter, sort, search, paging | +| `ios/Sources/ItemDetailView.swift` | one item: its files, its controls, its notes | +| `ios/Sources/ShowNotesView.swift` | the notes, which stay HTML on purpose | | `ios/Sources/LibraryView.swift` | the two beside each other, bar underneath | | `ios/Sources/RootViewController.swift` | the native interface, with the page a button away | | `ios/Sources/CookieBridge.swift` | `WKHTTPCookieStore` into `HTTPCookieStorage.shared` | diff --git a/ios/Sources/ItemDetailView.swift b/ios/Sources/ItemDetailView.swift new file mode 100644 index 0000000..d750e8f --- /dev/null +++ b/ios/Sources/ItemDetailView.swift @@ -0,0 +1,165 @@ +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) + } +} diff --git a/ios/Sources/ItemListView.swift b/ios/Sources/ItemListView.swift index f321d3e..f1fe491 100644 --- a/ios/Sources/ItemListView.swift +++ b/ios/Sources/ItemListView.swift @@ -4,7 +4,8 @@ import SwiftUI struct ItemListView: View { @ObservedObject var store: LibraryStore @ObservedObject var playback: Playback - var play: (IPX.Entry) -> Void + /// The file asked for, or nil for the item's first playable one. + var play: (IPX.Entry, IPX.Enclosure?) -> Void var body: some View { VStack(spacing: 0) { @@ -52,10 +53,18 @@ struct ItemListView: View { // MARK: - rows private var list: some View { - List(selection: $store.selected) { + // No selection binding: it swallowed the tap before the NavigationLink could act on + // it, so a row highlighted and went nowhere. Navigation is the link's job. + List { ForEach(store.entries) { entry in - row(entry) - .tag(entry.id) + // A destination built here rather than a value matched to one elsewhere: + // the value form pushed nothing and gave no reason, and there is nothing to + // get wrong about a link that carries its own destination. + NavigationLink { + ItemDetailView(store: store, playback: playback, entry: entry, play: play) + } label: { + row(entry) + } .onAppear { // Paging by the last row rather than a button: fifty at a time is the // server's page size, and a long list should not need a tap to continue. @@ -117,7 +126,7 @@ struct ItemListView: View { Image(systemName: "pin.fill").font(.caption2).foregroundStyle(Glass.accent) } if entry.playable != nil { - Button { play(entry) } label: { Image(systemName: "play.circle") } + Button { play(entry, nil) } label: { Image(systemName: "play.circle") } .buttonStyle(.plain).foregroundStyle(Glass.accent) .accessibilityLabel("Play") } diff --git a/ios/Sources/LibraryView.swift b/ios/Sources/LibraryView.swift index 3380049..72ed8d5 100644 --- a/ios/Sources/LibraryView.swift +++ b/ios/Sources/LibraryView.swift @@ -8,7 +8,8 @@ import SwiftUI struct LibraryView: View { @ObservedObject var store: LibraryStore @ObservedObject var playback: Playback - var play: (IPX.Entry) -> Void + /// The file is the one asked for, or the item's first playable one. + var play: (IPX.Entry, IPX.Enclosure?) -> Void var openPage: () -> Void var body: some View { diff --git a/ios/Sources/RootViewController.swift b/ios/Sources/RootViewController.swift index 7a794c9..d27ee35 100644 --- a/ios/Sources/RootViewController.swift +++ b/ios/Sources/RootViewController.swift @@ -33,7 +33,7 @@ final class RootViewController: UIViewController { let root = LibraryView( store: store, playback: playback, - play: { [weak self] entry in self?.play(entry) }, + play: { [weak self] entry, file in self?.play(entry, file) }, openPage: { [weak self] in self?.showPage() }) .environment(\.api, api) @@ -64,8 +64,9 @@ final class RootViewController: UIViewController { } /// Plays a row through the same Playback the lock screen and the car drive. - private func play(_ entry: IPX.Entry) { - guard let file = entry.playable, let url = ServerSettings.url("/media/\(file.id)") else { return } + private func play(_ entry: IPX.Entry, _ asked: IPX.Enclosure?) { + guard let file = asked ?? entry.playable, + let url = ServerSettings.url("/media/\(file.id)") else { return } let feed = store.feed(entry.feedId) playback.load(.init( url: url, diff --git a/ios/Sources/ShowNotesView.swift b/ios/Sources/ShowNotesView.swift new file mode 100644 index 0000000..aecfa62 --- /dev/null +++ b/ios/Sources/ShowNotesView.swift @@ -0,0 +1,90 @@ +import SwiftUI +import WebKit + +/// An item's show notes. +/// +/// They are feed-supplied HTML, sanitized server-side with ammonia before they are sent. 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 is a web view carrying only the notes, +/// dressed to match, and that is a deliberate choice rather than a thing left undone. +struct ShowNotesView: UIViewRepresentable { + let html: String + let baseURL: URL? + /// The notes size themselves; the pane scrolls as one, so the view grows to fit. + @Binding var height: CGFloat + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeUIView(context: Context) -> WKWebView { + let cfg = WKWebViewConfiguration() + // Cookies, so a picture behind the same sign-in loads. + cfg.websiteDataStore = .default() + let web = WKWebView(frame: .zero, configuration: cfg) + web.navigationDelegate = context.coordinator + web.scrollView.isScrollEnabled = false + web.isOpaque = false + web.backgroundColor = .clear + web.scrollView.backgroundColor = .clear + return web + } + + func updateUIView(_ web: WKWebView, context: Context) { + guard context.coordinator.shown != html else { return } + context.coordinator.shown = html + web.loadHTMLString(page(for: context.environment.colorScheme), baseURL: baseURL) + } + + /// The notes wrapped in just enough stylesheet to belong to the app: the system font at the + /// body size, the label colours, and links in the accent. Nothing else is imposed -- the + /// markup is the publisher's and should read as they wrote it. + private func page(for scheme: ColorScheme) -> String { + let dark = scheme == .dark + let fg = dark ? "#f5f5f7" : "#1d1d1f" + let dim = dark ? "#aeaeb2" : "#515154" + let link = dark ? "#409cff" : "#0055aa" + let rule = dark ? "rgba(255,255,255,.14)" : "rgba(0,0,0,.12)" + return """ + + + \(html) + """ + } + + final class Coordinator: NSObject, WKNavigationDelegate { + private let parent: ShowNotesView + var shown: String? + + init(_ parent: ShowNotesView) { self.parent = parent } + + func webView(_ web: WKWebView, didFinish navigation: WKNavigation!) { + // Measure once it has laid out, so the pane can give it the room it asked for. + web.evaluateJavaScript("document.body.scrollHeight") { value, _ in + if let h = value as? CGFloat { self.parent.height = max(h, 1) } + } + } + + /// A link in the notes opens in Safari. Following one inside this view would replace the + /// notes with somebody's website and leave no way back. + func webView(_ web: WKWebView, + decidePolicyFor action: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + guard action.navigationType == .linkActivated, let url = action.request.url else { + return decisionHandler(.allow) + } + UIApplication.shared.open(url) + decisionHandler(.cancel) + } + } +} diff --git a/ios/UITests/PlaybackTests.swift b/ios/UITests/PlaybackTests.swift index 1ad2cf1..24ad6cc 100644 --- a/ios/UITests/PlaybackTests.swift +++ b/ios/UITests/PlaybackTests.swift @@ -79,3 +79,30 @@ final class PlaybackTests: XCTestCase { return false } } + +/// Opening an item, which is a push away from the list and has to show what is in it. +final class ItemPaneTests: XCTestCase { + func testOpeningAnItemShowsItsFilesAndNotes() throws { + let server = ProcessInfo.processInfo.environment["IPX_SERVER"] ?? "" + try XCTSkipIf(server.isEmpty, "set TEST_RUNNER_IPX_SERVER to a fixture daemon") + + let app = XCUIApplication() + app.launchArguments = ["-ipx.server", server] + app.launch() + + XCTAssertTrue(app.staticTexts["Second Episode"].firstMatch.waitForExistence(timeout: 30), + "the list never appeared") + // The row, not its title: tapping the label does not activate the link, and with a + // selection binding on the list the tap was swallowed before the link ever saw it. + app.cells.element(boundBy: 0).tap() + + // The pane is titled with the feed, and carries the item's file and its notes. + XCTAssertTrue(app.staticTexts["Notes for the second."].waitForExistence(timeout: 15), + "the show notes did not render") + XCTAssertTrue(app.buttons["Delete file"].firstMatch.exists, + "the downloaded file is not offered for deletion") + XCTAssertTrue(app.buttons["Mark unread"].firstMatch.exists + || app.buttons["Mark read"].firstMatch.exists, + "no read control in the pane") + } +}