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:
1 parent
6f634522e5
commit
e2cf62cb07
10 files changed
+374
-27
No files matched your search
@@ -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() }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user