Stemify iOS 1.1 and WebUI updates, repo restructure into desktop/ios/server/web

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Sonnet 5.5 committed 2026-10-24 17:08:51 -07:00
1 parent 894dd5a695
commit df5d847169
157 files changed
+3935 -742

No files matched your search

+107 -14
View File
@@ -2,28 +2,74 @@ 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
/// The stored name, which ids are built from. Show [label] instead.
let name: String
/// One the user saved, as opposed to a built-in.
var custom = false
let build: ([String]) -> [String: StemSetting]?
var id: String { label }
static func == (a: MixPreset, b: MixPreset) -> Bool { a.label == b.label }
var id: String { (custom ? "c:" : "b:") + name }
/// The name as shown, with A Cappella spelled the way Appearance says.
var label: String { !custom && name == "A Cappella" && MixPresets.acapella ? "Acapella" : name }
static func == (a: MixPreset, b: MixPreset) -> Bool { a.id == b.id }
}
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 full = MixPreset(name: "Full Mix") { _ in [:] }
static let karaoke = MixPreset(name: "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
MixPreset(name: "A Cappella") { n in n.contains("vocals") ? ["vocals": StemSetting(solo: true)] : nil },
MixPreset(name: "No Drums") { n in n.contains("drums") ? ["drums": StemSetting(muted: true)] : nil },
MixPreset(name: "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 },
MixPreset(name: "No Guitar") { n in n.contains("guitar") ? ["guitar": StemSetting(muted: true)] : nil },
MixPreset(name: "No Bass") { n in n.contains("bass") ? ["bass": StemSetting(muted: true)] : nil },
]
static func custom(_ c: CustomPreset) -> MixPreset { MixPreset(name: c.name, custom: true) { _ in c.mix } }
/// Every preset, the user's and the built-ins, in the order set in the Stem Editor (new ones go to the end).
static func ordered(_ s: Settings) -> [MixPreset] {
let pool = CustomPreset.load(s).map(custom) + all
let order = s.presetOrder.split(separator: "\n").map(String.init)
let rank = Dictionary(order.enumerated().map { ($1, $0) }, uniquingKeysWith: { a, _ in a })
return pool.enumerated().sorted { a, b in
(rank[a.element.id] ?? order.count + a.offset, a.offset) < (rank[b.element.id] ?? order.count + b.offset, b.offset)
}.map(\.element)
}
/// Set by Appearance. Read straight from UserDefaults so labels work off the main thread.
static var acapella: Bool { UserDefaults.standard.bool(forKey: "acapella") }
/// Built-ins deleted in the Stem Editor. Custom ids from older builds, which could hide those too, don't count.
static func hidden(_ s: Settings) -> Set<String> {
Set(s.hiddenPresets.split(separator: "\n").map(String.init).filter { $0.hasPrefix("b:") })
}
/// The chips in the mixer bar, and the rotation for every Mix button outside the app.
static func bar(_ s: Settings) -> [MixPreset] {
let h = hidden(s)
return ordered(s).filter { !h.contains($0.id) }
}
static func restoreDefaults(_ s: Settings) { s.hiddenPresets = "" }
static func setOrder(_ s: Settings, _ list: [MixPreset]) { s.presetOrder = list.map(\.id).joined(separator: "\n") }
static func setHidden(_ s: Settings, _ id: String, _ on: Bool) {
var h = hidden(s)
if on { h.insert(id) } else { h.remove(id) }
s.hiddenPresets = h.sorted().joined(separator: "\n")
}
/// The preset every song starts with, whatever Keep Stem Mix says. Nil when unlocked.
static func locked(_ s: Settings) -> MixPreset? {
s.lockedPreset.isEmpty ? nil : ordered(s).first { $0.id == s.lockedPreset }
}
/// 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() } }
@@ -33,10 +79,19 @@ enum MixPresets {
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 }
/// Like [current], but also matches the user's own presets, so a saved mix is named wherever it shows.
static func current(_ s: Settings, _ names: [String], _ mix: [String: StemSetting]) -> MixPreset? {
let m = normalized(mix)
return ordered(s).first { p in p.build(names).map { normalized($0) == m } ?? false }
}
/// The mix after the current one in the Stem Editor's list, skipping ones these stems can't do.
/// A hand-made mix, or one deleted from the editor, goes to the start of the list.
static func next(_ s: Settings, _ names: [String], _ mix: [String: StemSetting]) -> MixPreset {
let usable = bar(s).filter { $0.build(names) != nil }
guard !usable.isEmpty else { return full }
let m = normalized(mix)
guard let i = usable.firstIndex(where: { p in p.build(names).map { normalized($0) == m } ?? false }) else { return usable[0] }
return usable[(i + 1) % usable.count]
}
@@ -48,3 +103,41 @@ enum MixPresets {
return v.muted || anySolo && !v.solo || v.level <= 0
}
}
/// A named, app-wide mix the user saved from the mixer. Stored as JSON in [Settings.presets].
struct CustomPreset: Codable, Identifiable, Equatable {
var name: String
var mix: [String: StemSetting]
var id: String { name }
@MainActor static func load() -> [CustomPreset] { load(AppGraph.shared.settings) }
static func load(_ s: Settings) -> [CustomPreset] {
guard let d = s.presets.data(using: .utf8) else { return [] }
return (try? JSONDecoder().decode([CustomPreset].self, from: d)) ?? []
}
@MainActor static func save(_ list: [CustomPreset]) {
guard let d = try? JSONEncoder().encode(list), let s = String(data: d, encoding: .utf8) else { return }
AppGraph.shared.settings.presets = s
}
/// Adds or replaces the preset with this name.
@MainActor static func add(_ name: String, _ mix: [String: StemSetting]) {
var l = load().filter { $0.name != name }
l.append(CustomPreset(name: name, mix: MixPresets.normalized(mix)))
save(l)
}
@MainActor static func remove(_ name: String) {
let s = AppGraph.shared.settings
if s.lockedPreset == "c:" + name { s.lockedPreset = "" }
save(load().filter { $0.name != name })
}
@MainActor static func removeAll() {
let s = AppGraph.shared.settings
if s.lockedPreset.hasPrefix("c:") { s.lockedPreset = "" }
save([])
}
}
+24 -10
View File
@@ -8,13 +8,17 @@ import UIKit
final class NowPlaying: @unchecked Sendable {
private let pm: PlaybackManager
private let plex: PlexClient
private let radio: WebRadio
private let settings: Settings
private let center = MPNowPlayingInfoCenter.default()
private var artKey: String?
private var artwork: MPMediaItemArtwork?
init(pm: PlaybackManager, plex: PlexClient) {
init(pm: PlaybackManager, plex: PlexClient, radio: WebRadio, settings: Settings) {
self.pm = pm
self.plex = plex
self.radio = radio
self.settings = settings
pm.addListener { [weak self] in self?.publish() }
wireCommands()
publish()
@@ -25,13 +29,14 @@ final class NowPlaying: @unchecked Sendable {
/// 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" }
guard let p = MixPresets.current(settings, 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) }
/// Follows the Stem Editor: deleted mixes are skipped and the user's own are in the rotation.
var nextMix: MixPreset { MixPresets.next(settings, pm.stemNames, pm.mix) }
/// One tap: hear the vocals again, or take them out.
func toggleVocals() {
@@ -50,7 +55,11 @@ final class NowPlaying: @unchecked Sendable {
// ---- info --------------------------------------------------------------------------
/// Puts the queue back on the lock screen (after the web radio stops).
func refresh() { publish() }
private func publish() {
if radio.active { return }
guard let t = pm.current?.track else {
center.nowPlayingInfo = nil
center.playbackState = .stopped
@@ -92,16 +101,21 @@ final class NowPlaying: @unchecked Sendable {
private func wireCommands() {
let c = MPRemoteCommandCenter.shared()
func on(_ cmd: MPRemoteCommand, _ f: @escaping (MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus) {
func on(_ cmd: MPRemoteCommand, _ f: @escaping @MainActor (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 }
// While a web radio station is loaded, play/pause drive it and skipping does nothing.
on(c.playCommand) { [pm, radio] _ in if radio.active { radio.resume() } else { pm.play() }; return .success }
on(c.pauseCommand) { [pm, radio] _ in if radio.active { radio.pause() } else { pm.pause() }; return .success }
on(c.stopCommand) { [pm, radio] _ in if radio.active { radio.stop() } else { pm.pause() }; return .success }
on(c.togglePlayPauseCommand) { [pm, radio] _ in if radio.active { radio.toggle() } else { pm.togglePlay() }; return .success }
on(c.nextTrackCommand) { [pm, radio] _ in if !radio.active { pm.next() }; return .success }
on(c.previousTrackCommand) { [weak self, pm, radio] _ in
guard !radio.active else { return .success }
if self?.settings.lockScreenMix == true && pm.source == .Stems { self?.cycleMix() } else { 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))
+60 -20
View File
@@ -100,7 +100,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
"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")"
"plex|\(track.ratingKey.firstIndex(of: "~").map { String(track.ratingKey[...$0]) } ?? "")\(track.partId.map(String.init) ?? "null")|\(track.partSize.map(String.init) ?? "null")"
}
@ObservationIgnored private let settings: Settings
@@ -131,6 +131,8 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
private(set) var songMixes: [String: [String: StemSetting]] = [:]
/// Karaoke mode: vocals on, soft or out (per settings) on every song, lyrics on Now Playing.
private(set) var karaoke = false
/// The last mix set by hand; the next song starts from it (never a song's saved mix).
@ObservationIgnored private var carryMix: [String: StemSetting] = [:]
@ObservationIgnored private var preKaraokeMix: [String: StemSetting]?
@ObservationIgnored private var listeners: [UUID: () -> Void] = [:]
@@ -142,9 +144,11 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
@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?
/// Sessions handed to the engine for a gapless switch, by token: which queue item and entry they are.
/// Kept until a switch or load, since the engine may already have switched when the queue changes.
@ObservationIgnored private var queued: [Int64: (uid: Int64, entry: StemEntry?)] = [:]
/// The queue ran out and playback stopped at its last song.
@ObservationIgnored private var finished = false
@ObservationIgnored private var currentEntry: StemEntry?
@ObservationIgnored private var pendingPositionMs: Int64 = 0
@ObservationIgnored private var failures = 0
@@ -218,13 +222,18 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
}
/// `mixes` lines up with `tracks`: each song plays with its own mix (stem playlists).
/// Shuffles whole albums, keeping each album's tracks in order (Plexamp-style), when the setting is on.
private func shuffled(_ items: [QueueItem]) -> [QueueItem] {
items.shuffled()
}
func playTracks(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil, start: Int = 0, shuffle doShuffle: Bool = false) {
if tracks.isEmpty { return }
let items = items(tracks, mixes)
if doShuffle {
unshuffled = items
let first = start > 0 && start < items.count ? items[start] : items.randomElement()!
queue = [first] + items.filter { $0.uid != first.uid }.shuffled()
queue = [first] + shuffled(items.filter { $0.uid != first.uid })
shuffle = true
load(0, 0, true)
} else {
@@ -245,8 +254,8 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
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)
if index < 0 || finished {
load(finished ? index + 1 : 0, 0, true)
} else {
requeueNext()
changed()
@@ -303,7 +312,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
let cur = q.indices.contains(idx) ? q[idx] : nil
if !shuffle {
unshuffled = q
queue = Array(q.prefix(idx + 1)) + q.dropFirst(idx + 1).shuffled()
queue = Array(q.prefix(idx + 1)) + shuffled(Array(q.dropFirst(idx + 1)))
shuffle = true
} else {
let orig = unshuffled ?? q
@@ -376,6 +385,14 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
else { pause(); seekTo(0) }
}
/// Skips past the rest of the current album (holding Next).
func nextAlbum() {
guard let album = current?.track.albumKey, index >= 0 else { return next() }
if let i = queue.indices.dropFirst(index + 1).first(where: { queue[$0].track.albumKey != album }) { 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)
@@ -401,7 +418,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
engine.stop()
reportTimeline("stopped")
currentToken = -1
nextToken = -1
queued = [:]
queue = []
index = -1
current = nil
@@ -428,14 +445,17 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
/// Karaoke and friends: set every stem at once.
func applyMix(_ m: [String: StemSetting]) {
mix = m
carryMix = m
pushMix()
}
/// The mix a song starts with: karaoke if that's on, else its stem playlist mix, else the song's saved mix,
/// else cleared (or kept, per settings).
private func startMix(_ item: QueueItem) -> [String: StemSetting] {
if karaoke && !settings.keepStemMix { karaoke = false; preKaraokeMix = nil }
if karaoke { return karaokeMix }
return item.mix ?? songMixes[item.track.ratingKey] ?? (settings.keepStemMix ? mix : [:])
if let l = MixPresets.locked(settings), let m = l.build(StemPlaylists.allStems) { return m }
return item.mix ?? songMixes[item.track.ratingKey] ?? (settings.keepStemMix ? carryMix : [:])
}
/// How much of the singer is left in karaoke mode. Soft is a guide under your own voice, as quiet as settings say.
@@ -491,6 +511,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
var s = mix[name] ?? StemSetting()
f(&s)
mix[name] = s
carryMix = mix
pushMix()
}
@@ -533,7 +554,8 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
let item = queue[i]
loadTask?.cancel()
nextTask?.cancel()
nextToken = -1
queued = [:]
finished = false
engine.queueNext(nil)
index = i
current = item
@@ -649,7 +671,6 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
/// 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]
@@ -666,10 +687,27 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
s.release()
return
}
self.nextToken = s.token
self.nextIndex = i
self.nextEntry = entry
self.queued[s.token] = (item.uid, entry)
self.engine.queueNext(s)
await self.precacheAhead(from: i)
}
}
/// Warms the cache for the songs after the gapless one, up to the Cache Ahead setting.
private func precacheAhead(from i: Int) async {
let n = settings.precacheCount - 1
guard n > 0 else { return }
for k in 1...n {
let j = i + k
guard !Task.isCancelled, j < queue.count else { return }
let t = queue[j].track
if playableOffline(t) { continue }
guard let (s, _) = try? await openDetached(t, 0) else { continue }
// Keep the session open until its prefetch has filled the cache.
for _ in 0..<300 where !Task.isCancelled && !playableOffline(t) {
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
s.release()
}
}
@@ -709,13 +747,14 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
}
func onTransition(_ token: Int64) {
if token == currentToken || token != nextToken { return }
guard token != currentToken, let q = queued[token] else { return }
queued = [:]
scrobble()
currentToken = token
nextToken = -1
index = nextIndex
current = queue.indices.contains(nextIndex) ? queue[nextIndex] : nil
currentEntry = nextEntry
let i = queue.firstIndex { $0.uid == q.uid } ?? min(index + 1, queue.count - 1)
index = i
current = queue.indices.contains(i) ? queue[i] : nil
currentEntry = q.entry
pendingPositionMs = 0
scrobbled = -1
guard let item = current else { return }
@@ -743,6 +782,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
}
playWhenReady = false
pendingPositionMs = 0
finished = true
reportTimeline("stopped")
changed()
save()
+1 -1
View File
@@ -30,7 +30,7 @@ final class StemPlaylists {
@ObservationIgnored private let file: URL
/// Every stem a song can have, for naming saved mixes without loading the song.
static let allStems = ["vocals", "drums", "bass", "guitar", "piano", "other"]
nonisolated static let allStems = ["vocals", "drums", "bass", "guitar", "piano", "other"]
init(dir: URL) {
file = dir.appendingPathComponent("stem-playlists.json")
+80
View File
@@ -0,0 +1,80 @@
import AVFoundation
import Foundation
import MediaPlayer
/// An internet radio stream the user saved in Browse > Web Radio.
struct WebStation: Codable, Hashable, Identifiable {
var id = UUID().uuidString
var name: String
var url: String
}
/// Plays a web radio stream through AVPlayer, outside the stem engine. Starting a station pauses
/// the queue, and playing the queue again stops the station. Main-thread only, like PlaybackManager.
@Observable
final class WebRadio: @unchecked Sendable {
private let pm: PlaybackManager
@ObservationIgnored private let player = AVPlayer()
@ObservationIgnored weak var nowPlaying: NowPlaying?
private(set) var station: WebStation?
private(set) var playing = false
/// A station is loaded (playing or paused); the lock screen belongs to the radio.
var active: Bool { station != nil }
init(pm: PlaybackManager) {
self.pm = pm
player.automaticallyWaitsToMinimizeStalling = true
pm.addListener { [weak self] in
guard let self, self.active, pm.playWhenReady else { return }
self.stop()
}
}
func play(_ s: WebStation) {
guard let url = URL(string: s.url) else { return }
station = s
if pm.playWhenReady { pm.pause() }
let a = AVAudioSession.sharedInstance()
try? a.setCategory(.playback, mode: .default, policy: .longFormAudio)
try? a.setActive(true)
player.replaceCurrentItem(with: AVPlayerItem(url: url))
player.play()
playing = true
publish()
}
func resume() {
guard active else { return }
// Live streams go stale while paused; rejoin at the live edge.
if let s = station { play(s) }
}
func pause() {
player.pause()
playing = false
publish()
}
func toggle() { if playing { pause() } else { resume() } }
func stop() {
player.pause()
player.replaceCurrentItem(with: nil)
station = nil
playing = false
nowPlaying?.refresh()
}
private func publish() {
guard let s = station else { return }
let c = MPNowPlayingInfoCenter.default()
c.nowPlayingInfo = [
MPMediaItemPropertyTitle: s.name,
MPMediaItemPropertyArtist: "Web Radio",
MPNowPlayingInfoPropertyIsLiveStream: true,
MPNowPlayingInfoPropertyPlaybackRate: playing ? 1.0 : 0.0,
]
c.playbackState = playing ? .playing : .paused
}
}