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: KeyHostingController! private let showingKeysFlag = Flag() private let sidebarFlag = Flag() /// Small boxes so a UIKit key handler can move a SwiftUI @Published value. final class Flag: ObservableObject { @Published var on = false } private var showingKeys: Bool { get { showingKeysFlag.on } set { showingKeysFlag.on = newValue } } private var showingSidebar: Bool { get { sidebarFlag.on } set { sidebarFlag.on = newValue } } 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] screen in self?.showPage(screen) }, keys: showingKeysFlag, sidebar: sidebarFlag) .environment(\.api, api) // The keys hang off the hosting controller rather than this one. SwiftUI's views hold // first responder, and a keyCommands override up here was simply never consulted -- it // failed by doing nothing, which is the hardest kind of wrong to notice. host = KeyHostingController(rootView: AnyView(root)) host.onKey = { [weak self] input in self?.key(input) } 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 } } // MARK: - the keyboard /// When a g was pressed, so the next key can be read as the second half of a pair. private var gAt: Date? private func key(_ input: String) { // A pair only counts within a second and a half of the g, as in the page. let warm = gAt.map { Date().timeIntervalSince($0) < Keys.pairWindow } ?? false gAt = nil guard let action = Keys.action(for: input, afterG: warm) else { return } switch action { case .startPair: gAt = Date() case .nextItem: store.step(1) case .previousItem: store.step(-1) case .nextFeed: store.stepFeed(1, within: places) case .previousFeed: store.stepFeed(-1, within: places) case .play: if let e = store.selectedEntryLive { play(e, nil) } case .toggleRead: if let e = store.selectedEntryLive { store.setRead(e, !e.read) } case .togglePin: if let e = store.selectedEntryLive { store.setPinned(e, !e.flagged) } case .openOriginal: if let link = store.selectedEntryLive?.link, let url = URL(string: link) { UIApplication.shared.open(url) } case .readAll: store.readAll() case .refresh: Task { await store.refreshFeeds(); store.reload() } case .toggleSidebar: showingSidebar.toggle() case .shortcuts: showingKeys = true case .playPause: playback.playing ? playback.pause() : playback.play() case .back15: playback.seek(to: playback.elapsed - 15) case .forward30: playback.seek(to: playback.elapsed + 30) case .go(let where_): switch where_ { case .all: store.place = .all case .listening: store.place = .listening case .directory: showPage(.directory) case .popular: showPage(.popular) case .settings: showPage(.settings) } } } /// The feeds in the order the sidebar lists them, so J and K move the way the eye does. private var places: [LibraryStore.Place] { [.all, .listening] + store.feeds .sorted { ($0.pinned ? 0 : 1, $0.name.lowercased()) < ($1.pinned ? 0 : 1, $1.name.lowercased()) } .map { .feed($0.id) } } /// 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(_ screen: WebViewController.Screen) { page.open(screen) 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) } }