Add iOS app with CarPlay and Siri

SwiftUI port of the Android app under ios/, with CarPlay browse and
Now Playing, Siri play-media intents, and a build-ipa.sh script that
makes an unsigned IPA for sideloading. README gets an iOS section and
the vibecoded disclaimer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-10-20 07:15:47 -07:00
1 parent 45bc0eefe0
commit fdf9e970b2
45 files changed
+9822

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
import CryptoKit
import Foundation
import Network
import Observation
struct HttpError: LocalizedError {
let code: Int
let message: String
var errorDescription: String? { message }
}
/// A thin URLSession wrapper. With [offlineCache] set, successful GETs are kept on disk and
/// answered from there when the network fails, so the library still browses with the server away.
final class Http: @unchecked Sendable {
let session: URLSession
private let cacheDir: URL?
/// When true, GETs never touch the network: cached body or HTTP 504.
var forceCache: (() -> Bool)?
init(connectTimeout: TimeInterval, readTimeout: TimeInterval, offlineCache: URL? = nil, memoryMb: Int = 0) {
let c = URLSessionConfiguration.default
c.timeoutIntervalForRequest = max(connectTimeout, readTimeout)
c.timeoutIntervalForResource = 3600
c.requestCachePolicy = .reloadIgnoringLocalCacheData
c.urlCache = memoryMb > 0 ? URLCache(memoryCapacity: memoryMb << 20, diskCapacity: 0) : nil
c.waitsForConnectivity = false
c.httpMaximumConnectionsPerHost = 8
session = URLSession(configuration: c)
cacheDir = offlineCache
if let d = offlineCache { try? FileManager.default.createDirectory(at: d, withIntermediateDirectories: true) }
}
private func cacheFile(_ req: URLRequest) -> URL? {
guard let dir = cacheDir, req.httpMethod ?? "GET" == "GET", let url = req.url?.absoluteString else { return nil }
var key = "GET " + url
for h in ["X-Plex-Container-Start", "X-Plex-Container-Size"] {
if let v = req.value(forHTTPHeaderField: h) { key += "|\(h)=\(v)" }
}
let hash = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined()
return dir.appendingPathComponent(hash)
}
/// Returns the body of a 2xx response, or throws [HttpError] / the URL error.
func data(_ req: URLRequest) async throws -> Data {
let file = cacheFile(req)
if let file, forceCache?() == true {
if let d = try? Data(contentsOf: file) { return d }
throw HttpError(code: 504, message: "Offline, and not cached")
}
do {
let (data, resp) = try await session.data(for: req)
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
guard (200..<300).contains(code) else {
throw HttpError(code: code, message: "HTTP \(code) from \(req.url?.host ?? "server")")
}
if let file { try? data.write(to: file, options: .atomic) }
return data
} catch let e as URLError {
if e.code != .cancelled, let file, let d = try? Data(contentsOf: file) { return d }
throw e
}
}
func string(_ req: URLRequest) async throws -> String {
String(decoding: try await data(req), as: UTF8.self)
}
/// Status code only; network errors become false.
func ok(_ req: URLRequest) async -> Bool {
guard let (_, resp) = try? await session.data(for: req) else { return false }
return ((resp as? HTTPURLResponse)?.statusCode).map { (200..<300).contains($0) } ?? false
}
}
extension URL {
/// Appends query items, skipping nil values.
func with(_ params: [(String, Any?)]) -> URL {
guard var c = URLComponents(url: self, resolvingAgainstBaseURL: false) else { return self }
var items = c.queryItems ?? []
for (k, v) in params { if let v { items.append(URLQueryItem(name: k, value: "\(v)")) } }
c.queryItems = items.isEmpty ? nil : items
// URLComponents leaves '+' alone, which servers read as a space.
c.percentEncodedQuery = c.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B")
return c.url ?? self
}
}
/// Form-style percent-encoding for a single query value.
func urlEncode(_ s: String) -> String {
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~")
return s.addingPercentEncoding(withAllowedCharacters: allowed) ?? s
}
/// Online / Wi-Fi state, for offline fallbacks and Wi-Fi-only downloads.
@Observable
final class Connectivity: @unchecked Sendable {
private(set) var online = true
private(set) var unmetered = true
@ObservationIgnored private let monitor = NWPathMonitor()
@ObservationIgnored private var listeners: [() -> Void] = []
init() {
monitor.pathUpdateHandler = { [weak self] path in
DispatchQueue.main.async {
guard let self else { return }
let on = path.status == .satisfied
let un = on && !path.isExpensive && !path.isConstrained
if on != self.online || un != self.unmetered {
self.online = on
self.unmetered = un
self.listeners.forEach { $0() }
}
}
}
monitor.start(queue: DispatchQueue(label: "connectivity"))
}
/// Main-thread callback whenever online or unmetered changes.
func onChange(_ f: @escaping () -> Void) { listeners.append(f) }
}
+195
View File
@@ -0,0 +1,195 @@
import Foundation
import Observation
enum ThemeMode: String, CaseIterable, Identifiable {
case Black, Dark, White
var id: String { rawValue }
var label: String { rawValue }
}
/// iOS system tints, light then dark variant.
enum Accent: String, CaseIterable, Identifiable {
case Music, Pink, Orange, Yellow, Green, Teal, Blue, Indigo, Purple
var id: String { rawValue }
var label: String { rawValue }
var light: UInt32 {
switch self {
case .Music: 0xFA2D48
case .Pink: 0xFF2D55
case .Orange: 0xFF9500
case .Yellow: 0xFFCC00
case .Green: 0x34C759
case .Teal: 0x30B0C7
case .Blue: 0x007AFF
case .Indigo: 0x5856D6
case .Purple: 0xAF52DE
}
}
var dark: UInt32 {
switch self {
case .Music: 0xFC3C44
case .Pink: 0xFF375F
case .Orange: 0xFF9F0A
case .Yellow: 0xFFD60A
case .Green: 0x30D158
case .Teal: 0x40C8E0
case .Blue: 0x0A84FF
case .Indigo: 0x5E5CE6
case .Purple: 0xBF5AF2
}
}
}
/// How stems travel from stemserve; Opus is transcoded (and cached) server-side.
enum StemQuality: String, CaseIterable, Identifiable {
case Lossless, High, Standard, Saver
var id: String { rawValue }
var label: String {
switch self {
case .Lossless: "Lossless"
case .High: "High"
case .Standard: "Standard"
case .Saver: "Data Saver"
}
}
var detail: String {
switch self {
case .Lossless: "Original 24-bit FLAC, ~30 MB per stem-minute"
case .High: "Opus 256 kbps per stem"
case .Standard: "Opus 160 kbps per stem"
case .Saver: "Opus 96 kbps per stem"
}
}
var fmt: String { self == .Lossless ? "flac" : "opus" }
var kbps: Int {
switch self {
case .Lossless: 0
case .High: 256
case .Standard: 160
case .Saver: 96
}
}
}
/// UserDefaults-backed settings. Safe to read from any thread (the engine and network code do);
/// SwiftUI views observe them, and [onChange] lets the rest of the app react to writes.
@Observable
final class Settings: @unchecked Sendable {
@ObservationIgnored private let defaults: UserDefaults
@ObservationIgnored private let lock = NSLock()
@ObservationIgnored private var store: [String: Any] = [:]
@ObservationIgnored private var listeners: [String: [() -> Void]] = [:]
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
for key in Self.keys {
if let v = defaults.object(forKey: key) { store[key] = v }
}
if let id = defaults.string(forKey: "clientId") {
clientId = id
} else {
clientId = UUID().uuidString.lowercased()
defaults.set(clientId, forKey: "clientId")
}
}
private static let keys = [
"plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
"stemUrl", "stemToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate",
"stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume",
]
private func read<T>(_ kp: KeyPath<Settings, T>, _ key: String, _ def: T) -> T {
access(keyPath: kp)
lock.lock()
defer { lock.unlock() }
return store[key] as? T ?? def
}
private func write<T: Equatable>(_ kp: KeyPath<Settings, T>, _ key: String, _ v: T, _ def: T) {
lock.lock()
let old = store[key] as? T ?? def
lock.unlock()
if old == v { return }
withMutation(keyPath: kp) {
lock.lock()
store[key] = v
lock.unlock()
}
defaults.set(v, forKey: key)
notify(key)
}
private func readEnum<E: RawRepresentable>(_ kp: KeyPath<Settings, E>, _ key: String, _ def: E) -> E where E.RawValue == String {
access(keyPath: kp)
lock.lock()
defer { lock.unlock() }
return (store[key] as? String).flatMap(E.init(rawValue:)) ?? def
}
private func writeEnum<E: RawRepresentable & Equatable>(_ kp: KeyPath<Settings, E>, _ key: String, _ v: E, _ def: E) where E.RawValue == String {
lock.lock()
let old = (store[key] as? String).flatMap(E.init(rawValue:)) ?? def
lock.unlock()
if old == v { return }
withMutation(keyPath: kp) {
lock.lock()
store[key] = v.rawValue
lock.unlock()
}
defaults.set(v.rawValue, forKey: key)
notify(key)
}
private func notify(_ key: String) {
let run = {
self.lock.lock()
let fs = self.listeners[key] ?? []
self.lock.unlock()
fs.forEach { $0() }
}
if Thread.isMainThread { run() } else { DispatchQueue.main.async(execute: run) }
}
/// Calls [f] on the main thread after any of [keys] changes.
func onChange(_ keys: String..., f: @escaping () -> Void) {
lock.lock()
for k in keys { listeners[k, default: []].append(f) }
lock.unlock()
}
var plexUrl: String { get { read(\.plexUrl, "plexUrl", "http://192.168.86.2:32400") } set { write(\.plexUrl, "plexUrl", newValue, "http://192.168.86.2:32400") } }
var plexToken: String { get { read(\.plexToken, "plexToken", "") } set { write(\.plexToken, "plexToken", newValue, "") } }
var plexUser: String { get { read(\.plexUser, "plexUser", "") } set { write(\.plexUser, "plexUser", newValue, "") } }
var plexMachineId: String { get { read(\.plexMachineId, "plexMachineId", "") } set { write(\.plexMachineId, "plexMachineId", newValue, "") } }
var musicSection: String { get { read(\.musicSection, "musicSection", "") } set { write(\.musicSection, "musicSection", newValue, "") } }
var musicSectionTitle: String { get { read(\.musicSectionTitle, "musicSectionTitle", "") } set { write(\.musicSectionTitle, "musicSectionTitle", newValue, "") } }
var stemUrl: String { get { read(\.stemUrl, "stemUrl", "http://192.168.86.2:8765") } set { write(\.stemUrl, "stemUrl", newValue, "http://192.168.86.2:8765") } }
var stemToken: String { get { read(\.stemToken, "stemToken", "") } set { write(\.stemToken, "stemToken", newValue, "") } }
/// Plex path prefixes that matched the stems index, newline-separated.
var stemRoots: String { get { read(\.stemRoots, "stemRoots", "") } set { write(\.stemRoots, "stemRoots", newValue, "") } }
var theme: ThemeMode { get { readEnum(\.theme, "theme", .Black) } set { writeEnum(\.theme, "theme", newValue, .Black) } }
var accent: Accent { get { readEnum(\.accent, "accent", .Music) } set { writeEnum(\.accent, "accent", newValue, .Music) } }
var albumColorNowPlaying: Bool { get { read(\.albumColorNowPlaying, "albumColorNowPlaying", false) } set { write(\.albumColorNowPlaying, "albumColorNowPlaying", newValue, false) } }
var highRefreshRate: Bool { get { read(\.highRefreshRate, "highRefreshRate", true) } set { write(\.highRefreshRate, "highRefreshRate", newValue, true) } }
var stemQuality: StemQuality { get { readEnum(\.stemQuality, "stemQuality", .High) } set { writeEnum(\.stemQuality, "stemQuality", newValue, .High) } }
var cacheLimitGb: Int { get { read(\.cacheLimitGb, "cacheLimitGb", 10) } set { write(\.cacheLimitGb, "cacheLimitGb", newValue, 10) } }
var downloadsWifiOnly: Bool { get { read(\.downloadsWifiOnly, "downloadsWifiOnly", true) } set { write(\.downloadsWifiOnly, "downloadsWifiOnly", newValue, true) } }
/// Downloads page order: 0 download order, 1 artist, 2 release date.
var downloadSort: Int { get { read(\.downloadSort, "downloadSort", 0) } set { write(\.downloadSort, "downloadSort", newValue, 0) } }
var keepStemMix: Bool { get { read(\.keepStemMix, "keepStemMix", true) } set { write(\.keepStemMix, "keepStemMix", newValue, true) } }
var masterVolume: Float {
get { access(keyPath: \.masterVolume); lock.lock(); defer { lock.unlock() }; return (store["masterVolume"] as? NSNumber)?.floatValue ?? 0.85 }
set { write(\.masterVolume, "masterVolume", newValue, 0.85) }
}
@ObservationIgnored let clientId: String
}