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,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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user