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:
1 parent
45bc0eefe0
commit
fdf9e970b2
45 files changed
+9822
No files matched your search
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
|
||||
/// Hand-wired singletons, shared by the phone UI, CarPlay and Siri.
|
||||
@MainActor
|
||||
final class AppGraph {
|
||||
static let shared = AppGraph()
|
||||
|
||||
let settings: Settings
|
||||
let net: Connectivity
|
||||
/// Library calls: kept on disk and served from there when the server is unreachable.
|
||||
let apiHttp: Http
|
||||
/// Same disk cache, but away from home it reads only from disk instead of waiting on LAN timeouts.
|
||||
let carHttp: Http
|
||||
/// Media ranges; MediaCache is the cache. Long timeout covers server-side Opus transcodes.
|
||||
let mediaSession: URLSession
|
||||
let plex: PlexClient
|
||||
let reach: HomeReach
|
||||
let carPlex: PlexClient
|
||||
let stems: StemServer
|
||||
let cache: MediaCache
|
||||
let eq: EqController
|
||||
let playback: PlaybackManager
|
||||
let downloads: Downloads
|
||||
let coverage: StemCoverage
|
||||
let nowPlaying: NowPlaying
|
||||
let car: CarLibrary
|
||||
|
||||
private init() {
|
||||
let fm = FileManager.default
|
||||
let support = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
let caches = fm.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
try? fm.createDirectory(at: support, withIntermediateDirectories: true)
|
||||
|
||||
settings = Settings()
|
||||
net = Connectivity()
|
||||
apiHttp = Http(connectTimeout: 6, readTimeout: 30, offlineCache: caches.appendingPathComponent("http"))
|
||||
carHttp = Http(connectTimeout: 6, readTimeout: 30, offlineCache: caches.appendingPathComponent("http"))
|
||||
let m = URLSessionConfiguration.default
|
||||
m.timeoutIntervalForRequest = 90
|
||||
m.timeoutIntervalForResource = 24 * 3600
|
||||
m.urlCache = nil
|
||||
m.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
mediaSession = URLSession(configuration: m)
|
||||
|
||||
plex = PlexClient(http: apiHttp, settings: settings)
|
||||
reach = HomeReach(settings: settings, net: net)
|
||||
let reach = reach
|
||||
carHttp.forceCache = { !reach.lastKnown }
|
||||
carPlex = PlexClient(http: carHttp, settings: settings)
|
||||
stems = StemServer(http: apiHttp, settings: settings, dir: support)
|
||||
|
||||
// Media lives outside Caches' purge-anytime rules and out of iCloud backups.
|
||||
var media = support.appendingPathComponent("media", isDirectory: true)
|
||||
try? fm.createDirectory(at: media, withIntermediateDirectories: true)
|
||||
var rv = URLResourceValues()
|
||||
rv.isExcludedFromBackup = true
|
||||
try? media.setResourceValues(rv)
|
||||
let settings = settings
|
||||
cache = MediaCache(dir: media, session: mediaSession) { Int64(settings.cacheLimitGb) << 30 }
|
||||
|
||||
eq = EqController(dir: support)
|
||||
playback = PlaybackManager(settings: settings, plex: plex, stems: stems, cache: cache, eq: eq, dir: support)
|
||||
downloads = Downloads(settings: settings, plex: plex, stems: stems, cache: cache, net: net, dir: support)
|
||||
coverage = StemCoverage(plex: plex, stems: stems, settings: settings, dir: support)
|
||||
nowPlaying = NowPlaying(pm: playback, plex: plex)
|
||||
car = CarLibrary(plex: carPlex, reach: reach, downloads: downloads, stems: stems, pm: playback)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Intents
|
||||
|
||||
/// "Hey Siri, play … on Stemify". Handled in the app process, so it can start playback directly.
|
||||
final class PlayMediaHandler: NSObject, INPlayMediaIntentHandling {
|
||||
func resolveMediaItems(for intent: INPlayMediaIntent) async -> [INPlayMediaMediaItemResolutionResult] {
|
||||
let q = intent.mediaSearch?.mediaName ?? intent.mediaSearch?.artistName ?? intent.mediaSearch?.albumName ?? ""
|
||||
let item = INMediaItem(identifier: "search:\(q)", title: q.isEmpty ? "Downloads" : q, type: .music, artwork: nil)
|
||||
return INPlayMediaMediaItemResolutionResult.successes(with: [item])
|
||||
}
|
||||
|
||||
func handle(intent: INPlayMediaIntent) async -> INPlayMediaIntentResponse {
|
||||
let s = intent.mediaSearch
|
||||
let q = s?.mediaName ?? s?.artistName ?? s?.albumName ?? intent.mediaItems?.first?.title ?? ""
|
||||
let shuffle = intent.playShuffled == true
|
||||
let ok = await Self.play(q, shuffle)
|
||||
return INPlayMediaIntentResponse(code: ok ? .success : .failure, userActivity: nil)
|
||||
}
|
||||
|
||||
@MainActor static func play(_ query: String, _ shuffle: Bool) async -> Bool {
|
||||
let g = AppGraph.shared
|
||||
do {
|
||||
guard let r = try await g.car.resolveSearch(query) else {
|
||||
g.playback.report(query.isEmpty ? "Nothing downloaded yet" : "Nothing found for “\(query)”")
|
||||
return false
|
||||
}
|
||||
g.playback.playTracks(r.tracks, start: r.start, shuffle: r.shuffle || shuffle)
|
||||
return true
|
||||
} catch {
|
||||
g.playback.report(error is NotSignedIn ? "Sign in to Plex on your phone" : "Couldn't load that: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import AVFoundation
|
||||
import Intents
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, policy: .longFormAudio)
|
||||
MainActor.assumeIsolated { _ = AppGraph.shared }
|
||||
return true
|
||||
}
|
||||
|
||||
/// Phone windows go to SwiftUI; CarPlay gets its own scene delegate.
|
||||
func application(_ application: UIApplication, configurationForConnecting session: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
if session.role == .carTemplateApplication {
|
||||
let c = UISceneConfiguration(name: "CarPlay", sessionRole: session.role)
|
||||
c.delegateClass = CarPlayScene.self
|
||||
return c
|
||||
}
|
||||
return UISceneConfiguration(name: "Phone", sessionRole: session.role)
|
||||
}
|
||||
|
||||
/// Siri: "Play <album> on Stemify".
|
||||
func application(_ application: UIApplication, handlerFor intent: INIntent) -> Any? {
|
||||
intent is INPlayMediaIntent ? PlayMediaHandler() : nil
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct StemifyApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||
@Environment(\.scenePhase) private var phase
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
}
|
||||
.onChange(of: phase) { _, p in
|
||||
if p == .active { AppGraph.shared.eq.refreshRoute() }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user