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

+102 -1
View File
@@ -17,6 +17,7 @@ struct MediaMenu {
a.append(SheetAction("Remove Download", icon: "trash", destructive: true) { g.downloads.remove([t]) })
}
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { addToPlaylist([t]) })
a.append(SheetAction("Add to Stem Playlist…", icon: "slider.horizontal.3") { addToStemPlaylist([t]) })
if t.partFile != nil, g.stems.index.size > 0, g.stems.find(t) == nil { a.append(stemNow { [t] }) }
if allowGoTo, let k = t.albumKey {
a.append(SheetAction("Go to Album", icon: "square.stack") {
@@ -52,10 +53,49 @@ struct MediaMenu {
func playlist(_ pl: Playlist, extra: [SheetAction] = []) -> [SheetAction] {
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)
}
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).
func tracks(_ title: String, _ tracks: [Track], extra: [SheetAction] = []) -> [SheetAction] {
let have = tracks.filter { g.downloads.isDownloaded($0.ratingKey) }
@@ -90,6 +130,7 @@ struct MediaMenu {
})
}
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { withTracks(load) { addToPlaylist($0, skip: playlistKey) } })
a.append(SheetAction("Add to Stem Playlist…", icon: "slider.horizontal.3") { withTracks(load) { addToStemPlaylist($0) } })
if !stemmed { a.append(stemNow(load)) }
return a + extra
}
@@ -160,6 +201,66 @@ struct MediaMenu {
}
}
/// 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 addToStemPlaylist(_ tracks: [Track]) {
let lists = g.stemPlaylists
overlays.actions("Add \(songs(tracks.count)) to Stem Playlist", [SheetAction("New Stem Playlist…") { newStemPlaylist(tracks) }] + lists.lists.map { pl in
SheetAction(pl.title) {
chooseMix(tracks) { m in
lists.add(pl.id, tracks: tracks, mix: m)
overlays.hud("Added to \(pl.title)")
}
}
})
}
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
}
chooseMix(tracks) { m in
onCreated(lists.create(name, tracks: tracks, mix: m))
overlays.hud("Created “\(name)”")
}
}
}
/// Makes a stem playlist from a Plex one, every song with the same mix.
func copyToStems(_ pl: Playlist, onCreated: @escaping (StemPlaylist) -> Void = { _ in }) {
let plex = g.plex
let lists = g.stemPlaylists
overlays.prompt("Copy to Stem Playlist", "Every song gets the mix you pick next; change single songs later.", initial: pl.title, "Next") { name in
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))")
}
}
}
}
func newPlaylist(_ tracks: [Track], onCreated: @escaping () -> Void = {}) {
let plex = g.plex
overlays.prompt("New Playlist", "Name it; you can add more songs later.", "Create", placeholder: "Playlist name") { name in
+1 -1
View File
@@ -18,7 +18,7 @@ enum Route: Hashable {
case Artists, Albums, Songs, Playlists
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
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.
+1
View File
@@ -65,6 +65,7 @@ struct RootView: View {
case .ArtistPage(let a): ArtistScreen(artist: a)
case .AlbumPage(let a): AlbumScreen(album: a)
case .PlaylistPage(let pl): PlaylistScreen(playlist: pl)
case .StemPlaylistPage(let id): StemPlaylistScreen(id: id)
}
}
}
@@ -912,6 +912,15 @@ private struct LyricsPanel: View {
.pickerStyle(.segmented)
}
.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
}
.task(id: track.ratingKey) { await follow() }
+152 -8
View File
@@ -2,29 +2,49 @@ import SwiftUI
// ---- playlists ------------------------------------------------------------------------------
enum PlaylistKind: String, CaseIterable { case Plex, Stem }
struct PlaylistsScreen: View {
@Environment(Ui.self) private var ui
@State private var lists: LoadState<[Playlist]> = .loading
@State private var reload = 0
@AppStorage("playlistKind") private var kind = PlaylistKind.Plex
private let sort = SortState("playlists", PLAYLIST_SORTS)
private var g: AppGraph { .shared }
var body: some View {
Page(title: "Playlists") {
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.")
Picker("Kind", selection: $kind) {
Text("Plex").tag(PlaylistKind.Plex)
Text("Stem").tag(PlaylistKind.Stem)
}
.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
PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl))
} else {
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 {
ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) }
if kind == .Plex { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
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)") {
@@ -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") }
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 {