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>
134 lines
6.0 KiB
Swift
134 lines
6.0 KiB
Swift
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
|
|
}
|
|
}
|