Files
stemify/ios/Stemify/UI/Components/MediaMenu.swift
T
cdnutterandClaude Opus 5.5 e2cf62cb07 iOS 1.0.8: stem playlists, karaoke softness slider in lyrics
- 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>
2026-10-20 10:02:42 -07:00

283 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) }
}