Zooming left the page filling four fifths of the window, and less as the zoom went up: at 1.6 it sat in the left three fifths with the rest of the window empty. body carried width:calc(100%/zoom). Under CSS zoom a percentage already resolves against the containing block in the zoomed element's own units, so 100% is the full width and dividing it again shrank the page by the zoom a second time. A block fills its container without being told to, so the declaration is gone. Viewport units are the exception -- dvh does not know about zoom -- which is why the height still has to be divided back out. Measured at 1.25, 1.6 and 2: body is exactly the window's 2275 by 1427 in each, and the document overflows by nothing in either direction. The first time round only the overflow was checked, which a page that under-fills passes just as happily as one that fits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
286 lines
13 KiB
Swift
286 lines
13 KiB
Swift
import UIKit
|
|
import WebKit
|
|
|
|
/// The app, very nearly: ipx's own page, full screen.
|
|
///
|
|
/// What it adds is the bridge to the host's player, a way to say which server, and a line of
|
|
/// honesty when something is wrong.
|
|
final class WebViewController: UIViewController, WKNavigationDelegate {
|
|
private var webView: WKWebView!
|
|
private var cookies: CookieBridge!
|
|
private var library: Library!
|
|
private var playback: Playback!
|
|
private var bridge: Bridge!
|
|
|
|
private let banner = UIView()
|
|
private let bannerText = UILabel()
|
|
private var checkedBridge = false
|
|
/// The page sits below the banner when there is one, and at the top when there is not. Two
|
|
/// constraints rather than an overlay: the banner covered the page's own toolbar, and ipx
|
|
/// lays that out itself, so there is nothing to scroll out of the way.
|
|
private var pageBelowBanner: NSLayoutConstraint!
|
|
private var pageAtTop: NSLayoutConstraint!
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
view.backgroundColor = .black
|
|
|
|
let cfg = WKWebViewConfiguration()
|
|
// The persistent store, so a sign-in through Cloudflare Access survives a relaunch --
|
|
// and so there are cookies for the player to use at all.
|
|
cfg.websiteDataStore = .default()
|
|
cfg.allowsInlineMediaPlayback = true
|
|
// Video still plays in the page, and it should not need a second tap to start.
|
|
cfg.mediaTypesRequiringUserActionForPlayback = []
|
|
|
|
webView = WKWebView(frame: .zero, configuration: cfg)
|
|
webView.navigationDelegate = self
|
|
webView.allowsBackForwardNavigationGestures = false
|
|
webView.scrollView.contentInsetAdjustmentBehavior = .never
|
|
webView.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(webView)
|
|
pageAtTop = webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
|
|
NSLayoutConstraint.activate([
|
|
pageAtTop,
|
|
webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
])
|
|
|
|
cookies = CookieBridge(store: cfg.websiteDataStore.httpCookieStore)
|
|
library = Library(cookies: cookies)
|
|
playback = Playback(cookies: cookies, library: library)
|
|
bridge = Bridge(webView: webView, playback: playback)
|
|
bridge.onReady = { [weak self] _ in
|
|
self?.checkedBridge = true
|
|
self?.setBanner(nil)
|
|
}
|
|
cfg.userContentController.add(bridge, name: Bridge.handlerName)
|
|
Zoom.install(into: cfg.userContentController)
|
|
|
|
setUpBanner()
|
|
|
|
if ServerSettings.isConfigured { load() }
|
|
}
|
|
|
|
// MARK: - how big the page is drawn
|
|
|
|
/// The page is sized for a phone and for a browser window, and on a Mac -- where the window
|
|
/// is often two thousand points across -- it reads small. Scaling it here rather than asking
|
|
/// ipx to carry a text size: the page is right everywhere else, and this is the one place
|
|
/// that is wrong.
|
|
enum Zoom {
|
|
private static let key = "ipx.zoom"
|
|
static let steps: [Double] = [1.0, 1.15, 1.25, 1.4, 1.6, 1.8, 2.0]
|
|
|
|
/// A Mac starts bigger; a phone and an iPad are already right.
|
|
static var normal: Double {
|
|
#if targetEnvironment(macCatalyst)
|
|
return 1.25
|
|
#else
|
|
return 1.0
|
|
#endif
|
|
}
|
|
|
|
static var current: Double {
|
|
get {
|
|
let v = UserDefaults.standard.double(forKey: key)
|
|
return steps.contains(v) ? v : normal
|
|
}
|
|
set { UserDefaults.standard.set(newValue, forKey: key) }
|
|
}
|
|
|
|
/// pageZoom alone spilled the page off the screen: ipx's body is `height:100dvh`, and
|
|
/// WebKit measures dvh against the unzoomed window, so the page laid itself out at the
|
|
/// window's full size and was then scaled up. A percentage of the root resolves against
|
|
/// a box that does know about zoom, which is the whole of the fix.
|
|
/// CSS zoom, not WKWebView.pageZoom. pageZoom scales what is drawn but leaves the
|
|
/// layout box the size of the window, so ipx's full-height page -- body is 100dvh --
|
|
/// laid itself out at the window's height and then got scaled past the bottom of it.
|
|
/// CSS zoom is part of layout, so the page measures itself correctly; body's own width
|
|
/// and height are divided back out because its containing block is not zoomed.
|
|
static func script(_ zoom: Double) -> String {
|
|
// No width. A percentage resolves against the containing block in the zoomed
|
|
// element's own units, so 100% is already the full width -- dividing it again left
|
|
// the page filling four fifths of the window and then three fifths as the zoom went
|
|
// up. A block fills its container by itself. dvh is the exception: viewport units do
|
|
// not know about zoom, so the height does have to be divided back out.
|
|
let css = zoom == 1 ? "" : "body{zoom:\(zoom);height:calc(100dvh/\(zoom))!important}"
|
|
return "(function(){var id='ipx-zoom';var s=document.getElementById(id);"
|
|
+ "if(!s){s=document.createElement('style');s.id=id;"
|
|
+ "(document.head||document.documentElement).appendChild(s)}"
|
|
+ "s.textContent=\(css.debugDescription)})()"
|
|
}
|
|
|
|
static func install(into controller: WKUserContentController) {
|
|
controller.addUserScript(WKUserScript(source: script(current),
|
|
injectionTime: .atDocumentEnd,
|
|
forMainFrameOnly: true))
|
|
}
|
|
}
|
|
|
|
override var keyCommands: [UIKeyCommand]? {
|
|
// The page's own shortcuts are single keys and ignore a modifier, so these do not collide.
|
|
[
|
|
UIKeyCommand(title: "Bigger", action: #selector(zoomIn), input: "+", modifierFlags: .command),
|
|
UIKeyCommand(title: "Bigger", action: #selector(zoomIn), input: "=", modifierFlags: .command),
|
|
UIKeyCommand(title: "Smaller", action: #selector(zoomOut), input: "-", modifierFlags: .command),
|
|
UIKeyCommand(title: "Actual Size", action: #selector(zoomReset), input: "0", modifierFlags: .command),
|
|
]
|
|
}
|
|
|
|
@objc private func zoomIn() { step(+1) }
|
|
@objc private func zoomOut() { step(-1) }
|
|
@objc private func zoomReset() { apply(Zoom.normal) }
|
|
|
|
private func step(_ by: Int) {
|
|
let steps = Zoom.steps
|
|
let at = steps.firstIndex(of: Zoom.current) ?? 0
|
|
apply(steps[min(steps.count - 1, max(0, at + by))])
|
|
}
|
|
|
|
private func apply(_ zoom: Double) {
|
|
Zoom.current = zoom
|
|
webView.evaluateJavaScript(Zoom.script(zoom))
|
|
}
|
|
|
|
// MARK: - which server
|
|
|
|
/// Shake to change it. There is nowhere on screen to put a button -- the page fills it, and
|
|
/// this is a setting touched about once -- so the way back is a gesture and the banner.
|
|
override var canBecomeFirstResponder: Bool { true }
|
|
override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
becomeFirstResponder()
|
|
// Not from viewDidLoad: presenting before the view is in a window gets you a blank screen
|
|
// and no error, which is exactly what it did.
|
|
if !ServerSettings.isConfigured && presentedViewController == nil {
|
|
showSetup(animated: animated)
|
|
}
|
|
}
|
|
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
|
|
if motion == .motionShake && presentedViewController == nil { showSetup(animated: true) }
|
|
}
|
|
|
|
@objc private func showSetup() { showSetup(animated: true) }
|
|
|
|
private func showSetup(animated: Bool) {
|
|
let setup = ServerSetupViewController(first: !ServerSettings.isConfigured)
|
|
setup.onSave = { [weak self] _ in
|
|
self?.dismiss(animated: true)
|
|
self?.setBanner(nil)
|
|
self?.playback.stop()
|
|
self?.load()
|
|
}
|
|
let nav = UINavigationController(rootViewController: setup)
|
|
// A first run has nothing behind it, so it cannot be swiped away unanswered.
|
|
nav.isModalInPresentation = !ServerSettings.isConfigured
|
|
present(nav, animated: animated)
|
|
}
|
|
|
|
private func load() {
|
|
guard let base = ServerSettings.base else { return showSetup(animated: true) }
|
|
// Sync first: the page is fetched with the cookies too, and without them the first
|
|
// request is a redirect to a sign-in that is not actually needed.
|
|
cookies.sync { [weak self] in
|
|
DispatchQueue.main.async { self?.webView.load(URLRequest(url: base)) }
|
|
}
|
|
}
|
|
|
|
// MARK: - navigation
|
|
|
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
// The bridge announces itself on load. A server whose page predates it never will, and the
|
|
// app would otherwise look identical while playing nothing in the background.
|
|
checkedBridge = false
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
|
guard let self, !self.checkedBridge else { return }
|
|
self.webView.evaluateJavaScript("!!(window.ipxNative)") { got, _ in
|
|
if (got as? Bool) != true {
|
|
self.setBanner("This server's page has no native bridge. Playback stays in the app; "
|
|
+ "background and car playback are off.")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
|
showFailure(error)
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
showFailure(error)
|
|
}
|
|
|
|
private func showFailure(_ error: Error) {
|
|
// -999 is a load cancelled because another started, which is not a failure to report.
|
|
if (error as NSError).code == NSURLErrorCancelled { return }
|
|
let host = ServerSettings.base?.host ?? "the server"
|
|
setBanner("Cannot reach \(host): \(error.localizedDescription)")
|
|
}
|
|
|
|
// MARK: - the banner
|
|
|
|
private func setUpBanner() {
|
|
banner.backgroundColor = UIColor(red: 0.55, green: 0.12, blue: 0.12, alpha: 1)
|
|
banner.isHidden = true
|
|
banner.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
bannerText.numberOfLines = 0
|
|
bannerText.font = .preferredFont(forTextStyle: .footnote)
|
|
bannerText.textColor = .white
|
|
|
|
let change = UIButton(type: .system)
|
|
change.setTitle("Server", for: .normal)
|
|
change.titleLabel?.font = .preferredFont(forTextStyle: .footnote)
|
|
change.tintColor = .white
|
|
change.setContentHuggingPriority(.required, for: .horizontal)
|
|
// Without this the label takes the whole row and the button truncates to an ellipsis,
|
|
// which is not a thing anyone will tap.
|
|
change.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
change.addTarget(self, action: #selector(showSetup as () -> Void), for: .touchUpInside)
|
|
|
|
let dismiss = UIButton(type: .system)
|
|
dismiss.setImage(UIImage(systemName: "xmark"), for: .normal)
|
|
dismiss.tintColor = .white
|
|
dismiss.accessibilityLabel = "Hide this message"
|
|
dismiss.setContentHuggingPriority(.required, for: .horizontal)
|
|
dismiss.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
dismiss.addTarget(self, action: #selector(hideBanner), for: .touchUpInside)
|
|
|
|
let row = UIStackView(arrangedSubviews: [bannerText, change, dismiss])
|
|
row.spacing = 12
|
|
row.alignment = .center
|
|
row.isLayoutMarginsRelativeArrangement = true
|
|
row.layoutMargins = .init(top: 8, left: 12, bottom: 8, right: 12)
|
|
row.translatesAutoresizingMaskIntoConstraints = false
|
|
banner.addSubview(row)
|
|
|
|
view.addSubview(banner)
|
|
pageBelowBanner = webView.topAnchor.constraint(equalTo: banner.bottomAnchor)
|
|
NSLayoutConstraint.activate([
|
|
banner.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
|
banner.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
banner.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
row.topAnchor.constraint(equalTo: banner.topAnchor),
|
|
row.bottomAnchor.constraint(equalTo: banner.bottomAnchor),
|
|
row.leadingAnchor.constraint(equalTo: banner.leadingAnchor),
|
|
row.trailingAnchor.constraint(equalTo: banner.trailingAnchor),
|
|
])
|
|
}
|
|
|
|
/// Dismissed by hand. It says something true, but it says it once -- a permanent stripe
|
|
/// across a page that otherwise works is worse than the thing it is warning about.
|
|
@objc private func hideBanner() { setBanner(nil) }
|
|
|
|
private func setBanner(_ text: String?) {
|
|
bannerText.text = text
|
|
banner.isHidden = text == nil
|
|
pageAtTop.isActive = text == nil
|
|
pageBelowBanner.isActive = text != nil
|
|
view.layoutIfNeeded()
|
|
}
|
|
|
|
override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent }
|
|
}
|