iPodderX for iPhone, iPad and Mac
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>
This commit is contained in:
79
ios/Sources/Library.swift
Normal file
79
ios/Sources/Library.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// The bits of ipx's API the native side writes back to, and the artwork it shows.
|
||||
///
|
||||
/// Small on purpose. The page does the browsing; this is only what the host has to do when the
|
||||
/// page cannot -- which is any time the screen is locked, and all the time in CarPlay.
|
||||
final class Library {
|
||||
private let cookies: CookieBridge
|
||||
private let session: URLSession
|
||||
|
||||
init(cookies: CookieBridge) {
|
||||
self.cookies = cookies
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.httpCookieStorage = HTTPCookieStorage.shared
|
||||
cfg.httpShouldSetCookies = true
|
||||
cfg.waitsForConnectivity = true
|
||||
self.session = URLSession(configuration: cfg)
|
||||
}
|
||||
|
||||
struct Unauthorized: Error {}
|
||||
|
||||
/// Where you are in an episode. The page sends this too, but only while it is running:
|
||||
/// a backgrounded web view is frozen, and this is the drive it would miss.
|
||||
func savePosition(feedId: String, guid: String, secs: Int, duration: Int?) {
|
||||
post("/api/entries/\(esc(feedId))/\(esc(guid))/position", position(secs, duration))
|
||||
}
|
||||
|
||||
func markRead(feedId: String, guid: String) {
|
||||
post("/api/entries/\(esc(feedId))/\(esc(guid))/flags", ["read": true])
|
||||
}
|
||||
|
||||
/// A path the page handed over, used as-is. It already names the right item, and rebuilding it
|
||||
/// here would be a second place for the escaping to be wrong.
|
||||
func savePosition(path: String, secs: Int, duration: Int?) {
|
||||
post(path, position(secs, duration))
|
||||
}
|
||||
|
||||
/// `duration` is left out when there is none rather than sent as null: it is Option<i64> on
|
||||
/// the server either way, and `duration as Any` on a nil Optional is an Any wrapping nil,
|
||||
/// which JSONSerialization refuses -- leaving the request with no body at all.
|
||||
private func position(_ secs: Int, _ duration: Int?) -> [String: Any] {
|
||||
var body: [String: Any] = ["secs": secs]
|
||||
if let duration { body["duration"] = duration }
|
||||
return body
|
||||
}
|
||||
|
||||
func artwork(_ path: String, done: @escaping (UIImage?) -> Void) {
|
||||
guard let url = ServerSettings.url(path) else { return done(nil) }
|
||||
session.dataTask(with: url) { data, _, _ in
|
||||
done(data.flatMap(UIImage.init(data:)))
|
||||
}.resume()
|
||||
}
|
||||
|
||||
/// Whether the session is still good, so a failure can say "sign in again" rather than stall.
|
||||
func check(done: @escaping (Bool) -> Void) {
|
||||
guard let url = ServerSettings.url("/api/me") else { return done(false) }
|
||||
session.dataTask(with: url) { _, resp, _ in
|
||||
done((resp as? HTTPURLResponse)?.statusCode == 200)
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private func post(_ path: String, _ body: [String: Any]) {
|
||||
guard let url = ServerSettings.url(path) else { return }
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||
session.dataTask(with: req) { _, resp, err in
|
||||
if let code = (resp as? HTTPURLResponse)?.statusCode, code >= 400 {
|
||||
NSLog("ipx: %@ -> %d", path, code)
|
||||
} else if let err { NSLog("ipx: %@ -> %@", path, err.localizedDescription) }
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private func esc(_ s: String) -> String {
|
||||
s.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(.init(charactersIn: "-._~"))) ?? s
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user