diff --git a/ios/Stemify/UI/Components/AddToPlaylistSheet.swift b/ios/Stemify/UI/Components/AddToPlaylistSheet.swift new file mode 100644 index 0000000..9586df0 --- /dev/null +++ b/ios/Stemify/UI/Components/AddToPlaylistSheet.swift @@ -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) + } +} diff --git a/ios/Stemify/UI/Components/MediaMenu.swift b/ios/Stemify/UI/Components/MediaMenu.swift index f610b00..dea974d 100644 --- a/ios/Stemify/UI/Components/MediaMenu.swift +++ b/ios/Stemify/UI/Components/MediaMenu.swift @@ -17,7 +17,6 @@ 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") { @@ -129,8 +128,7 @@ struct MediaMenu { 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 Stem Playlist…", icon: "slider.horizontal.3") { withTracks(load) { addToStemPlaylist($0) } }) + a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { withTracks(load) { addToPlaylist($0, skip: playlistKey, title: title) } }) if !stemmed { a.append(stemNow(load)) } return a + extra } @@ -173,32 +171,9 @@ struct MediaMenu { } } - func addToPlaylist(_ tracks: [Track], skip: String? = nil) { - Task { - let lists: [Playlist] - do { lists = try await g.plex.playlists().filter { !$0.smart && $0.ratingKey != skip } } catch { - overlays.hud("Couldn't load playlists", "exclamationmark.triangle") - return - } - let keys = tracks.map(\.ratingKey).filter { !$0.hasPrefix("stem:") } - if keys.isEmpty { - overlays.hud("These songs aren't in Plex, so they can't go in a playlist", "info.circle") - 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 { - do { - try await plex.addToPlaylist(pl.ratingKey, trackKeys: keys) - overlays.hud("Added to \(pl.title)") - } catch { - overlays.hud("Couldn't add: \(error.localizedDescription)", "exclamationmark.triangle") - } - } - } - }) - } + /// Plex or stem playlist, picked in one sheet. + func addToPlaylist(_ tracks: [Track], skip: String? = nil, title: String = "") { + overlays.addToPlaylist(tracks, title: title, skip: skip) } /// Picks the mix the songs get in a stem playlist: what's playing now, the song's saved mix, or a preset. @@ -221,18 +196,6 @@ struct MediaMenu { 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 diff --git a/ios/Stemify/UI/Components/Overlays.swift b/ios/Stemify/UI/Components/Overlays.swift index d4fb348..25fe210 100644 --- a/ios/Stemify/UI/Components/Overlays.swift +++ b/ios/Stemify/UI/Components/Overlays.swift @@ -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 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 AddToPlaylist: Identifiable { let id = UUID(); var tracks: [Track]; var title: String; var skip: String? } var actions: Actions? var confirm: Confirm? var prompt: Prompt? var hud: Hud? + var addToPlaylist: AddToPlaylist? /// 1 while the Now Playing sheet is up, so its host shows overlays instead of the root's. 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) } } + 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") { hudTask?.cancel() withAnimation(.easeOut(duration: 0.15)) { hud = Hud(text: text, icon: icon) } @@ -75,6 +81,7 @@ struct OverlayHost: ViewModifier { let overlays: Overlays let level: Int @State private var text = "" + @Environment(\.palette) private var p private var mine: Bool { overlays.level == level } @@ -109,6 +116,9 @@ struct OverlayHost: ViewModifier { if let m = p.message { Text(m) } } .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(_ kp: ReferenceWritableKeyPath) -> Binding { diff --git a/ios/project.yml b/ios/project.yml index 939ad5b..26ee8bc 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -6,8 +6,8 @@ options: settings: base: SWIFT_VERSION: "5.0" - MARKETING_VERSION: "1.0.8" - CURRENT_PROJECT_VERSION: "9" + MARKETING_VERSION: "1.0.9" + CURRENT_PROJECT_VERSION: "10" targets: Stemify: type: application