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

+133
View File
@@ -0,0 +1,133 @@
import Foundation
import MediaPlayer
import UIKit
/// Lock screen, Control Center, headphone and car controls: publishes the current track to
/// MPNowPlayingInfoCenter and routes MPRemoteCommandCenter back into [PlaybackManager].
/// Stands in for the Media3 session on Android.
final class NowPlaying: @unchecked Sendable {
private let pm: PlaybackManager
private let plex: PlexClient
private let center = MPNowPlayingInfoCenter.default()
private var artKey: String?
private var artwork: MPMediaItemArtwork?
init(pm: PlaybackManager, plex: PlexClient) {
self.pm = pm
self.plex = plex
pm.addListener { [weak self] in self?.publish() }
wireCommands()
publish()
}
// ---- shared with CarPlay ---------------------------------------------------------
/// The mix shown next to the artist: nil unless stems are playing or the mix is Full.
var mixLabel: String? {
guard pm.source == .Stems else { return nil }
guard let p = MixPresets.current(pm.stemNames, pm.mix) else { return "Custom mix" }
return p == MixPresets.full ? nil : p.label
}
var vocalsAvailable: Bool { pm.source == .Stems && pm.stemNames.contains("vocals") }
var vocalsSilent: Bool { MixPresets.vocalsSilent(pm.stemNames, pm.mix) }
var nextMix: MixPreset { MixPresets.next(pm.stemNames, pm.mix) }
/// One tap: hear the vocals again, or take them out.
func toggleVocals() {
let names = pm.stemNames, mix = pm.mix
let anySolo = names.contains { mix[$0]?.solo == true }
if MixPresets.vocalsSilent(names, mix) {
if mix["vocals"]?.muted == true && !anySolo { pm.toggleMute("vocals") } else { pm.applyMix([:]) }
} else if anySolo {
pm.applyMix(MixPresets.karaoke.build(names) ?? [:])
} else {
pm.toggleMute("vocals")
}
}
func cycleMix() { pm.applyMix(nextMix.build(pm.stemNames) ?? [:]) }
// ---- info --------------------------------------------------------------------------
private func publish() {
guard let t = pm.current?.track else {
center.nowPlayingInfo = nil
center.playbackState = .stopped
return
}
var info: [String: Any] = [
MPMediaItemPropertyTitle: t.title,
MPMediaItemPropertyArtist: mixLabel.map { "\(t.artist) · \($0)" } ?? t.artist,
MPMediaItemPropertyAlbumTitle: t.album,
MPMediaItemPropertyPlaybackDuration: Double(max(pm.durationMs, t.durationMs)) / 1000,
MPNowPlayingInfoPropertyElapsedPlaybackTime: Double(pm.positionMs()) / 1000,
MPNowPlayingInfoPropertyPlaybackRate: pm.isPlaying && !pm.buffering ? 1.0 : 0.0,
MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0,
MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue,
MPNowPlayingInfoPropertyPlaybackQueueIndex: max(pm.index, 0),
MPNowPlayingInfoPropertyPlaybackQueueCount: pm.queue.count,
MPNowPlayingInfoPropertyExternalContentIdentifier: t.ratingKey,
]
if let n = t.index { info[MPMediaItemPropertyAlbumTrackNumber] = n }
let key = t.thumb
if key == artKey, let artwork { info[MPMediaItemPropertyArtwork] = artwork }
center.nowPlayingInfo = info
center.playbackState = pm.isPlaying ? .playing : .paused
updateCommands()
if key != artKey {
artKey = key
artwork = nil
if let url = plex.artUrl(key, px: 600) {
Task { @MainActor [weak self] in
guard let img = await ArtLoader.shared.image(url), let self, self.artKey == key else { return }
self.artwork = MPMediaItemArtwork(boundsSize: img.size) { _ in img }
self.publish()
}
}
}
}
// ---- commands ----------------------------------------------------------------------
private func wireCommands() {
let c = MPRemoteCommandCenter.shared()
func on(_ cmd: MPRemoteCommand, _ f: @escaping (MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus) {
cmd.isEnabled = true
cmd.addTarget { e in MainActor.assumeIsolated { f(e) } }
}
on(c.playCommand) { [pm] _ in pm.play(); return .success }
on(c.pauseCommand) { [pm] _ in pm.pause(); return .success }
on(c.stopCommand) { [pm] _ in pm.pause(); return .success }
on(c.togglePlayPauseCommand) { [pm] _ in pm.togglePlay(); return .success }
on(c.nextTrackCommand) { [pm] _ in pm.next(); return .success }
on(c.previousTrackCommand) { [pm] _ in pm.previous(); return .success }
on(c.changePlaybackPositionCommand) { [pm] e in
guard let e = e as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
pm.seekTo(Int64(e.positionTime * 1000))
return .success
}
on(c.changeShuffleModeCommand) { [pm] e in
guard let e = e as? MPChangeShuffleModeCommandEvent else { return .commandFailed }
pm.setShuffle(e.shuffleType != .off)
return .success
}
on(c.changeRepeatModeCommand) { [pm] e in
guard let e = e as? MPChangeRepeatModeCommandEvent else { return .commandFailed }
pm.setRepeat(e.repeatType == .one ? .One : e.repeatType == .all ? .All : .Off)
return .success
}
c.skipForwardCommand.isEnabled = false
c.skipBackwardCommand.isEnabled = false
c.seekForwardCommand.isEnabled = false
c.seekBackwardCommand.isEnabled = false
}
private func updateCommands() {
let c = MPRemoteCommandCenter.shared()
c.changeShuffleModeCommand.currentShuffleType = pm.shuffle ? .items : .off
c.changeRepeatModeCommand.currentRepeatType = pm.repeatMode == .One ? .one : pm.repeatMode == .All ? .all : .off
c.nextTrackCommand.isEnabled = pm.index + 1 < pm.queue.count || pm.repeatMode != .Off
c.previousTrackCommand.isEnabled = pm.current != nil
}
}