A shell around the ipodderx-rs web UI whose audio is played by the host rather than the page, so it carries on with the screen locked and a car can control it. The page's half of the bridge is web/src/native.ts in ipodderx-rs (#45): it replaces the playback surface of the page's media element with one that posts here, so player.ts is unchanged and the player bar, the row buttons and the keyboard shortcuts work as they always did. Playback, Library, Bridge and CookieBridge never touch a view, which is what makes a CarPlay scene a later addition rather than a rewrite, and what lets the same target build for Mac Catalyst with no conditional code: AVAudioSession, MPNowPlayingInfoCenter and the remote commands all exist there. The Mac gets media keys and Now Playing; it has no CarPlay and no lock screen, so it is a convenience rather than the reason for any of this. Authentication is the web view's. ipx decides who is asking by cookie -- ipx_session, and CF_Authorization from Cloudflare Access in front of the tunnel -- and its auth layer was written so a plain <audio src> would work, which is why the player needs no API of its own. CookieBridge keeps HTTPCookieStorage in step with the web view's store and hands them to each asset. Named ipodderx-app, not -ios: native.ts already carries the branch for an Android host, and the iOS project is one directory rather than the repo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
106 lines
4.1 KiB
Swift
106 lines
4.1 KiB
Swift
import UIKit
|
|
|
|
/// Asks where ipx is. Shown on first run, and again whenever the address needs changing.
|
|
final class ServerSetupViewController: UIViewController, UITextFieldDelegate {
|
|
var onSave: ((URL) -> Void)?
|
|
|
|
private let field = UITextField()
|
|
private let note = UILabel()
|
|
private let first: Bool
|
|
|
|
/// The first run has nothing to go back to, so it gets no Cancel.
|
|
init(first: Bool) {
|
|
self.first = first
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
required init?(coder: NSCoder) { fatalError("not from a storyboard") }
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
view.backgroundColor = .systemBackground
|
|
title = first ? "Where is ipx?" : "Server"
|
|
|
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
|
title: "Save", style: .done, target: self, action: #selector(save))
|
|
if !first {
|
|
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
|
title: "Cancel", style: .plain, target: self, action: #selector(cancel))
|
|
}
|
|
|
|
let blurb = UILabel()
|
|
blurb.text = "The address of your ipx server. Sign in on the page that follows; the app plays "
|
|
+ "what you are signed in to."
|
|
blurb.numberOfLines = 0
|
|
blurb.font = .preferredFont(forTextStyle: .subheadline)
|
|
blurb.textColor = .secondaryLabel
|
|
|
|
field.text = ServerSettings.base?.absoluteString ?? ServerSettings.suggestion
|
|
field.placeholder = ServerSettings.suggestion
|
|
field.borderStyle = .roundedRect
|
|
field.keyboardType = .URL
|
|
field.textContentType = .URL
|
|
field.autocapitalizationType = .none
|
|
field.autocorrectionType = .no
|
|
field.spellCheckingType = .no
|
|
field.clearButtonMode = .whileEditing
|
|
field.returnKeyType = .done
|
|
field.delegate = self
|
|
field.addTarget(self, action: #selector(edited), for: .editingChanged)
|
|
|
|
note.numberOfLines = 0
|
|
note.font = .preferredFont(forTextStyle: .footnote)
|
|
note.textColor = .secondaryLabel
|
|
|
|
let stack = UIStackView(arrangedSubviews: [blurb, field, note])
|
|
stack.axis = .vertical
|
|
stack.spacing = 12
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(stack)
|
|
NSLayoutConstraint.activate([
|
|
stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 24),
|
|
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
|
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
|
|
])
|
|
edited()
|
|
}
|
|
|
|
override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
field.becomeFirstResponder()
|
|
}
|
|
|
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool { save(); return true }
|
|
|
|
/// Says what is wrong while it is still being typed, rather than after a failed load.
|
|
@objc private func edited() {
|
|
let typed = field.text ?? ""
|
|
guard let url = ServerSettings.parse(typed) else {
|
|
note.text = typed.isEmpty ? "" : "That is not a web address."
|
|
note.textColor = .secondaryLabel
|
|
navigationItem.rightBarButtonItem?.isEnabled = false
|
|
return
|
|
}
|
|
navigationItem.rightBarButtonItem?.isEnabled = true
|
|
if url.scheme == "http" && !ServerSettings.isLocal(url) {
|
|
// Saying so here beats a blocked request later that reads like the server is down.
|
|
note.text = "Plain HTTP is only allowed on your own network. This address will not load."
|
|
note.textColor = .systemOrange
|
|
} else if url.scheme == "http" {
|
|
note.text = "On your own network, so plain HTTP is fine."
|
|
note.textColor = .secondaryLabel
|
|
} else {
|
|
note.text = "Will open \(url.absoluteString)"
|
|
note.textColor = .secondaryLabel
|
|
}
|
|
}
|
|
|
|
@objc private func save() {
|
|
guard let url = ServerSettings.parse(field.text ?? "") else { return }
|
|
ServerSettings.base = url
|
|
field.resignFirstResponder()
|
|
onSave?(url)
|
|
}
|
|
|
|
@objc private func cancel() { dismiss(animated: true) }
|
|
}
|