4 Commits
Author SHA1 Message Date
cdnutterandClaude Opus 5.5 eb541b9a16 iOS 1.0.9: Add to Playlist sheet
Replaces the chain of dialogs (each waited ~450 ms for the last to close)
with one sheet: Plex | Stem tabs, inline new-playlist name field, playlist
rows with art, and mix chips for stem playlists. One "Add to Playlist…"
menu entry now covers both kinds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:28:25 -07:00
cdnutterandClaude Opus 5.5 790f8a35a2 iOS: source.json for 1.0.8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:02:44 -07:00
cdnutterandClaude Opus 5.5 e2cf62cb07 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>
2026-10-20 10:02:42 -07:00
cdnutterandClaude Opus 5.5 6f634522e5 ios: source.json for 1.0.7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:39:04 -07:00
13 changed files with 589 additions and 49 deletions

No files matched your search

+2
View File
@@ -20,6 +20,7 @@ final class AppGraph {
let cache: MediaCache let cache: MediaCache
let eq: EqController let eq: EqController
let playback: PlaybackManager let playback: PlaybackManager
let stemPlaylists: StemPlaylists
let downloads: Downloads let downloads: Downloads
let coverage: StemCoverage let coverage: StemCoverage
let nowPlaying: NowPlaying let nowPlaying: NowPlaying
@@ -59,6 +60,7 @@ final class AppGraph {
cache = MediaCache(dir: media, session: mediaSession) { Int64(settings.cacheLimitGb) << 30 } cache = MediaCache(dir: media, session: mediaSession) { Int64(settings.cacheLimitGb) << 30 }
eq = EqController(dir: support) eq = EqController(dir: support)
stemPlaylists = StemPlaylists(dir: support)
playback = PlaybackManager(settings: settings, plex: plex, stems: stems, cache: cache, eq: eq, dir: support) playback = PlaybackManager(settings: settings, plex: plex, stems: stems, cache: cache, eq: eq, dir: support)
downloads = Downloads(settings: settings, plex: plex, stems: stems, cache: cache, net: net, dir: support) downloads = Downloads(settings: settings, plex: plex, stems: stems, cache: cache, net: net, dir: support)
coverage = StemCoverage(plex: plex, stems: stems, settings: settings, dir: support) coverage = StemCoverage(plex: plex, stems: stems, settings: settings, dir: support)
+18 -14
View File
@@ -46,6 +46,8 @@ enum SourceKind { case None, Stems, Original }
struct QueueItem: Codable, Hashable, Identifiable { struct QueueItem: Codable, Hashable, Identifiable {
var uid: Int64 var uid: Int64
var track: Track 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 } var id: Int64 { uid }
} }
@@ -204,16 +206,17 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
// ---- queue -------------------------------------------------------------------------- // ---- queue --------------------------------------------------------------------------
private func items(_ tracks: [Track]) -> [QueueItem] { private func items(_ tracks: [Track], _ mixes: [[String: StemSetting]]? = nil) -> [QueueItem] {
tracks.map { t in tracks.enumerated().map { i, t in
defer { nextUid += 1 } 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 } if tracks.isEmpty { return }
let items = items(tracks) let items = items(tracks, mixes)
if doShuffle { if doShuffle {
unshuffled = items unshuffled = items
let first = start > 0 && start < items.count ? items[start] : items.randomElement()! 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 playNext(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil) { insert(index + 1, tracks, mixes) }
func addToQueue(_ tracks: [Track]) { insert(queue.count, tracks) } 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 } if tracks.isEmpty { return }
let items = items(tracks) let items = items(tracks, mixes)
var q = queue var q = queue
q.insert(contentsOf: items, at: min(max(at, 0), q.count)) q.insert(contentsOf: items, at: min(max(at, 0), q.count))
queue = q queue = q
@@ -415,10 +418,11 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
pushMix() pushMix()
} }
/// The mix a song starts with: karaoke if that's on, else the song's saved mix, else cleared (or kept, per settings). /// The mix a song starts with: karaoke if that's on, else its stem playlist mix, else the song's saved mix,
private func startMix(_ t: Track) -> [String: StemSetting] { /// else cleared (or kept, per settings).
private func startMix(_ item: QueueItem) -> [String: StemSetting] {
if karaoke { return karaokeMix } 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. /// 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 } if play && !activateSession() { return }
playWhenReady = play playWhenReady = play
buffering = true buffering = true
mix = startMix(item.track) mix = startMix(item)
changed() changed()
save() save()
loadTask = Task { @MainActor [weak self] in loadTask = Task { @MainActor [weak self] in
@@ -700,7 +704,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
guard let item = current else { return } guard let item = current else { return }
durationMs = item.track.durationMs durationMs = item.track.durationMs
waveform = nil waveform = nil
mix = startMix(item.track) mix = startMix(item)
source = currentEntry != nil ? .Stems : .Original source = currentEntry != nil ? .Stems : .Original
stemNames = Stems.sort(currentEntry?.stems.map(\.name) ?? ["mix"]) stemNames = Stems.sort(currentEntry?.stems.map(\.name) ?? ["mix"])
pushMix() 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) }
}
}
}
+1 -1
View File
@@ -405,7 +405,7 @@ extension PlexItem {
func toArtist() -> Artist { Artist(ratingKey: ratingKey, title: title, sortTitle: titleSort ?? title, thumb: thumb) } func toArtist() -> Artist { Artist(ratingKey: ratingKey, title: title, sortTitle: titleSort ?? title, thumb: thumb) }
func toPlaylist() -> Playlist { func toPlaylist() -> Playlist {
Playlist(ratingKey: ratingKey, title: title, thumb: composite ?? thumb, trackCount: leafCount ?? 0, Playlist(ratingKey: ratingKey, title: title, thumb: thumb ?? composite, trackCount: leafCount ?? 0,
durationMs: duration ?? 0, smart: smart ?? false) durationMs: duration ?? 0, smart: smart ?? false)
} }
} }
@@ -0,0 +1,202 @@
import SwiftUI
/// One sheet for adding songs to a playlist: Plex or stem, new or existing, and for stem playlists the mix.
/// Replaces the chain of dialogs, each of which had to wait for the last to go away.
struct AddToPlaylistSheet: View {
let req: Overlays.AddToPlaylist
let overlays: Overlays
@Environment(\.dismiss) private var dismiss
@Environment(\.palette) private var p
@AppStorage("addPlaylistKind") private var kind = PlaylistKind.Plex
@State private var plex: LoadState<[Playlist]> = .loading
@State private var naming = false
@State private var name = ""
@State private var mixIndex = 0
@State private var busy: String?
@FocusState private var nameFocused: Bool
private var g: AppGraph { .shared }
private struct MixOption { var label: String; var mix: [String: StemSetting] }
/// What's playing now and the song's saved mix (for a single song), then the presets.
private var mixes: [MixOption] {
let pm = g.playback
var o: [MixOption] = []
if req.tracks.count == 1, let t = req.tracks.first {
if pm.current?.track.ratingKey == t.ratingKey { o.append(MixOption(label: "Now: \(StemPlaylists.label(pm.mix))", mix: pm.mix)) }
if let m = pm.songMixes[t.ratingKey] { o.append(MixOption(label: "Saved: \(StemPlaylists.label(m))", mix: m)) }
}
for preset in MixPresets.all {
if let m = preset.build(StemPlaylists.allStems) { o.append(MixOption(label: StemPlaylists.label(m), mix: m)) }
}
return o
}
private var plexKeys: [String] { req.tracks.map(\.ratingKey).filter { !$0.hasPrefix("stem:") } }
var body: some View {
NavigationStack {
List {
Section {
songHeader
Picker("Kind", selection: $kind) {
Text("Plex").tag(PlaylistKind.Plex)
Text("Stem").tag(PlaylistKind.Stem)
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
}
if kind == .Stem {
Section("Mix") { mixChips }
}
Section {
newRow
if kind == .Plex { plexRows } else { stemRows }
}
}
.navigationTitle("Add to Playlist")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } }
.animation(.default, value: kind)
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.task {
let plexClient = g.plex
let skip = req.skip
await LoadState.load($plex) { try await plexClient.playlists().filter { !$0.smart && $0.ratingKey != skip } }
}
.onChange(of: kind) { _, _ in naming = false; name = "" }
}
private var songHeader: some View {
let t = req.tracks.first
return HStack(spacing: 12) {
Artwork(thumb: t?.thumb, size: 48, corner: 6, placeholder: "music.note")
VStack(alignment: .leading, spacing: 2) {
Text(req.tracks.count == 1 ? t?.title ?? "" : songs(req.tracks.count)).foregroundStyle(p.label).lineLimit(1)
Text(req.tracks.count == 1 ? t?.artist ?? "" : req.title).font(.subheadline).foregroundStyle(p.label2).lineLimit(1)
}
}
.listRowBackground(Color.clear)
}
private var mixChips: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(mixes.enumerated()), id: \.offset) { i, o in
let on = i == mixIndex
Button { mixIndex = i } label: {
Text(o.label).font(.subheadline.weight(.medium))
.padding(.horizontal, 12).padding(.vertical, 7)
.foregroundStyle(on ? Color.white : p.label)
.background(on ? p.accent : p.fill, in: Capsule())
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 2)
}
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
}
@ViewBuilder private var newRow: some View {
if naming {
HStack {
TextField(kind == .Plex ? "Playlist name" : "Stem playlist name", text: $name)
.focused($nameFocused).submitLabel(.done).onSubmit(create)
Button("Create", action: create).bold()
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || busy != nil)
}
} else {
Button {
naming = true
nameFocused = true
} label: {
Label(kind == .Plex ? "New Playlist" : "New Stem Playlist", systemImage: "plus").foregroundStyle(p.accent)
}
}
}
@ViewBuilder private var plexRows: some View {
if plexKeys.isEmpty {
Text("These songs aren't in Plex. Use a stem playlist instead.").foregroundStyle(p.label2)
} else {
switch plex {
case .loading: HStack { Spacer(); ProgressView(); Spacer() }
case .err(let e): Text("Couldn't load playlists: \(e)").foregroundStyle(p.label2)
case .ok(let lists):
ForEach(lists) { pl in
row(pl.title, pl.thumb, songs(pl.trackCount), "music.note.list", busy: busy == pl.ratingKey) { addPlex(pl) }
}
}
}
}
@ViewBuilder private var stemRows: some View {
ForEach(g.stemPlaylists.lists) { pl in
row(pl.title, pl.thumb, songs(pl.items.count), "slider.horizontal.3", busy: false) {
g.stemPlaylists.add(pl.id, tracks: req.tracks, mix: mixes[mixIndex].mix)
done("Added to \(pl.title)")
}
}
}
private func row(_ title: String, _ thumb: String?, _ sub: String, _ placeholder: String, busy: Bool, _ tap: @escaping () -> Void) -> some View {
Button(action: tap) {
HStack(spacing: 12) {
Artwork(thumb: thumb, size: 44, corner: 5, placeholder: placeholder)
VStack(alignment: .leading, spacing: 1) {
Text(title).foregroundStyle(p.label).lineLimit(1)
Text(sub).font(.caption).foregroundStyle(p.label2)
}
Spacer()
if busy { ProgressView() }
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(self.busy != nil)
}
private func create() {
let n = name.trimmingCharacters(in: .whitespacesAndNewlines)
if n.isEmpty || busy != nil { return }
if kind == .Stem {
g.stemPlaylists.create(n, tracks: req.tracks, mix: mixes[mixIndex].mix)
done("Created “\(n)”")
return
}
busy = "new"
let plexClient = g.plex, keys = plexKeys
Task {
do {
_ = try await plexClient.createPlaylist(n, trackKeys: keys)
done("Created “\(n)”")
} catch {
busy = nil
overlays.hud("Couldn't create: \(error.localizedDescription)", "exclamationmark.triangle")
}
}
}
private func addPlex(_ pl: Playlist) {
busy = pl.ratingKey
let plexClient = g.plex, keys = plexKeys
Task {
do {
try await plexClient.addToPlaylist(pl.ratingKey, trackKeys: keys)
done("Added to \(pl.title)")
} catch {
busy = nil
overlays.hud("Couldn't add: \(error.localizedDescription)", "exclamationmark.triangle")
}
}
}
private func done(_ msg: String) {
dismiss()
overlays.hud(msg)
}
}
+87 -23
View File
@@ -52,10 +52,49 @@ struct MediaMenu {
func playlist(_ pl: Playlist, extra: [SheetAction] = []) -> [SheetAction] { func playlist(_ pl: Playlist, extra: [SheetAction] = []) -> [SheetAction] {
let plex = g.plex let plex = g.plex
return collection(pl.title, [], false, { try await plex.playlistTracks(pl.ratingKey) }, playlistKey: pl.ratingKey, extra: extra, let copy = SheetAction("Copy to Stem Playlist…", icon: "doc.on.doc") { copyToStems(pl) }
return collection(pl.title, [], false, { try await plex.playlistTracks(pl.ratingKey) }, playlistKey: pl.ratingKey, extra: [copy] + extra,
confirmDownload: pl.trackCount > 50) confirmDownload: pl.trackCount > 50)
} }
func stemPlaylist(_ pl: StemPlaylist, extra: [SheetAction] = [], onPage: Bool = false) -> [SheetAction] {
var a = [
SheetAction("Add to Front of Queue", icon: "text.line.first.and.arrowtriangle.forward") {
g.playback.playNext(pl.tracks, mixes: pl.mixes); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward")
},
SheetAction("Add to End of Queue", icon: "text.line.last.and.arrowtriangle.forward") {
g.playback.addToQueue(pl.tracks, mixes: pl.mixes); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward")
},
]
let missing = pl.tracks.filter { !g.downloads.isDownloaded($0.ratingKey) }
if !missing.isEmpty {
a.append(SheetAction("Download", icon: "arrow.down.circle") { g.downloads.download(missing); overlays.hud("Downloading", "arrow.down.circle") })
}
if pl.items.isEmpty { a = [] }
let lists = g.stemPlaylists
a.append(SheetAction("Delete Stem Playlist", icon: "trash", destructive: true) {
overlays.confirm("Delete “\(pl.title)”?", "Stem playlists are only on this device.", "Delete") { lists.delete(pl.id); if onPage { ui.pop() } }
})
return extra + a
}
/// A song in a stem playlist: queue it with its mix, change the mix, or take it out.
func stemItem(_ item: StemPlaylistItem, in listId: String) -> [SheetAction] {
let lists = g.stemPlaylists
return [
SheetAction("Add to Front of Queue", icon: "text.line.first.and.arrowtriangle.forward") {
g.playback.playNext([item.track], mixes: [item.mix]); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward")
},
SheetAction("Add to End of Queue", icon: "text.line.last.and.arrowtriangle.forward") {
g.playback.addToQueue([item.track], mixes: [item.mix]); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward")
},
SheetAction("Change Mix…", icon: "slider.horizontal.3") {
chooseMix([item.track]) { lists.setMix(listId, item: item.uid, $0) }
},
SheetAction("Remove from Stem Playlist", icon: "minus.circle", destructive: true) { lists.remove(listId, item: item.uid) },
]
}
/// For songs already in hand (downloads with no Plex album behind them). /// For songs already in hand (downloads with no Plex album behind them).
func tracks(_ title: String, _ tracks: [Track], extra: [SheetAction] = []) -> [SheetAction] { func tracks(_ title: String, _ tracks: [Track], extra: [SheetAction] = []) -> [SheetAction] {
let have = tracks.filter { g.downloads.isDownloaded($0.ratingKey) } let have = tracks.filter { g.downloads.isDownloaded($0.ratingKey) }
@@ -89,7 +128,7 @@ struct MediaMenu {
overlays.confirm("Remove Download?", "“\(title)” will no longer be available offline.", "Remove") { g.downloads.remove(downloaded) } overlays.confirm("Remove Download?", "“\(title)” will no longer be available offline.", "Remove") { g.downloads.remove(downloaded) }
}) })
} }
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { withTracks(load) { addToPlaylist($0, skip: playlistKey) } }) a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { withTracks(load) { addToPlaylist($0, skip: playlistKey, title: title) } })
if !stemmed { a.append(stemNow(load)) } if !stemmed { a.append(stemNow(load)) }
return a + extra return a + extra
} }
@@ -132,31 +171,56 @@ struct MediaMenu {
} }
} }
func addToPlaylist(_ tracks: [Track], skip: String? = nil) { /// Plex or stem playlist, picked in one sheet.
Task { func addToPlaylist(_ tracks: [Track], skip: String? = nil, title: String = "") {
let lists: [Playlist] overlays.addToPlaylist(tracks, title: title, skip: skip)
do { lists = try await g.plex.playlists().filter { !$0.smart && $0.ratingKey != skip } } catch { }
overlays.hud("Couldn't load playlists", "exclamationmark.triangle")
/// Picks the mix the songs get in a stem playlist: what's playing now, the song's saved mix, or a preset.
func chooseMix(_ tracks: [Track], _ then: @escaping ([String: StemSetting]) -> Void) {
let pm = g.playback
var acts: [SheetAction] = []
if tracks.count == 1, let t = tracks.first {
if pm.current?.track.ratingKey == t.ratingKey {
let m = pm.mix
acts.append(SheetAction("Current Mix (\(StemPlaylists.label(m)))", icon: "play.circle") { then(m) })
}
if let m = pm.songMixes[t.ratingKey] {
acts.append(SheetAction("This Song’s Mix (\(StemPlaylists.label(m)))", icon: "bookmark") { then(m) })
}
}
for p in MixPresets.all {
guard let m = p.build(StemPlaylists.allStems) else { continue }
acts.append(SheetAction(StemPlaylists.label(m)) { then(m) })
}
overlays.actions("Choose a Mix", acts)
}
func newStemPlaylist(_ tracks: [Track] = [], onCreated: @escaping (StemPlaylist) -> Void = { _ in }) {
let lists = g.stemPlaylists
overlays.prompt("New Stem Playlist", "Every song in it plays with the mix you pick.", "Create", placeholder: "Playlist name") { name in
if tracks.isEmpty {
onCreated(lists.create(name))
return return
} }
let keys = tracks.map(\.ratingKey).filter { !$0.hasPrefix("stem:") } chooseMix(tracks) { m in
if keys.isEmpty { onCreated(lists.create(name, tracks: tracks, mix: m))
overlays.hud("These songs aren't in Plex, so they can't go in a playlist", "info.circle") overlays.hud("Created “\(name)”")
return
} }
let plex = g.plex }
overlays.actions("Add \(songs(keys.count)) to Playlist", [SheetAction("New Playlist…") { newPlaylist(tracks) }] + lists.map { pl in }
SheetAction(pl.title) {
Task { /// Makes a stem playlist from a Plex one, every song with the same mix.
do { func copyToStems(_ pl: Playlist, onCreated: @escaping (StemPlaylist) -> Void = { _ in }) {
try await plex.addToPlaylist(pl.ratingKey, trackKeys: keys) let plex = g.plex
overlays.hud("Added to \(pl.title)") let lists = g.stemPlaylists
} catch { overlays.prompt("Copy to Stem Playlist", "Every song gets the mix you pick next; change single songs later.", initial: pl.title, "Next") { name in
overlays.hud("Couldn't add: \(error.localizedDescription)", "exclamationmark.triangle") withTracks({ try await plex.playlistTracks(pl.ratingKey) }) { tracks in
} chooseMix(tracks) { m in
} onCreated(lists.create(name, tracks: tracks, mix: m))
overlays.hud("Copied \(songs(tracks.count))")
} }
}) }
} }
} }
+10
View File
@@ -22,11 +22,13 @@ final class Overlays {
struct Confirm { var title: String; var message: String?; var button: String; var destructive: Bool; var run: () -> Void } struct Confirm { var title: String; var message: String?; var button: String; var destructive: Bool; var run: () -> Void }
struct Prompt { var title: String; var message: String?; var initial: String; var button: String; var placeholder: String; var run: (String) -> Void } struct Prompt { var title: String; var message: String?; var initial: String; var button: String; var placeholder: String; var run: (String) -> Void }
struct Hud: Equatable { let id = UUID(); var text: String; var icon: String } struct Hud: Equatable { let id = UUID(); var text: String; var icon: String }
struct AddToPlaylist: Identifiable { let id = UUID(); var tracks: [Track]; var title: String; var skip: String? }
var actions: Actions? var actions: Actions?
var confirm: Confirm? var confirm: Confirm?
var prompt: Prompt? var prompt: Prompt?
var hud: Hud? var hud: Hud?
var addToPlaylist: AddToPlaylist?
/// 1 while the Now Playing sheet is up, so its host shows overlays instead of the root's. /// 1 while the Now Playing sheet is up, so its host shows overlays instead of the root's.
var level = 0 var level = 0
@@ -59,6 +61,10 @@ final class Overlays {
present { self.prompt = Prompt(title: title, message: message, initial: initial, button: button, placeholder: placeholder, run: run) } present { self.prompt = Prompt(title: title, message: message, initial: initial, button: button, placeholder: placeholder, run: run) }
} }
func addToPlaylist(_ tracks: [Track], title: String, skip: String?) {
present { self.addToPlaylist = AddToPlaylist(tracks: tracks, title: title, skip: skip) }
}
func hud(_ text: String, _ icon: String = "checkmark") { func hud(_ text: String, _ icon: String = "checkmark") {
hudTask?.cancel() hudTask?.cancel()
withAnimation(.easeOut(duration: 0.15)) { hud = Hud(text: text, icon: icon) } withAnimation(.easeOut(duration: 0.15)) { hud = Hud(text: text, icon: icon) }
@@ -75,6 +81,7 @@ struct OverlayHost: ViewModifier {
let overlays: Overlays let overlays: Overlays
let level: Int let level: Int
@State private var text = "" @State private var text = ""
@Environment(\.palette) private var p
private var mine: Bool { overlays.level == level } private var mine: Bool { overlays.level == level }
@@ -109,6 +116,9 @@ struct OverlayHost: ViewModifier {
if let m = p.message { Text(m) } if let m = p.message { Text(m) }
} }
.onChange(of: overlays.prompt?.title) { _, _ in text = overlays.prompt?.initial ?? "" } .onChange(of: overlays.prompt?.title) { _, _ in text = overlays.prompt?.initial ?? "" }
.sheet(item: Binding(get: { mine ? overlays.addToPlaylist : nil }, set: { overlays.addToPlaylist = $0 })) { r in
AddToPlaylistSheet(req: r, overlays: overlays).environment(\.palette, p).tint(p.accent)
}
} }
private func binding<T>(_ kp: ReferenceWritableKeyPath<Overlays, T?>) -> Binding<Bool> { private func binding<T>(_ kp: ReferenceWritableKeyPath<Overlays, T?>) -> Binding<Bool> {
+1 -1
View File
@@ -18,7 +18,7 @@ enum Route: Hashable {
case Artists, Albums, Songs, Playlists case Artists, Albums, Songs, Playlists
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String) case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
case PlexAccount, StemServer, Quality, Storage, Appearance case PlexAccount, StemServer, Quality, Storage, Appearance
case ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist) case ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String)
} }
/// Tabs, their stacks and the Now Playing sheet: the things any screen can ask of the shell. /// Tabs, their stacks and the Now Playing sheet: the things any screen can ask of the shell.
+1
View File
@@ -65,6 +65,7 @@ struct RootView: View {
case .ArtistPage(let a): ArtistScreen(artist: a) case .ArtistPage(let a): ArtistScreen(artist: a)
case .AlbumPage(let a): AlbumScreen(album: a) case .AlbumPage(let a): AlbumScreen(album: a)
case .PlaylistPage(let pl): PlaylistScreen(playlist: pl) case .PlaylistPage(let pl): PlaylistScreen(playlist: pl)
case .StemPlaylistPage(let id): StemPlaylistScreen(id: id)
} }
} }
} }
@@ -912,6 +912,15 @@ private struct LyricsPanel: View {
.pickerStyle(.segmented) .pickerStyle(.segmented)
} }
.padding(.horizontal, 28).padding(.top, 8) .padding(.horizontal, 28).padding(.top, 8)
if g.playback.karaokeVocals == .Soft {
let db = g.settings.karaokeSoftDb
HStack(spacing: 12) {
Text("Softness").font(.subheadline.weight(.semibold)).foregroundStyle(p.label2)
Slider(value: Binding(get: { Double(db) }, set: { g.playback.setKaraokeSoftDb(Int($0.rounded())) }), in: -30...(-3), step: 1)
Text("\(db) dB").font(.caption).monospacedDigit().foregroundStyle(p.label2).frame(width: 46, alignment: .trailing)
}
.padding(.horizontal, 28).padding(.top, 8)
}
content content
} }
.task(id: track.ratingKey) { await follow() } .task(id: track.ratingKey) { await follow() }
+152 -8
View File
@@ -2,29 +2,49 @@ import SwiftUI
// ---- playlists ------------------------------------------------------------------------------ // ---- playlists ------------------------------------------------------------------------------
enum PlaylistKind: String, CaseIterable { case Plex, Stem }
struct PlaylistsScreen: View { struct PlaylistsScreen: View {
@Environment(Ui.self) private var ui @Environment(Ui.self) private var ui
@State private var lists: LoadState<[Playlist]> = .loading @State private var lists: LoadState<[Playlist]> = .loading
@State private var reload = 0 @State private var reload = 0
@AppStorage("playlistKind") private var kind = PlaylistKind.Plex
private let sort = SortState("playlists", PLAYLIST_SORTS) private let sort = SortState("playlists", PLAYLIST_SORTS)
private var g: AppGraph { .shared } private var g: AppGraph { .shared }
var body: some View { var body: some View {
Page(title: "Playlists") { Page(title: "Playlists") {
Loaded(state: lists, retry: { reload += 1 }) { list in Picker("Kind", selection: $kind) {
if list.isEmpty { Text("Plex").tag(PlaylistKind.Plex)
EmptyState(icon: "music.note.list", title: "No Playlists", Text("Stem").tag(PlaylistKind.Stem)
message: "Hold any song, album, artist or playlist and choose “Add to Playlist”, or + to make one from your queue. They sync with Plexamp.") }
.pickerStyle(.segmented)
.padding(.horizontal, MARGIN).padding(.bottom, 8)
if kind == .Plex {
Loaded(state: lists, retry: { reload += 1 }) { list in
if list.isEmpty {
EmptyState(icon: "music.note.list", title: "No Playlists",
message: "Hold any song, album, artist or playlist and choose “Add to Playlist”, or + to make one from your queue. They sync with Plexamp.")
}
ForEach(list) { pl in
PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl))
}
} }
ForEach(list) { pl in } else {
PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl)) let stems = g.stemPlaylists.lists
if stems.isEmpty {
EmptyState(icon: "slider.horizontal.3", title: "No Stem Playlists",
message: "Each song plays with its own mix, like an instrumental or a cappella. Hold any song and choose “Add to Stem Playlist”, or copy a Plex playlist with +.")
}
ForEach(stems) { pl in
StemPlaylistRow(playlist: pl).onTapGesture { ui.push(.StemPlaylistPage(pl.id)) }.actionsMenu(ui.menu.stemPlaylist(pl))
} }
} }
} }
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } if kind == .Plex { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button { newPlaylist() } label: { Image(systemName: "plus") } Button { kind == .Plex ? newPlaylist() : newStemPlaylist() } label: { Image(systemName: "plus") }
} }
} }
.task(id: "\(g.settings.musicSection)|\(sort.param)|\(reload)") { .task(id: "\(g.settings.musicSection)|\(sort.param)|\(reload)") {
@@ -45,6 +65,130 @@ struct PlaylistsScreen: View {
if acts.isEmpty { ui.overlays.hud("Play something first, or use “Add to Playlist” on any song.", "info.circle") } if acts.isEmpty { ui.overlays.hud("Play something first, or use “Add to Playlist” on any song.", "info.circle") }
else { ui.overlays.actions("New Playlist", acts) } else { ui.overlays.actions("New Playlist", acts) }
} }
private func newStemPlaylist() {
let menu = ui.menu
let open: (StemPlaylist) -> Void = { ui.push(.StemPlaylistPage($0.id)) }
ui.overlays.actions("New Stem Playlist", [
SheetAction("Empty Stem Playlist…", icon: "plus") { menu.newStemPlaylist(onCreated: open) },
SheetAction("Copy from Plex Playlist…", icon: "doc.on.doc") {
Task {
guard let plex = try? await g.plex.playlists() else {
ui.overlays.hud("Couldn't load playlists", "exclamationmark.triangle")
return
}
if plex.isEmpty { ui.overlays.hud("No Plex playlists to copy", "info.circle"); return }
ui.overlays.actions("Copy from Plex", plex.map { pl in SheetAction(pl.title) { menu.copyToStems(pl, onCreated: open) } })
}
},
])
}
}
struct StemPlaylistRow: View {
let playlist: StemPlaylist
@Environment(\.palette) private var p
var body: some View {
HStack(spacing: 14) {
Artwork(thumb: playlist.thumb, size: 60, corner: 6, placeholder: "slider.horizontal.3")
VStack(alignment: .leading, spacing: 2) {
Text(playlist.title).foregroundStyle(p.label).lineLimit(1)
Text(songs(playlist.items.count)).font(.subheadline).foregroundStyle(p.label2)
}
Spacer(minLength: 8)
Tag(text: "STEMS")
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3)
}
.frame(height: 76)
.contentShape(Rectangle())
.listRow(inset: 94, trailing: 16)
}
}
/// A stem playlist: lives on this device, and every song plays with its saved mix.
struct StemPlaylistScreen: View {
let id: String
@Environment(Ui.self) private var ui
@Environment(\.palette) private var p
@State private var editing = false
@State private var items: [StemPlaylistItem] = []
private var g: AppGraph { .shared }
var body: some View {
if let pl = g.stemPlaylists.list(id) {
page(pl)
} else {
EmptyState(icon: "slider.horizontal.3", title: "Playlist Gone", message: "This stem playlist was deleted.")
}
}
private func page(_ pl: StemPlaylist) -> some View {
Group {
if editing {
List {
header(pl).listRowBackground(Color.clear).listRowSeparator(.hidden)
ForEach(items) { it in
TrackRow(track: it.track, subtitle: StemPlaylists.label(it.mix)) {}.listRowBackground(p.bg)
}
.onDelete { items.remove(atOffsets: $0) }
.onMove { items.move(fromOffsets: $0, toOffset: $1) }
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(p.bg)
.environment(\.editMode, .constant(.active))
.navigationTitle(pl.title)
.navigationBarTitleDisplayMode(.inline)
} else {
Page(title: pl.title, large: false) {
header(pl)
if pl.items.isEmpty {
EmptyState(icon: "slider.horizontal.3", title: "No Songs",
message: "Hold any song, album or playlist and choose “Add to Stem Playlist”.")
}
ForEach(Array(pl.items.enumerated()), id: \.element.id) { i, it in
TrackRow(track: it.track, subtitle: "\(it.track.artist) · \(StemPlaylists.label(it.mix))", menu: ui.menu.stemItem(it, in: pl.id)) {
g.playback.playTracks(pl.tracks, mixes: pl.mixes, start: i)
}
.listRow(inset: MARGIN + 60)
}
}
}
}
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
if editing { Button("Done") { g.stemPlaylists.setItems(pl.id, items); editing = false }.bold() }
else if !pl.items.isEmpty { Button("Edit") { items = pl.items; editing = true } }
if !editing { MoreMenu(items: moreItems(pl)) }
}
}
}
private func header(_ pl: StemPlaylist) -> some View {
VStack(spacing: 0) {
Artwork(thumb: pl.thumb, size: 230, corner: 10, placeholder: "slider.horizontal.3")
Text(pl.title).font(.title2.bold()).foregroundStyle(p.label).multilineTextAlignment(.center).lineLimit(2)
.padding(.horizontal, 32).padding(.top, 14)
Text(pl.items.isEmpty ? "Stem Playlist" : "\(songs(pl.items.count)) · \(formatMinutes(pl.durationMs))").font(.subheadline).foregroundStyle(p.label2)
if !editing && !pl.items.isEmpty {
PlayShuffle(play: { g.playback.playTracks(pl.tracks, mixes: pl.mixes) },
shuffle: { g.playback.playTracks(pl.tracks, mixes: pl.mixes, shuffle: true) }).padding(.top, 14)
}
}
.frame(maxWidth: .infinity)
.padding(.top, 8).padding(.bottom, 16)
}
private func moreItems(_ pl: StemPlaylist) -> [SheetAction] {
let lists = g.stemPlaylists
let rename = SheetAction("Rename…", icon: "pencil") {
ui.overlays.prompt("Rename Stem Playlist", nil, initial: pl.title, "Save") { lists.rename(pl.id, $0) }
}
let mixAll = SheetAction("Set Mix for All Songs…", icon: "slider.horizontal.3") {
ui.menu.chooseMix(pl.tracks) { m in lists.setItems(pl.id, pl.items.map { var it = $0; it.mix = MixPresets.normalized(m); return it }) }
}
return ui.menu.stemPlaylist(pl, extra: pl.items.isEmpty ? [rename] : [rename, mixAll], onPage: true)
}
} }
struct PlaylistRow: View { struct PlaylistRow: View {
+2 -2
View File
@@ -6,8 +6,8 @@ options:
settings: settings:
base: base:
SWIFT_VERSION: "5.0" SWIFT_VERSION: "5.0"
MARKETING_VERSION: "1.0.7" MARKETING_VERSION: "1.0.9"
CURRENT_PROJECT_VERSION: "8" CURRENT_PROJECT_VERSION: "10"
targets: targets:
Stemify: Stemify:
type: application type: application
+18
View File
@@ -15,6 +15,24 @@
"tintColor": "#FA2D48", "tintColor": "#FA2D48",
"category": "entertainment", "category": "entertainment",
"versions": [ "versions": [
{
"version": "1.0.8",
"buildVersion": "9",
"date": "2026-10-20T10:02:44-07:00",
"localizedDescription": "- Playlists tab now has Plex and Stem sections. Plex playlists are unchanged and use their custom Plex poster when there is one.\n- Stem playlists: each song keeps its own mix (instrumental, a cappella, no drums, custom...). Add songs from any menu, or copy a Plex playlist over in one go. Change one song's mix or the whole list's.\n- Softness slider under the Vocals picker in the lyrics view when vocals are set to Soft.\n\nUnsigned IPA: sideload with AltStore, SideStore or Feather.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.8/stemify-1.0.8.ipa",
"size": 1462423,
"minOSVersion": "17.0"
},
{
"version": "1.0.7",
"buildVersion": "8",
"date": "2026-10-20T09:39:03-07:00",
"localizedDescription": "- Mini player now sits in the tab bar's Liquid Glass accessory on iOS 26.1 and later, like Music. The tab bar shrinks when you scroll down and the player collapses to title and play/pause. Older iOS keeps the floating card.\n- Settings > Sound > Karaoke Soft Vocals: set how quiet Soft vocals are (-30 to -3 dB, default -12).\n\nUnsigned IPA: sideload with AltStore, SideStore or Feather.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.7/stemify-1.0.7.ipa",
"size": 1390623,
"minOSVersion": "17.0"
},
{ {
"version": "1.0.6", "version": "1.0.6",
"buildVersion": "7", "buildVersion": "7",