iOS 1.0.8: stem playlists, karaoke softness slider in lyrics

- Playlists tab splits into Plex and Stem. Plex playlists are unchanged and
  now show their custom poster when one is set.
- Stem playlists live on the device; each song keeps its own mix. Add from
  any song or collection menu, or copy a whole Plex playlist.
- Queue items can carry a mix, which wins over the song's saved mix.
- Softness slider under the Vocals picker in lyrics when set to Soft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-10-20 10:02:42 -07:00
1 parent 6f634522e5
commit e2cf62cb07
10 files changed
+374 -27

No files matched your search

+18 -14
View File
@@ -46,6 +46,8 @@ enum SourceKind { case None, Stems, Original }
struct QueueItem: Codable, Hashable, Identifiable {
var uid: Int64
var track: Track
/// Set when the song came from a stem playlist; it plays with this mix instead of its saved one.
var mix: [String: StemSetting]?
var id: Int64 { uid }
}
@@ -204,16 +206,17 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
// ---- queue --------------------------------------------------------------------------
private func items(_ tracks: [Track]) -> [QueueItem] {
tracks.map { t in
private func items(_ tracks: [Track], _ mixes: [[String: StemSetting]]? = nil) -> [QueueItem] {
tracks.enumerated().map { i, t in
defer { nextUid += 1 }
return QueueItem(uid: nextUid, track: t)
return QueueItem(uid: nextUid, track: t, mix: mixes.flatMap { i < $0.count ? $0[i] : nil })
}
}
func playTracks(_ tracks: [Track], start: Int = 0, shuffle doShuffle: Bool = false) {
/// `mixes` lines up with `tracks`: each song plays with its own mix (stem playlists).
func playTracks(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil, start: Int = 0, shuffle doShuffle: Bool = false) {
if tracks.isEmpty { return }
let items = items(tracks)
let items = items(tracks, mixes)
if doShuffle {
unshuffled = items
let first = start > 0 && start < items.count ? items[start] : items.randomElement()!
@@ -228,12 +231,12 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
}
}
func playNext(_ tracks: [Track]) { insert(index + 1, tracks) }
func addToQueue(_ tracks: [Track]) { insert(queue.count, tracks) }
func playNext(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil) { insert(index + 1, tracks, mixes) }
func addToQueue(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil) { insert(queue.count, tracks, mixes) }
private func insert(_ at: Int, _ tracks: [Track]) {
private func insert(_ at: Int, _ tracks: [Track], _ mixes: [[String: StemSetting]]? = nil) {
if tracks.isEmpty { return }
let items = items(tracks)
let items = items(tracks, mixes)
var q = queue
q.insert(contentsOf: items, at: min(max(at, 0), q.count))
queue = q
@@ -415,10 +418,11 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
pushMix()
}
/// The mix a song starts with: karaoke if that's on, else the song's saved mix, else cleared (or kept, per settings).
private func startMix(_ t: Track) -> [String: StemSetting] {
/// 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 { return karaokeMix }
return songMixes[t.ratingKey] ?? (settings.keepStemMix ? mix : [:])
return item.mix ?? songMixes[item.track.ratingKey] ?? (settings.keepStemMix ? mix : [:])
}
/// How much of the singer is left in karaoke mode. Soft is a guide under your own voice, as quiet as settings say.
@@ -527,7 +531,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
if play && !activateSession() { return }
playWhenReady = play
buffering = true
mix = startMix(item.track)
mix = startMix(item)
changed()
save()
loadTask = Task { @MainActor [weak self] in
@@ -700,7 +704,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
guard let item = current else { return }
durationMs = item.track.durationMs
waveform = nil
mix = startMix(item.track)
mix = startMix(item)
source = currentEntry != nil ? .Stems : .Original
stemNames = Stems.sort(currentEntry?.stems.map(\.name) ?? ["mix"])
pushMix()
+86
View File
@@ -0,0 +1,86 @@
import Foundation
import Observation
/// One song in a stem playlist, saved with the mix it plays with.
struct StemPlaylistItem: Codable, Hashable, Identifiable {
var uid = UUID().uuidString
var track: Track
var mix: [String: StemSetting]
var id: String { uid }
}
struct StemPlaylist: Codable, Hashable, Identifiable {
var id = UUID().uuidString
var title: String
var items: [StemPlaylistItem] = []
var created = Date()
var updated = Date()
var tracks: [Track] { items.map(\.track) }
var mixes: [[String: StemSetting]] { items.map(\.mix) }
var thumb: String? { items.lazy.compactMap(\.track.thumb).first }
var durationMs: Int64 { items.reduce(0) { $0 + $1.track.durationMs } }
}
/// Playlists that live on this device only: each song keeps its own stem mix (instrumentals, a cappellas…).
/// Plex playlists can't hold a mix, so these are separate.
@MainActor @Observable
final class StemPlaylists {
private(set) var lists: [StemPlaylist] = []
@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"]
init(dir: URL) {
file = dir.appendingPathComponent("stem-playlists.json")
lists = (try? JSONDecoder().decode([StemPlaylist].self, from: Data(contentsOf: file))) ?? []
}
func list(_ id: String) -> StemPlaylist? { lists.first { $0.id == id } }
@discardableResult
func create(_ title: String, tracks: [Track] = [], mix: [String: StemSetting] = [:]) -> StemPlaylist {
let pl = StemPlaylist(title: title, items: tracks.map { StemPlaylistItem(track: $0, mix: MixPresets.normalized(mix)) })
lists.insert(pl, at: 0)
save()
return pl
}
func add(_ id: String, tracks: [Track], mix: [String: StemSetting]) {
edit(id) { $0.items += tracks.map { StemPlaylistItem(track: $0, mix: MixPresets.normalized(mix)) } }
}
func setItems(_ id: String, _ items: [StemPlaylistItem]) { edit(id) { $0.items = items } }
func remove(_ id: String, item: String) { edit(id) { $0.items.removeAll { $0.uid == item } } }
func setMix(_ id: String, item: String, _ mix: [String: StemSetting]) {
edit(id) { pl in if let i = pl.items.firstIndex(where: { $0.uid == item }) { pl.items[i].mix = MixPresets.normalized(mix) } }
}
func rename(_ id: String, _ title: String) { edit(id) { $0.title = title } }
func delete(_ id: String) {
lists.removeAll { $0.id == id }
save()
}
/// "Instrumental", "A Cappella"… or "Custom Mix" for a hand-made one.
static func label(_ mix: [String: StemSetting]) -> String {
if MixPresets.normalized(mix).isEmpty { return MixPresets.full.label }
if MixPresets.current(allStems, mix) == MixPresets.karaoke { return "Instrumental" }
return MixPresets.current(allStems, mix)?.label ?? "Custom Mix"
}
private func edit(_ id: String, _ change: (inout StemPlaylist) -> Void) {
guard let i = lists.firstIndex(where: { $0.id == id }) else { return }
change(&lists[i])
lists[i].updated = Date()
save()
}
private func save() {
let all = lists, file = file
DispatchQueue.global(qos: .utility).async {
if let data = try? JSONEncoder().encode(all) { try? data.write(to: file, options: .atomic) }
}
}
}