import SwiftUI // ---- playlists ------------------------------------------------------------------------------ struct PlaylistsScreen: View { @Environment(Ui.self) private var ui @State private var lists: LoadState<[Playlist]> = .loading @State private var reload = 0 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.") } ForEach(list) { pl in PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl)) } } } .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { newPlaylist() } label: { Image(systemName: "plus") } } } .task(id: "\(g.settings.musicSection)|\(reload)") { await LoadState.load($lists) { try await g.plex.playlists() } } } /// Plex can't hold an empty playlist, so new ones start from songs you have. private func newPlaylist() { let queue = g.playback.queue var acts: [SheetAction] = [] if !queue.isEmpty { acts.append(SheetAction("New Playlist from Queue (\(queue.count))") { ui.menu.newPlaylist(queue.map(\.track)) { reload += 1 } }) } if let now = g.playback.current?.track { acts.append(SheetAction("New Playlist with “\(now.title)”") { ui.menu.newPlaylist([now]) { reload += 1 } }) } 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) } } } struct PlaylistRow: View { let playlist: Playlist @Environment(\.palette) private var p var body: some View { HStack(spacing: 14) { Artwork(thumb: playlist.thumb, size: 60, corner: 6, placeholder: "music.note.list") VStack(alignment: .leading, spacing: 2) { Text(playlist.title).foregroundStyle(p.label).lineLimit(1) Text(songs(playlist.trackCount)).font(.subheadline).foregroundStyle(p.label2) } Spacer(minLength: 8) if playlist.smart { Tag(text: "SMART") } Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3) } .frame(height: 76) .contentShape(Rectangle()) .listRow(inset: 94, trailing: 16) } } struct PlaylistScreen: View { let playlist: Playlist @Environment(Ui.self) private var ui @Environment(\.palette) private var p @State private var tracks: LoadState<[Track]> = .loading @State private var reload = 0 @State private var title: String @State private var editing = false @State private var items: [Track] = [] @State private var removed: [Track] = [] @State private var saving = false private var g: AppGraph { .shared } init(playlist: Playlist) { self.playlist = playlist _title = State(initialValue: playlist.title) } var body: some View { let list = tracks.value ?? [] Group { if editing { // Native list editing: delete buttons on the left, grips on the right. List { header(list).listRowBackground(Color.clear).listRowSeparator(.hidden) ForEach(items) { t in TrackRow(track: t) {}.listRowBackground(p.bg) } .onDelete { idx in removed += idx.map { items[$0] } items.remove(atOffsets: idx) } .onMove { items.move(fromOffsets: $0, toOffset: $1) } } .listStyle(.plain) .scrollContentBackground(.hidden) .background(p.bg) .environment(\.editMode, .constant(.active)) .navigationTitle(title) .navigationBarTitleDisplayMode(.inline) } else { Page(title: title, large: false) { header(list) Loaded(state: tracks, retry: { reload += 1 }) { list in ForEach(Array(list.enumerated()), id: \.element.id) { i, t in TrackRow(track: t, menu: ui.menu.track(t, extra: removeAction(t))) { g.playback.playTracks(list, start: i) } .listRow(inset: MARGIN + 60) } } } } } .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { if !playlist.smart && !list.isEmpty { if saving { ProgressView() } else if editing { Button("Done") { commit(list) }.bold() } else { Button("Edit") { items = list; removed = []; editing = true } } } if !editing { MoreMenu(items: moreItems(list)) } } } .task(id: reload) { await LoadState.load($tracks) { try await g.plex.playlistTracks(playlist.ratingKey) } } } private func header(_ list: [Track]) -> some View { VStack(spacing: 0) { Artwork(thumb: playlist.thumb, size: 230, corner: 10, placeholder: "music.note.list") Text(title).font(.title2.bold()).foregroundStyle(p.label).multilineTextAlignment(.center).lineLimit(2) .padding(.horizontal, 32).padding(.top, 14) if !list.isEmpty { Text("\(songs(list.count)) · \(formatMinutes(list.reduce(0) { $0 + $1.durationMs }))").font(.subheadline).foregroundStyle(p.label2) } if !editing { PlayShuffle(play: { g.playback.playTracks(list) }, shuffle: { g.playback.playTracks(list, shuffle: true) }).padding(.top, 14) } } .frame(maxWidth: .infinity) .padding(.top, 8).padding(.bottom, 16) } private func removeAction(_ t: Track) -> [SheetAction] { if playlist.smart { return [] } return [SheetAction("Remove from Playlist", icon: "minus.circle", destructive: true) { Task { if let id = t.playlistItemId { try? await g.plex.removeFromPlaylist(playlist.ratingKey, playlistItemId: id) } reload += 1 } }] } private func moreItems(_ list: [Track]) -> [SheetAction] { var extra: [SheetAction] = [] let plex = g.plex let key = playlist.ratingKey if !playlist.smart { extra.append(SheetAction("Rename…", icon: "pencil") { ui.overlays.prompt("Rename Playlist", nil, initial: title, "Save") { name in Task { if (try? await plex.renamePlaylist(key, title: name)) != nil { title = name } } } }) } extra.append(SheetAction("Delete Playlist", icon: "trash", destructive: true) { ui.overlays.confirm("Delete “\(title)”?", "This removes it from Plex for every app.", "Delete") { Task { if (try? await plex.deletePlaylist(key)) != nil { ui.pop() } } } }) var pl = playlist pl.title = title pl.trackCount = list.count return ui.menu.playlist(pl, extra: extra) } /// Sends removals, then replays the new order as "move after" calls against a simulated server list. private func commit(_ original: [Track]) { let target = items let gone = removed let plex = g.plex let key = playlist.ratingKey saving = true Task { do { for t in gone { if let id = t.playlistItemId { try await plex.removeFromPlaylist(key, playlistItemId: id) } } let goneIds = Set(gone.compactMap(\.playlistItemId)) var sim = original.filter { !goneIds.contains($0.playlistItemId ?? -1) } for (i, t) in target.enumerated() { guard let id = t.playlistItemId, i >= sim.count || sim[i].playlistItemId != id else { continue } try await plex.movePlaylistItem(key, playlistItemId: id, afterItemId: i == 0 ? nil : target[i - 1].playlistItemId) sim.removeAll { $0.playlistItemId == id } sim.insert(t, at: min(i, sim.count)) } } catch { ui.overlays.hud("Couldn't save: \(error.localizedDescription)", "exclamationmark.triangle") } reload += 1 saving = false editing = false } } } // ---- search --------------------------------------------------------------------------------- struct SearchScreen: View { @Environment(Ui.self) private var ui @State private var query = "" @State private var results: LoadState? @State private var retry = 0 private var g: AppGraph { .shared } var body: some View { let q = query.trimmingCharacters(in: .whitespaces) Page(title: "Search") { if let results { Loaded(state: results, retry: { retry += 1 }) { s in if s.isEmpty { EmptyState(icon: "magnifyingglass", title: "No Results", message: "Nothing matches “\(q)”.") } if !s.artists.isEmpty { SectionTitle(title: "Artists") ForEach(s.artists) { a in Button { ui.push(.ArtistPage(a)) } label: { ArtistRow(artist: a).contentShape(Rectangle()) } .buttonStyle(.plain).actionsMenu(ui.menu.artist(a)).listRow(inset: MARGIN + 58, trailing: 16) } } if !s.albums.isEmpty { SectionTitle(title: "Albums") AlbumGrid(albums: s.albums, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }) } if !s.tracks.isEmpty { SectionTitle(title: "Songs") ForEach(Array(s.tracks.enumerated()), id: \.element.id) { i, t in TrackRow(track: t, subtitle: "\(t.artist) — \(t.album)", menu: ui.menu.track(t)) { g.playback.playTracks(s.tracks, start: i) } .listRow(inset: MARGIN + 60) } } if !s.playlists.isEmpty { SectionTitle(title: "Playlists") ForEach(s.playlists) { pl in PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl)) } } } } else { EmptyState(icon: "magnifyingglass", title: "Search Your Library", message: "Find artists, albums, songs and playlists on your Plex server.") } } .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always), prompt: "Artists, Albums, Songs") .autocorrectionDisabled() .task(id: "\(q)|\(retry)") { guard q.count >= 2 else { results = nil; return } try? await Task.sleep(nanoseconds: 280_000_000) if Task.isCancelled { return } results = .loading do { results = .ok(try await g.plex.search(q)) } catch is CancellationError {} catch { results = .err(error.localizedDescription) } } } }