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 { let css = zoom == 1 ? "" : "body{zoom:\(zoom);width:calc(100%/\(zoom))!important;" + "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 } }