- Playlists tab splits into Plex and Stem. Plex playlists are unchanged and now show their custom poster when one is set. - Stem playlists live on the device; each song keeps its own mix. Add from any song or collection menu, or copy a whole Plex playlist. - Queue items can carry a mix, which wins over the song's saved mix. - Softness slider under the Vocals picker in lyrics when set to Soft. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
283 lines
15 KiB
Swift
283 lines
15 KiB
Swift
import SwiftUI
|
||
|
||
/// The long-press / ••• menu for anything playable: songs, albums, artists and playlists all
|
||
/// offer the front or end of the queue, download, and add-to-playlist (playlists included).
|
||
/// 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.records[t.ratingKey] == nil {
|
||
a.append(SheetAction("Download", icon: "arrow.down.circle") { g.downloads.download([t]); overlays.hud("Downloading", "arrow.down.circle") })
|
||
} else {
|
||
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") {
|
||
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 have = g.downloads.records.values.filter { $0.done && $0.track.albumKey == al.ratingKey }.map(\.track)
|
||
let cov = g.coverage.albums[al.ratingKey]
|
||
let total = cov?.total ?? al.trackCount ?? Int.max
|
||
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
|
||
return collection("\(al.title) — \(al.artist)", have, have.count >= total, { try await plex.albumTracks(al.ratingKey) },
|
||
playlistKey: nil, extra: more + extra, stemmed: stemmed(cov))
|
||
}
|
||
|
||
func artist(_ ar: Artist) -> [SheetAction] {
|
||
let have = g.downloads.records.values.filter { $0.done && $0.track.artistKey == ar.ratingKey }.map(\.track)
|
||
let cov = g.coverage.artists[ar.ratingKey]
|
||
let plex = g.plex
|
||
return collection(ar.title, have, have.count >= (cov?.total ?? Int.max), { 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) }
|
||
return collection(pl.title, [], false, { 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("Add to Front of Queue", 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 End of 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")
|
||
},
|
||
]
|
||
let missing = pl.tracks.filter { !g.downloads.isDownloaded($0.ratingKey) }
|
||
if !missing.isEmpty {
|
||
a.append(SheetAction("Download", icon: "arrow.down.circle") { g.downloads.download(missing); overlays.hud("Downloading", "arrow.down.circle") })
|
||
}
|
||
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("Add to Front of Queue", 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 End of 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 have = tracks.filter { g.downloads.isDownloaded($0.ratingKey) }
|
||
let stemmed = g.stems.index.size > 0 && tracks.allSatisfy { $0.partFile == nil || g.stems.find($0) != nil }
|
||
return collection(title, have, have.count == tracks.count, { tracks }, playlistKey: nil, extra: 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, _ downloaded: [Track], _ allDownloaded: Bool, _ load: @escaping () async throws -> [Track],
|
||
playlistKey: String?, extra: [SheetAction], confirmDownload: Bool = false, stemmed: Bool = false) -> [SheetAction] {
|
||
var a = queueActions(load)
|
||
if !allDownloaded {
|
||
a.append(SheetAction(downloaded.isEmpty ? "Download" : "Download the Rest", icon: "arrow.down.circle") {
|
||
withTracks(load) { tracks in
|
||
let go = { g.downloads.download(tracks); overlays.hud("Downloading", "arrow.down.circle") }
|
||
if confirmDownload && tracks.count > 20 {
|
||
overlays.confirm("Download \(songs(tracks.count))?", "“\(title)” will play without your server, stems and all.", "Download", destructive: false, go)
|
||
} else {
|
||
go()
|
||
}
|
||
}
|
||
})
|
||
}
|
||
if !downloaded.isEmpty {
|
||
a.append(SheetAction("Remove Download", icon: "trash", destructive: true) {
|
||
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) } })
|
||
if !stemmed { a.append(stemNow(load)) }
|
||
return a + extra
|
||
}
|
||
|
||
/// Front of the queue (right after this song) and the end of it.
|
||
private func queueActions(_ load: @escaping () async throws -> [Track]) -> [SheetAction] {
|
||
[
|
||
SheetAction("Add to Front of Queue", 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 End of 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") }
|
||
},
|
||
]
|
||
}
|
||
|
||
/// 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")
|
||
}
|
||
}
|
||
}
|
||
|
||
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")
|
||
}
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
/// 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 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 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
|
||
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) }
|
||
}
|