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) } }