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,50 @@
|
||||
import Foundation
|
||||
|
||||
/// A one-tap stem mix. [build] returns nil when the track lacks the stems it needs.
|
||||
struct MixPreset: Identifiable, Equatable {
|
||||
let label: String
|
||||
let build: ([String]) -> [String: StemSetting]?
|
||||
var id: String { label }
|
||||
static func == (a: MixPreset, b: MixPreset) -> Bool { a.label == b.label }
|
||||
}
|
||||
|
||||
enum MixPresets {
|
||||
static let full = MixPreset(label: "Full Mix") { _ in [:] }
|
||||
static let karaoke = MixPreset(label: "Karaoke") { n in n.contains("vocals") ? ["vocals": StemSetting(muted: true)] : nil }
|
||||
|
||||
static let all: [MixPreset] = [
|
||||
full,
|
||||
karaoke,
|
||||
MixPreset(label: "A Cappella") { n in n.contains("vocals") ? ["vocals": StemSetting(solo: true)] : nil },
|
||||
MixPreset(label: "No Drums") { n in n.contains("drums") ? ["drums": StemSetting(muted: true)] : nil },
|
||||
MixPreset(label: "Rhythm") { n in
|
||||
n.contains("drums") && n.contains("bass") ? ["drums": StemSetting(solo: true), "bass": StemSetting(solo: true)] : nil
|
||||
},
|
||||
MixPreset(label: "No Guitar") { n in n.contains("guitar") ? ["guitar": StemSetting(muted: true)] : nil },
|
||||
MixPreset(label: "No Bass") { n in n.contains("bass") ? ["bass": StemSetting(muted: true)] : nil },
|
||||
]
|
||||
|
||||
/// Drops stems left at their defaults, so mixes compare by what they actually change.
|
||||
static func normalized(_ m: [String: StemSetting]) -> [String: StemSetting] { m.filter { $0.value != StemSetting() } }
|
||||
|
||||
/// The preset [mix] matches for these stems, or nil for a hand-made mix.
|
||||
static func current(_ names: [String], _ mix: [String: StemSetting]) -> MixPreset? {
|
||||
let m = normalized(mix)
|
||||
return all.first { p in p.build(names).map { normalized($0) == m } ?? false }
|
||||
}
|
||||
|
||||
/// The preset after the current one (a custom mix goes back to Full), skipping ones these stems can't do.
|
||||
static func next(_ names: [String], _ mix: [String: StemSetting]) -> MixPreset {
|
||||
let usable = all.filter { $0.build(names) != nil }
|
||||
guard let cur = current(names, mix), let i = usable.firstIndex(of: cur) else { return full }
|
||||
return usable[(i + 1) % usable.count]
|
||||
}
|
||||
|
||||
/// True when the vocals stem can't be heard (muted, or another stem soloed).
|
||||
static func vocalsSilent(_ names: [String], _ mix: [String: StemSetting]) -> Bool {
|
||||
if !names.contains("vocals") { return false }
|
||||
let v = mix["vocals"] ?? StemSetting()
|
||||
let anySolo = names.contains { mix[$0]?.solo == true }
|
||||
return v.muted || anySolo && !v.solo || v.level <= 0
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
/// Display order and colours for stems, matching the desktop app.
|
||||
enum Stems {
|
||||
static let order = ["vocals", "drums", "bass", "guitar", "piano", "other", "instrumental", "mix"]
|
||||
private static let colors: [String: UInt32] = [
|
||||
"vocals": 0xFF7AB6, "drums": 0xFFB547, "bass": 0x7C8CFF, "other": 0x4CD07D,
|
||||
"guitar": 0xFF8A5C, "piano": 0xB48CFF, "instrumental": 0x3DD6C6, "mix": 0x8E8E93,
|
||||
]
|
||||
|
||||
static func color(_ name: String) -> UInt32 { colors[name] ?? 0x8E8E93 }
|
||||
static func label(_ name: String) -> String { name == "mix" ? "Original Mix" : name.prefix(1).uppercased() + name.dropFirst() }
|
||||
static func sort(_ names: [String]) -> [String] {
|
||||
names.enumerated().sorted { a, b in
|
||||
let ia = order.firstIndex(of: a.element) ?? 99, ib = order.firstIndex(of: b.element) ?? 99
|
||||
return ia != ib ? ia < ib : a.offset < b.offset
|
||||
}.map(\.element)
|
||||
}
|
||||
}
|
||||
|
||||
/// One stem's fader. [level] is the slider position; 0.8 is unity gain.
|
||||
struct StemSetting: Codable, Hashable {
|
||||
static let unity: Float = 0.8
|
||||
var level: Float = StemSetting.unity
|
||||
var muted = false
|
||||
var solo = false
|
||||
|
||||
init(level: Float = StemSetting.unity, muted: Bool = false, solo: Bool = false) {
|
||||
self.level = level; self.muted = muted; self.solo = solo
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
level = c.double("level").map(Float.init) ?? Self.unity
|
||||
muted = c.bool("muted") ?? false
|
||||
solo = c.bool("solo") ?? false
|
||||
}
|
||||
}
|
||||
|
||||
enum RepeatMode: String, Codable, CaseIterable { case Off, All, One }
|
||||
enum SourceKind { case None, Stems, Original }
|
||||
|
||||
struct QueueItem: Codable, Hashable, Identifiable {
|
||||
var uid: Int64
|
||||
var track: Track
|
||||
var id: Int64 { uid }
|
||||
}
|
||||
|
||||
/// Waveform data for the seek bar: per-stem envelopes from stemserve, or Plex loudness in dB.
|
||||
struct Waveform {
|
||||
var stems: [String: [Float]]?
|
||||
var loudnessDb: [Float]?
|
||||
}
|
||||
|
||||
private struct SavedQueue: Codable {
|
||||
var items: [QueueItem] = []
|
||||
var unshuffled: [QueueItem]?
|
||||
var index = -1
|
||||
var positionMs: Int64 = 0
|
||||
var repeatMode: RepeatMode = .Off
|
||||
var mix: [String: StemSetting] = [:]
|
||||
|
||||
init(items: [QueueItem], unshuffled: [QueueItem]?, index: Int, positionMs: Int64, repeatMode: RepeatMode, mix: [String: StemSetting]) {
|
||||
self.items = items; self.unshuffled = unshuffled; self.index = index
|
||||
self.positionMs = positionMs; self.repeatMode = repeatMode; self.mix = mix
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey { case items, unshuffled, index, positionMs, repeatMode = "repeat", mix }
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
items = (try? c.decode([QueueItem].self, forKey: .items)) ?? []
|
||||
unshuffled = try? c.decode([QueueItem].self, forKey: .unshuffled)
|
||||
index = (try? c.decode(Int.self, forKey: .index)) ?? -1
|
||||
positionMs = (try? c.decode(Int64.self, forKey: .positionMs)) ?? 0
|
||||
repeatMode = (try? c.decode(RepeatMode.self, forKey: .repeatMode)) ?? .Off
|
||||
mix = (try? c.decode([String: StemSetting].self, forKey: .mix)) ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
/// A toast for the UI; [id] changes even when the text repeats.
|
||||
struct AppMessage: Equatable {
|
||||
let id = UUID()
|
||||
let text: String
|
||||
}
|
||||
|
||||
/// Owns the queue and the [StemEngine]. Everything public is main-thread only; the UI reads the
|
||||
/// observable state, and NowPlaying mirrors it into the lock screen and CarPlay.
|
||||
@Observable
|
||||
final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
static let ringFrames = StemEngine.ringSeconds * 48_000
|
||||
static let waveBuckets = 256
|
||||
|
||||
static func stemKey(_ entry: StemEntry, _ file: String, _ size: Int64, _ q: StemQuality) -> String {
|
||||
"stem|\(entry.key)|\(file)|\(size)|\(q.rawValue)"
|
||||
}
|
||||
static func plexKey(_ track: Track) -> String {
|
||||
"plex|\(track.partId.map(String.init) ?? "null")|\(track.partSize.map(String.init) ?? "null")"
|
||||
}
|
||||
|
||||
@ObservationIgnored private let settings: Settings
|
||||
@ObservationIgnored private let plex: PlexClient
|
||||
@ObservationIgnored private let stems: StemServer
|
||||
@ObservationIgnored private let cache: MediaCache
|
||||
@ObservationIgnored private let eq: EqController
|
||||
@ObservationIgnored private(set) var engine: StemEngine!
|
||||
@ObservationIgnored private let queueFile: URL
|
||||
|
||||
private(set) var queue: [QueueItem] = []
|
||||
private(set) var index = -1
|
||||
private(set) var current: QueueItem?
|
||||
private(set) var playWhenReady = false
|
||||
private(set) var buffering = false
|
||||
private(set) var shuffle = false
|
||||
private(set) var repeatMode = RepeatMode.Off
|
||||
private(set) var source = SourceKind.None
|
||||
private(set) var stemNames: [String] = []
|
||||
private(set) var mix: [String: StemSetting] = [:]
|
||||
private(set) var master: Float
|
||||
private(set) var waveform: Waveform?
|
||||
private(set) var durationMs: Int64 = 0
|
||||
private(set) var message: AppMessage?
|
||||
|
||||
@ObservationIgnored private var listeners: [UUID: () -> Void] = [:]
|
||||
@ObservationIgnored private var messageListeners: [UUID: (String) -> Void] = [:]
|
||||
@ObservationIgnored private var unshuffled: [QueueItem]?
|
||||
@ObservationIgnored private var nextUid: Int64 = 1
|
||||
@ObservationIgnored private let tokenLock = NSLock()
|
||||
@ObservationIgnored private var tokens: Int64 = 0
|
||||
@ObservationIgnored private var loadTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var nextTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var currentToken: Int64 = -1
|
||||
@ObservationIgnored private var nextToken: Int64 = -1
|
||||
@ObservationIgnored private var nextIndex = -1
|
||||
@ObservationIgnored private var nextEntry: StemEntry?
|
||||
@ObservationIgnored private var currentEntry: StemEntry?
|
||||
@ObservationIgnored private var pendingPositionMs: Int64 = 0
|
||||
@ObservationIgnored private var failures = 0
|
||||
@ObservationIgnored private var scrobbled: Int64 = -1
|
||||
@ObservationIgnored private var resumeAfterInterruption = false
|
||||
@ObservationIgnored private var saveTask: Task<Void, Never>?
|
||||
|
||||
init(settings: Settings, plex: PlexClient, stems: StemServer, cache: MediaCache, eq: EqController, dir: URL) {
|
||||
self.settings = settings
|
||||
self.plex = plex
|
||||
self.stems = stems
|
||||
self.cache = cache
|
||||
self.eq = eq
|
||||
queueFile = dir.appendingPathComponent("queue.json")
|
||||
master = settings.masterVolume
|
||||
engine = StemEngine(listener: self)
|
||||
eq.onParams = { [weak self] p in self?.engine.eqParams = p }
|
||||
engine.eqParams = eq.currentParams()
|
||||
engine.masterGain = masterTaper(master)
|
||||
restore()
|
||||
observeSession()
|
||||
Task { @MainActor [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 10_000_000_000)
|
||||
guard let self else { return }
|
||||
if self.playWhenReady { self.reportTimeline("playing") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Surfaces a message wherever messages show (toasts on the phone, an alert in the car).
|
||||
func report(_ text: String) {
|
||||
message = AppMessage(text: text)
|
||||
messageListeners.values.forEach { $0(text) }
|
||||
}
|
||||
|
||||
@discardableResult func addListener(_ f: @escaping () -> Void) -> UUID {
|
||||
let id = UUID()
|
||||
listeners[id] = f
|
||||
return id
|
||||
}
|
||||
func removeListener(_ id: UUID) { listeners[id] = nil; messageListeners[id] = nil }
|
||||
@discardableResult func addMessageListener(_ f: @escaping (String) -> Void) -> UUID {
|
||||
let id = UUID()
|
||||
messageListeners[id] = f
|
||||
return id
|
||||
}
|
||||
private func changed() { listeners.values.forEach { $0() } }
|
||||
|
||||
func positionMs() -> Int64 {
|
||||
let snap = engine.snapshot
|
||||
let pos = currentToken >= 0 && snap.token == currentToken ? snap.positionMs() : pendingPositionMs
|
||||
let d = durationMs
|
||||
return d > 0 ? min(max(pos, 0), d) : max(pos, 0)
|
||||
}
|
||||
|
||||
var isPlaying: Bool { playWhenReady }
|
||||
|
||||
// ---- queue --------------------------------------------------------------------------
|
||||
|
||||
private func items(_ tracks: [Track]) -> [QueueItem] {
|
||||
tracks.map { t in
|
||||
defer { nextUid += 1 }
|
||||
return QueueItem(uid: nextUid, track: t)
|
||||
}
|
||||
}
|
||||
|
||||
func playTracks(_ tracks: [Track], start: Int = 0, shuffle doShuffle: Bool = false) {
|
||||
if tracks.isEmpty { return }
|
||||
let items = items(tracks)
|
||||
if doShuffle {
|
||||
unshuffled = items
|
||||
let first = start > 0 && start < items.count ? items[start] : items.randomElement()!
|
||||
queue = [first] + items.filter { $0.uid != first.uid }.shuffled()
|
||||
shuffle = true
|
||||
load(0, 0, true)
|
||||
} else {
|
||||
unshuffled = nil
|
||||
shuffle = false
|
||||
queue = items
|
||||
load(min(max(start, 0), items.count - 1), 0, true)
|
||||
}
|
||||
}
|
||||
|
||||
func playNext(_ tracks: [Track]) { insert(index + 1, tracks) }
|
||||
func addToQueue(_ tracks: [Track]) { insert(queue.count, tracks) }
|
||||
|
||||
private func insert(_ at: Int, _ tracks: [Track]) {
|
||||
if tracks.isEmpty { return }
|
||||
let items = items(tracks)
|
||||
var q = queue
|
||||
q.insert(contentsOf: items, at: min(max(at, 0), q.count))
|
||||
queue = q
|
||||
unshuffled = unshuffled.map { $0 + items }
|
||||
if index < 0 {
|
||||
load(0, 0, true)
|
||||
} else {
|
||||
requeueNext()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
func removeAt(_ i: Int) {
|
||||
var q = queue
|
||||
guard q.indices.contains(i) else { return }
|
||||
let removed = q.remove(at: i)
|
||||
unshuffled = unshuffled?.filter { $0.uid != removed.uid }
|
||||
queue = q
|
||||
let idx = index
|
||||
if q.isEmpty {
|
||||
stop()
|
||||
} else if i == idx {
|
||||
load(min(i, q.count - 1), 0, playWhenReady)
|
||||
} else if i < idx {
|
||||
index = idx - 1
|
||||
}
|
||||
requeueNext()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
func move(_ from: Int, _ to: Int) {
|
||||
var q = queue
|
||||
guard q.indices.contains(from), q.indices.contains(to), from != to else { return }
|
||||
q.insert(q.remove(at: from), at: to)
|
||||
queue = q
|
||||
let idx = index
|
||||
if from == idx { index = to }
|
||||
else if from < idx && to >= idx { index = idx - 1 }
|
||||
else if from > idx && to <= idx { index = idx + 1 }
|
||||
requeueNext()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
func clearUpNext() {
|
||||
guard index >= 0 else { return }
|
||||
queue = Array(queue.prefix(index + 1))
|
||||
unshuffled = nil
|
||||
shuffle = false
|
||||
requeueNext()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
func toggleShuffle() {
|
||||
let q = queue
|
||||
let idx = index
|
||||
let cur = q.indices.contains(idx) ? q[idx] : nil
|
||||
if !shuffle {
|
||||
unshuffled = q
|
||||
queue = Array(q.prefix(idx + 1)) + q.dropFirst(idx + 1).shuffled()
|
||||
shuffle = true
|
||||
} else {
|
||||
let orig = unshuffled ?? q
|
||||
queue = orig
|
||||
index = max(orig.firstIndex { $0.uid == cur?.uid } ?? 0, 0)
|
||||
unshuffled = nil
|
||||
shuffle = false
|
||||
}
|
||||
requeueNext()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
func cycleRepeat() {
|
||||
let all = RepeatMode.allCases
|
||||
repeatMode = all[(all.firstIndex(of: repeatMode)! + 1) % all.count]
|
||||
requeueNext()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
func setRepeat(_ mode: RepeatMode) {
|
||||
repeatMode = mode
|
||||
requeueNext()
|
||||
changed()
|
||||
}
|
||||
|
||||
func setShuffle(_ on: Bool) { if on != shuffle { toggleShuffle() } }
|
||||
|
||||
// ---- transport ----------------------------------------------------------------------
|
||||
|
||||
func play() {
|
||||
if queue.isEmpty { return }
|
||||
if !activateSession() { return }
|
||||
playWhenReady = true
|
||||
if currentToken < 0 && loadTask == nil {
|
||||
load(max(index, 0), pendingPositionMs, true)
|
||||
return
|
||||
}
|
||||
engine.setPlaying(true)
|
||||
reportTimeline("playing")
|
||||
changed()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
playWhenReady = false
|
||||
engine.setPlaying(false)
|
||||
pendingPositionMs = positionMs()
|
||||
reportTimeline("paused")
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
func togglePlay() { if playWhenReady { pause() } else { play() } }
|
||||
|
||||
func next() {
|
||||
if queue.isEmpty { return }
|
||||
let i = index + 1
|
||||
if i < queue.count { load(i, 0, playWhenReady) }
|
||||
else if repeatMode != .Off { load(0, 0, playWhenReady) }
|
||||
else { pause(); seekTo(0) }
|
||||
}
|
||||
|
||||
func previous() {
|
||||
if positionMs() > 3000 || index <= 0 {
|
||||
seekTo(0)
|
||||
return
|
||||
}
|
||||
load(index - 1, 0, playWhenReady)
|
||||
}
|
||||
|
||||
func skipTo(_ i: Int, positionMs: Int64 = 0) {
|
||||
if queue.indices.contains(i) { load(i, positionMs, true) }
|
||||
}
|
||||
|
||||
func seekTo(_ ms: Int64) {
|
||||
pendingPositionMs = max(ms, 0)
|
||||
if currentToken >= 0 { engine.seekTo(pendingPositionMs) }
|
||||
changed()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
loadTask?.cancel()
|
||||
loadTask = nil
|
||||
nextTask?.cancel()
|
||||
engine.stop()
|
||||
reportTimeline("stopped")
|
||||
currentToken = -1
|
||||
nextToken = -1
|
||||
queue = []
|
||||
index = -1
|
||||
current = nil
|
||||
playWhenReady = false
|
||||
source = .None
|
||||
stemNames = []
|
||||
waveform = nil
|
||||
deactivateSession()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
// ---- mixer --------------------------------------------------------------------------
|
||||
|
||||
func setStemLevel(_ name: String, _ level: Float) { editMix(name) { $0.level = min(max(level, 0), 1) } }
|
||||
func toggleMute(_ name: String) { editMix(name) { $0.muted.toggle() } }
|
||||
func toggleSolo(_ name: String) { editMix(name) { $0.solo.toggle() } }
|
||||
|
||||
func resetMix() {
|
||||
mix = [:]
|
||||
pushMix()
|
||||
}
|
||||
|
||||
/// Karaoke and friends: set every stem at once.
|
||||
func applyMix(_ m: [String: StemSetting]) {
|
||||
mix = m
|
||||
pushMix()
|
||||
}
|
||||
|
||||
private func editMix(_ name: String, _ f: (inout StemSetting) -> Void) {
|
||||
var s = mix[name] ?? StemSetting()
|
||||
f(&s)
|
||||
mix[name] = s
|
||||
pushMix()
|
||||
}
|
||||
|
||||
private func pushMix() {
|
||||
let m = mix
|
||||
let anySolo = stemNames.contains { m[$0]?.solo == true }
|
||||
var gains: [String: Float] = [:]
|
||||
for n in stemNames {
|
||||
let s = m[n] ?? StemSetting()
|
||||
gains[n] = anySolo && !s.solo ? 0 : s.muted ? 0 : stemTaper(s.level)
|
||||
}
|
||||
engine.setMix(gains)
|
||||
// Now Playing shows the mix (artist line, Vocals/Mix buttons), so it needs to hear about it.
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
/// True when [track] would play without the home network: its stems (or, lacking stems, its file) are fully cached.
|
||||
func playableOffline(_ track: Track) -> Bool {
|
||||
if let entry = stems.find(track) {
|
||||
return StemQuality.allCases.contains { q in entry.stems.allSatisfy { cache.isComplete(Self.stemKey(entry, $0.file, $0.size, q)) } }
|
||||
}
|
||||
return track.partKey != nil && cache.isComplete(Self.plexKey(track))
|
||||
}
|
||||
|
||||
func hasStems(_ track: Track) -> Bool { stems.find(track) != nil }
|
||||
|
||||
func setMaster(_ v: Float) {
|
||||
master = min(max(v, 0), 1)
|
||||
engine.masterGain = masterTaper(master)
|
||||
settings.masterVolume = master
|
||||
}
|
||||
|
||||
// ---- loading ------------------------------------------------------------------------
|
||||
|
||||
private func nextTokenValue() -> Int64 { tokenLock.withLock { tokens += 1; return tokens } }
|
||||
|
||||
private func load(_ i: Int, _ positionMs: Int64, _ play: Bool) {
|
||||
guard queue.indices.contains(i) else { return }
|
||||
let item = queue[i]
|
||||
loadTask?.cancel()
|
||||
nextTask?.cancel()
|
||||
nextToken = -1
|
||||
engine.queueNext(nil)
|
||||
index = i
|
||||
current = item
|
||||
pendingPositionMs = positionMs
|
||||
durationMs = item.track.durationMs
|
||||
waveform = nil
|
||||
currentToken = -1
|
||||
if play && !activateSession() { return }
|
||||
playWhenReady = play
|
||||
buffering = true
|
||||
if !settings.keepStemMix { mix = [:] }
|
||||
changed()
|
||||
save()
|
||||
loadTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let (s, entry) = try await self.openDetached(item.track, positionMs)
|
||||
if Task.isCancelled {
|
||||
s.release()
|
||||
return
|
||||
}
|
||||
self.loadTask = nil
|
||||
self.currentToken = s.token
|
||||
self.currentEntry = entry
|
||||
self.applySession(s, entry)
|
||||
self.engine.load(s, play: self.playWhenReady)
|
||||
self.failures = 0
|
||||
self.scrobbled = -1
|
||||
self.reportTimeline(self.playWhenReady ? "playing" : "paused")
|
||||
self.loadWaveform(item.track, entry)
|
||||
self.prepareNext()
|
||||
} catch {
|
||||
if Task.isCancelled { return }
|
||||
self.loadTask = nil
|
||||
self.buffering = false
|
||||
self.report("Couldn't play “\(item.track.title)”: \(error.localizedDescription)")
|
||||
self.failures += 1
|
||||
if self.failures < 3 && i + 1 < self.queue.count {
|
||||
self.load(i + 1, 0, play)
|
||||
} else {
|
||||
self.failures = 0
|
||||
self.playWhenReady = false
|
||||
}
|
||||
self.changed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applySession(_ s: TrackSession, _ entry: StemEntry?) {
|
||||
source = entry != nil ? .Stems : .Original
|
||||
stemNames = s.names
|
||||
let frames = s.durationFrames
|
||||
if frames > 0 && s.sampleRate > 0 { durationMs = frames * 1000 / Int64(s.sampleRate) }
|
||||
pushMix()
|
||||
changed()
|
||||
}
|
||||
|
||||
private func openDetached(_ track: Track, _ startMs: Int64) async throws -> (TrackSession, StemEntry?) {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
||||
cont.resume(with: Result { try open(track, startMs) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens every stem (or the original file) and waits for the decoders to know their format.
|
||||
private func open(_ track: Track, _ startMs: Int64) throws -> (TrackSession, StemEntry?) {
|
||||
let entry = stems.find(track)
|
||||
var sources: [(name: String, key: String, url: URL?, hint: String)] = []
|
||||
if let entry {
|
||||
let q = qualityFor(entry)
|
||||
stems.prepare(entry, q)
|
||||
for name in Stems.sort(entry.stems.map(\.name)) {
|
||||
guard let f = entry.stems.first(where: { $0.name == name }) else { continue }
|
||||
let hint = q.fmt == "opus" ? "opus" : (f.file as NSString).pathExtension
|
||||
sources.append((name, Self.stemKey(entry, f.file, f.size, q), stems.fileUrl(entry, f, q), hint))
|
||||
}
|
||||
} else {
|
||||
guard let part = track.partKey else { throw PlainError("Plex has no file for this track") }
|
||||
let hint = track.partFile.map { ($0 as NSString).pathExtension } ?? track.codec ?? ""
|
||||
sources.append(("mix", Self.plexKey(track), plex.partUrl(part), hint))
|
||||
}
|
||||
if sources.isEmpty { throw PlainError("No stems listed for this track") }
|
||||
let decoders = sources.map { src -> StemDecoder in
|
||||
let res = cache.acquire(src.key) { src.url }
|
||||
return StemDecoder(name: src.name, source: ResourceDataSource(res) { [cache] in cache.release(res) },
|
||||
ringFrames: Self.ringFrames, hint: src.hint)
|
||||
}
|
||||
do {
|
||||
decoders.forEach { $0.start(gen: 0, frame: 0) }
|
||||
for d in decoders where !d.awaitPrepared(timeoutMs: 45_000) {
|
||||
throw PlainError(d.error?.localizedDescription ?? "Timed out opening \(d.name)")
|
||||
}
|
||||
let rate = decoders[0].sampleRate
|
||||
if decoders.contains(where: { $0.sampleRate != rate }) { throw PlainError("Stems have mixed sample rates") }
|
||||
let s = TrackSession(token: nextTokenValue(), names: decoders.map(\.name), decoders: decoders)
|
||||
if startMs > 0 { s.startAt(startMs * Int64(rate) / 1000) }
|
||||
return (s, entry)
|
||||
} catch {
|
||||
decoders.forEach { $0.release() }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays a downloaded copy if there is one, whatever quality it was saved at.
|
||||
private func qualityFor(_ entry: StemEntry) -> StemQuality {
|
||||
let want = stems.effectiveQuality(settings.stemQuality)
|
||||
guard let f = entry.stems.first else { return want }
|
||||
if cache.isComplete(Self.stemKey(entry, f.file, f.size, want)) { return want }
|
||||
return StemQuality.allCases.first { cache.isComplete(Self.stemKey(entry, f.file, f.size, $0)) } ?? want
|
||||
}
|
||||
|
||||
/// Builds the next track's session now so the switch can be gapless.
|
||||
private func prepareNext() {
|
||||
nextTask?.cancel()
|
||||
nextToken = -1
|
||||
engine.queueNext(nil)
|
||||
guard let i = upcomingIndex() else { return }
|
||||
let item = queue[i]
|
||||
nextTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 4_000_000_000) // let the current track's stems get a head start
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
// The normal load path reports failures.
|
||||
guard let (s, entry) = try? await self.openDetached(item.track, 0) else { return }
|
||||
if Task.isCancelled {
|
||||
s.release()
|
||||
return
|
||||
}
|
||||
self.nextToken = s.token
|
||||
self.nextIndex = i
|
||||
self.nextEntry = entry
|
||||
self.engine.queueNext(s)
|
||||
}
|
||||
}
|
||||
|
||||
private func requeueNext() {
|
||||
if currentToken >= 0 { prepareNext() }
|
||||
}
|
||||
|
||||
private func upcomingIndex() -> Int? {
|
||||
let q = queue
|
||||
let i = index
|
||||
if q.isEmpty || i < 0 { return nil }
|
||||
if repeatMode == .One { return i }
|
||||
if i + 1 < q.count { return i + 1 }
|
||||
if repeatMode == .All { return 0 }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func loadWaveform(_ track: Track, _ entry: StemEntry?) {
|
||||
let uid = current?.uid
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
var w: Waveform?
|
||||
if let entry {
|
||||
w = await self.stems.peaks(entry, buckets: Self.waveBuckets).map { Waveform(stems: $0, loudnessDb: nil) }
|
||||
} else if let l = try? await self.plex.loudness(track.ratingKey, samples: Self.waveBuckets) {
|
||||
w = Waveform(stems: nil, loudnessDb: l)
|
||||
}
|
||||
if self.current?.uid == uid { self.waveform = w }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- engine callbacks (main thread) ---------------------------------------------------
|
||||
|
||||
func onEngineState() {
|
||||
buffering = engine.buffering || loadTask != nil
|
||||
changed()
|
||||
}
|
||||
|
||||
func onTransition(_ token: Int64) {
|
||||
if token == currentToken || token != nextToken { return }
|
||||
scrobble()
|
||||
currentToken = token
|
||||
nextToken = -1
|
||||
index = nextIndex
|
||||
current = queue.indices.contains(nextIndex) ? queue[nextIndex] : nil
|
||||
currentEntry = nextEntry
|
||||
pendingPositionMs = 0
|
||||
scrobbled = -1
|
||||
guard let item = current else { return }
|
||||
durationMs = item.track.durationMs
|
||||
waveform = nil
|
||||
if !settings.keepStemMix { mix = [:] }
|
||||
source = currentEntry != nil ? .Stems : .Original
|
||||
stemNames = Stems.sort(currentEntry?.stems.map(\.name) ?? ["mix"])
|
||||
pushMix()
|
||||
loadWaveform(item.track, currentEntry)
|
||||
reportTimeline("playing")
|
||||
prepareNext()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
func onEnded(_ token: Int64) {
|
||||
if token != currentToken { return }
|
||||
scrobble()
|
||||
currentToken = -1
|
||||
// Normally the next song was queued for a gapless switch; if preparing it failed, try again now.
|
||||
if let i = upcomingIndex() {
|
||||
load(i, 0, true)
|
||||
return
|
||||
}
|
||||
playWhenReady = false
|
||||
pendingPositionMs = 0
|
||||
reportTimeline("stopped")
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
func onError(_ token: Int64, _ msg: String) {
|
||||
if token != currentToken { return }
|
||||
report("Playback stopped: \(msg)")
|
||||
currentToken = -1
|
||||
let i = index
|
||||
failures += 1
|
||||
if i + 1 < queue.count && failures < 3 {
|
||||
load(i + 1, 0, playWhenReady)
|
||||
} else {
|
||||
playWhenReady = false
|
||||
changed()
|
||||
}
|
||||
}
|
||||
|
||||
private func scrobble() {
|
||||
guard let item = current, scrobbled != item.uid else { return }
|
||||
let d = durationMs
|
||||
if d > 0 && Double(positionMs()) < Double(d) * 0.5 && engine.snapshot.token == currentToken { return }
|
||||
scrobbled = item.uid
|
||||
Task { [plex] in await plex.scrobble(item.track.ratingKey) }
|
||||
}
|
||||
|
||||
private func reportTimeline(_ state: String) {
|
||||
guard let t = current?.track else { return }
|
||||
let pos = positionMs()
|
||||
if !plex.signedIn { return }
|
||||
Task { [plex] in await plex.timeline(t, state: state, positionMs: pos) }
|
||||
if state == "playing" && durationMs > 0 && Double(pos) > Double(durationMs) * 0.9 { scrobble() }
|
||||
}
|
||||
|
||||
// ---- audio session --------------------------------------------------------------------
|
||||
|
||||
private func activateSession() -> Bool {
|
||||
let s = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try s.setCategory(.playback, mode: .default, policy: .longFormAudio)
|
||||
try s.setActive(true)
|
||||
return true
|
||||
} catch {
|
||||
report("Couldn't start audio: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func deactivateSession() {
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
private func observeSession() {
|
||||
let nc = NotificationCenter.default
|
||||
nc.addObserver(forName: AVAudioSession.interruptionNotification, object: nil, queue: .main) { [weak self] n in
|
||||
guard let self, let info = n.userInfo,
|
||||
let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
|
||||
switch type {
|
||||
case .began:
|
||||
// A stale "began" arrives after the app was suspended; the session wasn't really taken.
|
||||
if (info[AVAudioSessionInterruptionReasonKey] as? UInt) == 1 /* appWasSuspended, deprecated but still sent */ { return }
|
||||
self.resumeAfterInterruption = self.playWhenReady
|
||||
if self.playWhenReady { self.pause() }
|
||||
case .ended:
|
||||
let opts = AVAudioSession.InterruptionOptions(rawValue: info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0)
|
||||
if self.resumeAfterInterruption && opts.contains(.shouldResume) { self.play() }
|
||||
self.resumeAfterInterruption = false
|
||||
@unknown default: break
|
||||
}
|
||||
}
|
||||
nc.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { [weak self] n in
|
||||
guard let self, let raw = n.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||
let reason = AVAudioSession.RouteChangeReason(rawValue: raw) else { return }
|
||||
// Headphones unplugged or Bluetooth gone: don't carry on out of the speaker.
|
||||
if reason == .oldDeviceUnavailable && self.playWhenReady { self.pause() }
|
||||
self.eq.refreshRoute()
|
||||
}
|
||||
nc.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
let was = self.playWhenReady
|
||||
let pos = self.positionMs()
|
||||
self.currentToken = -1
|
||||
self.engine.release()
|
||||
self.engine = StemEngine(listener: self)
|
||||
self.engine.eqParams = self.eq.currentParams()
|
||||
self.engine.masterGain = masterTaper(self.master)
|
||||
self.pendingPositionMs = pos
|
||||
if self.index >= 0 { self.load(self.index, pos, was) }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- persistence ------------------------------------------------------------------------
|
||||
|
||||
private func save() {
|
||||
saveTask?.cancel()
|
||||
saveTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
let snap = SavedQueue(items: self.queue, unshuffled: self.unshuffled, index: self.index,
|
||||
positionMs: self.positionMs(), repeatMode: self.repeatMode, mix: self.mix)
|
||||
let file = self.queueFile
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
guard let data = try? JSONEncoder().encode(snap) else { return }
|
||||
try? data.write(to: file, options: .atomic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves right away, for when the app is about to be suspended.
|
||||
func flushSave() {
|
||||
saveTask?.cancel()
|
||||
let snap = SavedQueue(items: queue, unshuffled: unshuffled, index: index, positionMs: positionMs(), repeatMode: repeatMode, mix: mix)
|
||||
if let data = try? JSONEncoder().encode(snap) { try? data.write(to: queueFile, options: .atomic) }
|
||||
}
|
||||
|
||||
private func restore() {
|
||||
guard let data = try? Data(contentsOf: queueFile), let saved = try? JSONDecoder().decode(SavedQueue.self, from: data) else { return }
|
||||
queue = saved.items
|
||||
unshuffled = saved.unshuffled
|
||||
shuffle = saved.unshuffled != nil
|
||||
repeatMode = saved.repeatMode
|
||||
mix = saved.mix
|
||||
nextUid = (saved.items.map(\.uid).max() ?? 0) + 1
|
||||
let i = min(max(saved.index, -1), saved.items.count - 1)
|
||||
index = i
|
||||
current = saved.items.indices.contains(i) ? saved.items[i] : nil
|
||||
durationMs = current?.track.durationMs ?? 0
|
||||
pendingPositionMs = saved.positionMs
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user