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

+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()