import SwiftUI /// The long-press / ••• menu for anything playable: songs, albums, artists and playlists all /// offer Play Next, Add to Queue, download (whole albums, artists and playlists, like Plexamp), and add-to-playlist. /// Each call returns the actions; follow-up choices go through [Overlays]. @MainActor struct MediaMenu { let overlays: Overlays let ui: Ui private var g: AppGraph { .shared } func track(_ t: Track, extra: [SheetAction] = [], allowGoTo: Bool = true) -> [SheetAction] { var a = queueActions { [t] } if g.downloads.looseRecords.contains(where: { $0.track.ratingKey == t.ratingKey }) { 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]) }) 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") { ui.showInLibrary(.AlbumPage(Album(ratingKey: k, title: t.album, artist: t.artist, artistKey: t.artistKey, year: t.year, thumb: t.thumb))) }) } if allowGoTo, let k = t.artistKey { a.append(SheetAction("Go to Artist", icon: "music.mic") { ui.showInLibrary(.ArtistPage(Artist(ratingKey: k, title: t.artist, sortTitle: t.artist))) }) } return a + extra } func album(_ al: Album, extra: [SheetAction] = [], allowGoTo: Bool = true) -> [SheetAction] { let cov = g.coverage.albums[al.ratingKey] var more: [SheetAction] = [] if allowGoTo, let k = al.artistKey { more.append(SheetAction("Go to Artist", icon: "music.mic") { ui.showInLibrary(.ArtistPage(Artist(ratingKey: k, title: al.artist, sortTitle: al.artist))) }) } let plex = g.plex let gr = DownloadGroup(kind: .album, key: al.ratingKey, title: al.title, subtitle: al.artist, thumb: al.thumb) return collection("\(al.title) — \(al.artist)", gr, { try await plex.albumTracks(al.ratingKey) }, playlistKey: nil, extra: more + extra, stemmed: stemmed(cov)) } func artist(_ ar: Artist) -> [SheetAction] { let cov = g.coverage.artists[ar.ratingKey] let plex = g.plex let gr = DownloadGroup(kind: .artist, key: ar.ratingKey, title: ar.title, subtitle: "Artist", thumb: ar.thumb) return collection(ar.title, gr, { try await plex.artistTracks(ar.ratingKey) }, playlistKey: nil, extra: [], confirmDownload: true, stemmed: stemmed(cov)) } func playlist(_ pl: Playlist, extra: [SheetAction] = []) -> [SheetAction] { let plex = g.plex let copy = SheetAction("Copy to Stem Playlist…", icon: "doc.on.doc") { copyToStems(pl) } let gr = DownloadGroup(kind: .playlist, key: pl.ratingKey, title: pl.title, subtitle: "Playlist", thumb: pl.thumb) return collection(pl.title, gr, { try await plex.playlistTracks(pl.ratingKey) }, playlistKey: pl.ratingKey, extra: [copy] + extra, confirmDownload: pl.trackCount > 50) } func stemPlaylist(_ pl: StemPlaylist, extra: [SheetAction] = [], onPage: Bool = false) -> [SheetAction] { var a = [ SheetAction("Play Next", icon: "text.line.first.and.arrowtriangle.forward") { g.playback.playNext(pl.tracks, mixes: pl.mixes); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward") }, SheetAction("Add to Queue", icon: "text.line.last.and.arrowtriangle.forward") { g.playback.addToQueue(pl.tracks, mixes: pl.mixes); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward") }, ] a.append(downloadAction(pl.title, DownloadGroup(kind: .stemPlaylist, key: pl.id, title: pl.title, subtitle: "Stem Playlist", thumb: pl.thumb), { pl.tracks }, confirm: false)) if pl.items.isEmpty { a = [] } let lists = g.stemPlaylists a.append(SheetAction("Delete Stem Playlist", icon: "trash", destructive: true) { overlays.confirm("Delete “\(pl.title)”?", "Stem playlists are only on this device.", "Delete") { lists.delete(pl.id); if onPage { ui.pop() } } }) return extra + a } /// A song in a stem playlist: queue it with its mix, change the mix, or take it out. func stemItem(_ item: StemPlaylistItem, in listId: String) -> [SheetAction] { let lists = g.stemPlaylists return [ SheetAction("Play Next", icon: "text.line.first.and.arrowtriangle.forward") { g.playback.playNext([item.track], mixes: [item.mix]); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward") }, SheetAction("Add to Queue", icon: "text.line.last.and.arrowtriangle.forward") { g.playback.addToQueue([item.track], mixes: [item.mix]); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward") }, SheetAction("Change Mix…", icon: "slider.horizontal.3") { chooseMix([item.track]) { lists.setMix(listId, item: item.uid, $0) } }, SheetAction("Remove from Stem Playlist", icon: "minus.circle", destructive: true) { lists.remove(listId, item: item.uid) }, ] } /// For songs already in hand (downloads with no Plex album behind them). func tracks(_ title: String, _ tracks: [Track], extra: [SheetAction] = []) -> [SheetAction] { let stemmed = g.stems.index.size > 0 && tracks.allSatisfy { $0.partFile == nil || g.stems.find($0) != nil } let remove = SheetAction("Remove Download", icon: "trash", destructive: true) { overlays.confirm("Remove Download?", "“\(title)” will no longer be available offline.", "Remove") { g.downloads.remove(tracks) } } return collection(title, nil, { tracks }, playlistKey: nil, extra: [remove] + extra, stemmed: stemmed) } /// True when every song is known to have stems, so there's nothing for Stem Now to do. private func stemmed(_ cov: Coverage?) -> Bool { guard g.coverage.ready, let cov else { return false } return cov.withStems >= cov.total } private func collection(_ title: String, _ group: DownloadGroup?, _ load: @escaping () async throws -> [Track], playlistKey: String?, extra: [SheetAction], confirmDownload: Bool = false, stemmed: Bool = false) -> [SheetAction] { var a = queueActions(load) if let group { a.append(downloadAction(title, group, load, confirm: confirmDownload)) } 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 } /// Play Next (right after this song) and Add to Queue (the end of it). private func queueActions(_ load: @escaping () async throws -> [Track]) -> [SheetAction] { [ SheetAction("Play Next", icon: "text.line.first.and.arrowtriangle.forward") { withTracks(load) { g.playback.playNext($0); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward") } }, SheetAction("Add to Queue", icon: "text.line.last.and.arrowtriangle.forward") { withTracks(load) { g.playback.addToQueue($0); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward") } }, ] } /// Download, or Remove Download once [group] is downloaded. A downloaded group keeps itself in step with the server. func downloadAction(_ title: String, _ group: DownloadGroup, _ load: @escaping () async throws -> [Track], confirm: Bool) -> SheetAction { if g.downloads.groups[group.id] != nil { return SheetAction("Remove Download", icon: "trash", destructive: true) { overlays.confirm("Remove Download?", "“\(title)” will no longer be available offline.", "Remove") { g.downloads.removeGroup(group.id) } } } return SheetAction("Download", icon: "arrow.down.circle") { withTracks(load) { tracks in let go = { g.downloads.download(group, tracks); overlays.hud("Downloading", "arrow.down.circle") } if confirm && tracks.count > 20 { overlays.confirm("Download \(songs(tracks.count))?", "“\(title)” will play without your server, stems and all.", "Download", destructive: false, go) } else { go() } } } } /// Has stemd on the server separate the songs that lack stems ahead of the rest of the library. private func stemNow(_ load: @escaping () async throws -> [Track]) -> SheetAction { SheetAction("Stem Now", icon: "waveform") { Task { do { let n = try await g.stems.stemNow(try await load()) if n == 0 { overlays.hud("Already has stems", "info.circle") } else { overlays.hud(n == 1 ? "Stemming next" : "Stemming \(songs(n)) next", "waveform") } } catch { overlays.hud("Couldn't queue: \(error.localizedDescription)", "exclamationmark.triangle") } } } } private func withTracks(_ load: @escaping () async throws -> [Track], _ then: @escaping ([Track]) -> Void) { Task { do { let t = try await load() if t.isEmpty { overlays.hud("Nothing to add", "info.circle") } else { then(t) } } catch { overlays.hud("Couldn't load songs: \(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. func chooseMix(_ tracks: [Track], _ then: @escaping ([String: StemSetting]) -> Void) { let pm = g.playback var acts: [SheetAction] = [] if tracks.count == 1, let t = tracks.first { if pm.current?.track.ratingKey == t.ratingKey { let m = pm.mix acts.append(SheetAction("Current Mix (\(StemPlaylists.label(m)))", icon: "play.circle") { then(m) }) } if let m = pm.songMixes[t.ratingKey] { acts.append(SheetAction("This Song’s Mix (\(StemPlaylists.label(m)))", icon: "bookmark") { then(m) }) } } for c in CustomPreset.load() { acts.append(SheetAction(c.name, icon: "slider.horizontal.3") { then(c.mix) }) } for p in MixPresets.all { guard let m = p.build(StemPlaylists.allStems) else { continue } acts.append(SheetAction(StemPlaylists.label(m)) { then(m) }) } overlays.actions("Choose a Mix", acts) } 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 if tracks.isEmpty { onCreated(lists.create(name)) return } chooseMix(tracks) { m in onCreated(lists.create(name, tracks: tracks, mix: m)) overlays.hud("Created “\(name)”") } } } /// Makes a stem playlist from a Plex one, every song with the same mix. func copyToStems(_ pl: Playlist, onCreated: @escaping (StemPlaylist) -> Void = { _ in }) { let plex = g.plex let lists = g.stemPlaylists overlays.prompt("Copy to Stem Playlist", "Every song gets the mix you pick next; change single songs later.", initial: pl.title, "Next") { name in withTracks({ try await plex.playlistTracks(pl.ratingKey) }) { tracks in chooseMix(tracks) { m in onCreated(lists.create(name, tracks: tracks, mix: m)) overlays.hud("Copied \(songs(tracks.count))") } } } } func newPlaylist(_ tracks: [Track], onCreated: @escaping () -> Void = {}) { let plex = g.plex overlays.prompt("New Playlist", "Name it; you can add more songs later.", "Create", placeholder: "Playlist name") { name in Task { do { _ = try await plex.createPlaylist(name, trackKeys: tracks.map(\.ratingKey).filter { !$0.hasPrefix("stem:") }) overlays.hud("Created “\(name)”") onCreated() } catch { overlays.hud("Couldn't create: \(error.localizedDescription)", "exclamationmark.triangle") } } } } } extension Ui { var menu: MediaMenu { MediaMenu(overlays: overlays, ui: self) } }