Files
ipodderx-app/ios/Sources/RootViewController.swift
Ray Slakinski 33c3790fe3 Settings, admin, the directory and OPML open in the page (#5)
Each is named in the toolbar's menu rather than hidden behind one button
that says "page": reaching Settings here should be no harder than in a
browser. Admin appears only for an admin, because the server sends that
page to admins alone and a link for anyone else leads to a refusal.

Settings, the directory, Popular and OPML are dialogs the page opens by
name rather than routes of their own, so they are reached by calling them
once it has loaded -- prefsModal(), opmlModal(), selectFeed(':directory').
Asking a moment after the load avoids racing the page wiring them up. The
admin page is a real route and just loads. Asking for the screen already
showing runs the script without reloading, or the page would reload to sit
exactly where it already was.

This is a decision as much as a change, and worth writing down: the goal
was never a native app with no web view in it. These screens are
form-heavy, rarely opened, admin-gated in places, and they work. Rewriting
them would have been the largest part of the job for the smallest return.

The test opens the menu, chooses Settings, and looks for the page's own
dialog, so a menu that opens the page but not the thing asked for fails
rather than passing. It also found that the menu had no accessibility
label, which it should have had anyway.

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

98 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] screen in self?.showPage(screen) })
.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(_ 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)
}
}