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>
This commit is contained in:
1 parent
790f8a35a2
commit
eb541b9a16
4 files changed
+218
-43
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user