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>
97 lines
4.1 KiB
Swift
97 lines
4.1 KiB
Swift
import Combine
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
/// What the window holds: the native interface, with ipx's page a button away.
|
|
///
|
|
/// The page is not a fallback here, it is the rest of the app. Settings, the admin page, the
|
|
/// directory and OPML have never been native and are not meant to be (#5), and an item's show
|
|
/// notes are still HTML (#4). Both live behind the same web view that used to be the whole
|
|
/// interface, so nothing has been lost by putting a list in front of it.
|
|
final class RootViewController: UIViewController {
|
|
private let api = API()
|
|
private var store: LibraryStore!
|
|
private var cookies: CookieBridge!
|
|
private var playback: Playback!
|
|
private var page: WebViewController!
|
|
private var host: UIHostingController<AnyView>!
|
|
private var watching: AnyCancellable?
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
view.backgroundColor = .black
|
|
|
|
// The page is built first and kept: it owns the web view whose cookies authenticate
|
|
// everything, including the API client and the player. Signing in happens there.
|
|
page = WebViewController()
|
|
page.loadViewIfNeeded()
|
|
cookies = page.cookieBridge
|
|
playback = page.player
|
|
|
|
store = LibraryStore(api: api)
|
|
|
|
let root = LibraryView(
|
|
store: store,
|
|
playback: playback,
|
|
play: { [weak self] entry, file in self?.play(entry, file) },
|
|
openPage: { [weak self] in self?.showPage() })
|
|
.environment(\.api, api)
|
|
|
|
host = UIHostingController(rootView: AnyView(root))
|
|
addChild(host)
|
|
host.view.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(host.view)
|
|
NSLayoutConstraint.activate([
|
|
host.view.topAnchor.constraint(equalTo: view.topAnchor),
|
|
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
])
|
|
host.didMove(toParent: self)
|
|
|
|
// preferredColorScheme inside a hosting controller does not reliably reach the whole
|
|
// hierarchy -- the list stayed light against a dark account. Overriding the style on the
|
|
// controller does, and it carries to the page presented over it as well.
|
|
watching = store.$appearance.sink { [weak self] scheme in
|
|
let style: UIUserInterfaceStyle
|
|
switch scheme {
|
|
case .some(.dark): style = .dark
|
|
case .some(.light): style = .light
|
|
default: style = .unspecified
|
|
}
|
|
self?.overrideUserInterfaceStyle = style
|
|
}
|
|
}
|
|
|
|
/// Plays a row through the same Playback the lock screen and the car drive.
|
|
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,
|
|
feedId: entry.feedId,
|
|
guid: entry.guid,
|
|
title: entry.title ?? "",
|
|
feedTitle: feed?.name ?? entry.feedId,
|
|
artwork: entry.image ?? feed?.image,
|
|
position: entry.position,
|
|
duration: entry.duration))
|
|
// Where it starts is the entry's own position, since there is no page here to decide it.
|
|
if entry.position > 5 { playback.seek(to: Double(entry.position)) }
|
|
playback.play()
|
|
}
|
|
|
|
private func showPage() {
|
|
guard page.presentingViewController == nil else { return }
|
|
let nav = UINavigationController(rootViewController: page)
|
|
page.navigationItem.leftBarButtonItem = UIBarButtonItem(
|
|
systemItem: .done, primaryAction: UIAction { [weak self] _ in
|
|
self?.dismiss(animated: true)
|
|
// The page may have changed read state or subscriptions while it was open.
|
|
Task { await self?.store.refreshFeeds(); self?.store.reload() }
|
|
})
|
|
present(nav, animated: true)
|
|
}
|
|
}
|