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>
This commit is contained in:
1 parent
6f634522e5
commit
e2cf62cb07
10 files changed
+368
-21
No files matched your search
@@ -20,6 +20,7 @@ final class AppGraph {
|
|||||||
let cache: MediaCache
|
let cache: MediaCache
|
||||||
let eq: EqController
|
let eq: EqController
|
||||||
let playback: PlaybackManager
|
let playback: PlaybackManager
|
||||||
|
let stemPlaylists: StemPlaylists
|
||||||
let downloads: Downloads
|
let downloads: Downloads
|
||||||
let coverage: StemCoverage
|
let coverage: StemCoverage
|
||||||
let nowPlaying: NowPlaying
|
let nowPlaying: NowPlaying
|
||||||
@@ -59,6 +60,7 @@ final class AppGraph {
|
|||||||
cache = MediaCache(dir: media, session: mediaSession) { Int64(settings.cacheLimitGb) << 30 }
|
cache = MediaCache(dir: media, session: mediaSession) { Int64(settings.cacheLimitGb) << 30 }
|
||||||
|
|
||||||
eq = EqController(dir: support)
|
eq = EqController(dir: support)
|
||||||
|
stemPlaylists = StemPlaylists(dir: support)
|
||||||
playback = PlaybackManager(settings: settings, plex: plex, stems: stems, cache: cache, eq: eq, dir: support)
|
playback = PlaybackManager(settings: settings, plex: plex, stems: stems, cache: cache, eq: eq, dir: support)
|
||||||
downloads = Downloads(settings: settings, plex: plex, stems: stems, cache: cache, net: net, dir: support)
|
downloads = Downloads(settings: settings, plex: plex, stems: stems, cache: cache, net: net, dir: support)
|
||||||
coverage = StemCoverage(plex: plex, stems: stems, settings: settings, dir: support)
|
coverage = StemCoverage(plex: plex, stems: stems, settings: settings, dir: support)
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ enum SourceKind { case None, Stems, Original }
|
|||||||
struct QueueItem: Codable, Hashable, Identifiable {
|
struct QueueItem: Codable, Hashable, Identifiable {
|
||||||
var uid: Int64
|
var uid: Int64
|
||||||
var track: Track
|
var track: Track
|
||||||
|
/// Set when the song came from a stem playlist; it plays with this mix instead of its saved one.
|
||||||
|
var mix: [String: StemSetting]?
|
||||||
var id: Int64 { uid }
|
var id: Int64 { uid }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,16 +206,17 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
|
|
||||||
// ---- queue --------------------------------------------------------------------------
|
// ---- queue --------------------------------------------------------------------------
|
||||||
|
|
||||||
private func items(_ tracks: [Track]) -> [QueueItem] {
|
private func items(_ tracks: [Track], _ mixes: [[String: StemSetting]]? = nil) -> [QueueItem] {
|
||||||
tracks.map { t in
|
tracks.enumerated().map { i, t in
|
||||||
defer { nextUid += 1 }
|
defer { nextUid += 1 }
|
||||||
return QueueItem(uid: nextUid, track: t)
|
return QueueItem(uid: nextUid, track: t, mix: mixes.flatMap { i < $0.count ? $0[i] : nil })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func playTracks(_ tracks: [Track], start: Int = 0, shuffle doShuffle: Bool = false) {
|
/// `mixes` lines up with `tracks`: each song plays with its own mix (stem playlists).
|
||||||
|
func playTracks(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil, start: Int = 0, shuffle doShuffle: Bool = false) {
|
||||||
if tracks.isEmpty { return }
|
if tracks.isEmpty { return }
|
||||||
let items = items(tracks)
|
let items = items(tracks, mixes)
|
||||||
if doShuffle {
|
if doShuffle {
|
||||||
unshuffled = items
|
unshuffled = items
|
||||||
let first = start > 0 && start < items.count ? items[start] : items.randomElement()!
|
let first = start > 0 && start < items.count ? items[start] : items.randomElement()!
|
||||||
@@ -228,12 +231,12 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func playNext(_ tracks: [Track]) { insert(index + 1, tracks) }
|
func playNext(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil) { insert(index + 1, tracks, mixes) }
|
||||||
func addToQueue(_ tracks: [Track]) { insert(queue.count, tracks) }
|
func addToQueue(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil) { insert(queue.count, tracks, mixes) }
|
||||||
|
|
||||||
private func insert(_ at: Int, _ tracks: [Track]) {
|
private func insert(_ at: Int, _ tracks: [Track], _ mixes: [[String: StemSetting]]? = nil) {
|
||||||
if tracks.isEmpty { return }
|
if tracks.isEmpty { return }
|
||||||
let items = items(tracks)
|
let items = items(tracks, mixes)
|
||||||
var q = queue
|
var q = queue
|
||||||
q.insert(contentsOf: items, at: min(max(at, 0), q.count))
|
q.insert(contentsOf: items, at: min(max(at, 0), q.count))
|
||||||
queue = q
|
queue = q
|
||||||
@@ -415,10 +418,11 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
pushMix()
|
pushMix()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The mix a song starts with: karaoke if that's on, else the song's saved mix, else cleared (or kept, per settings).
|
/// The mix a song starts with: karaoke if that's on, else its stem playlist mix, else the song's saved mix,
|
||||||
private func startMix(_ t: Track) -> [String: StemSetting] {
|
/// else cleared (or kept, per settings).
|
||||||
|
private func startMix(_ item: QueueItem) -> [String: StemSetting] {
|
||||||
if karaoke { return karaokeMix }
|
if karaoke { return karaokeMix }
|
||||||
return songMixes[t.ratingKey] ?? (settings.keepStemMix ? mix : [:])
|
return item.mix ?? songMixes[item.track.ratingKey] ?? (settings.keepStemMix ? mix : [:])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How much of the singer is left in karaoke mode. Soft is a guide under your own voice, as quiet as settings say.
|
/// How much of the singer is left in karaoke mode. Soft is a guide under your own voice, as quiet as settings say.
|
||||||
@@ -527,7 +531,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
if play && !activateSession() { return }
|
if play && !activateSession() { return }
|
||||||
playWhenReady = play
|
playWhenReady = play
|
||||||
buffering = true
|
buffering = true
|
||||||
mix = startMix(item.track)
|
mix = startMix(item)
|
||||||
changed()
|
changed()
|
||||||
save()
|
save()
|
||||||
loadTask = Task { @MainActor [weak self] in
|
loadTask = Task { @MainActor [weak self] in
|
||||||
@@ -700,7 +704,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
guard let item = current else { return }
|
guard let item = current else { return }
|
||||||
durationMs = item.track.durationMs
|
durationMs = item.track.durationMs
|
||||||
waveform = nil
|
waveform = nil
|
||||||
mix = startMix(item.track)
|
mix = startMix(item)
|
||||||
source = currentEntry != nil ? .Stems : .Original
|
source = currentEntry != nil ? .Stems : .Original
|
||||||
stemNames = Stems.sort(currentEntry?.stems.map(\.name) ?? ["mix"])
|
stemNames = Stems.sort(currentEntry?.stems.map(\.name) ?? ["mix"])
|
||||||
pushMix()
|
pushMix()
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// One song in a stem playlist, saved with the mix it plays with.
|
||||||
|
struct StemPlaylistItem: Codable, Hashable, Identifiable {
|
||||||
|
var uid = UUID().uuidString
|
||||||
|
var track: Track
|
||||||
|
var mix: [String: StemSetting]
|
||||||
|
var id: String { uid }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StemPlaylist: Codable, Hashable, Identifiable {
|
||||||
|
var id = UUID().uuidString
|
||||||
|
var title: String
|
||||||
|
var items: [StemPlaylistItem] = []
|
||||||
|
var created = Date()
|
||||||
|
var updated = Date()
|
||||||
|
|
||||||
|
var tracks: [Track] { items.map(\.track) }
|
||||||
|
var mixes: [[String: StemSetting]] { items.map(\.mix) }
|
||||||
|
var thumb: String? { items.lazy.compactMap(\.track.thumb).first }
|
||||||
|
var durationMs: Int64 { items.reduce(0) { $0 + $1.track.durationMs } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Playlists that live on this device only: each song keeps its own stem mix (instrumentals, a cappellas…).
|
||||||
|
/// Plex playlists can't hold a mix, so these are separate.
|
||||||
|
@MainActor @Observable
|
||||||
|
final class StemPlaylists {
|
||||||
|
private(set) var lists: [StemPlaylist] = []
|
||||||
|
@ObservationIgnored private let file: URL
|
||||||
|
|
||||||
|
/// Every stem a song can have, for naming saved mixes without loading the song.
|
||||||
|
static let allStems = ["vocals", "drums", "bass", "guitar", "piano", "other"]
|
||||||
|
|
||||||
|
init(dir: URL) {
|
||||||
|
file = dir.appendingPathComponent("stem-playlists.json")
|
||||||
|
lists = (try? JSONDecoder().decode([StemPlaylist].self, from: Data(contentsOf: file))) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func list(_ id: String) -> StemPlaylist? { lists.first { $0.id == id } }
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func create(_ title: String, tracks: [Track] = [], mix: [String: StemSetting] = [:]) -> StemPlaylist {
|
||||||
|
let pl = StemPlaylist(title: title, items: tracks.map { StemPlaylistItem(track: $0, mix: MixPresets.normalized(mix)) })
|
||||||
|
lists.insert(pl, at: 0)
|
||||||
|
save()
|
||||||
|
return pl
|
||||||
|
}
|
||||||
|
|
||||||
|
func add(_ id: String, tracks: [Track], mix: [String: StemSetting]) {
|
||||||
|
edit(id) { $0.items += tracks.map { StemPlaylistItem(track: $0, mix: MixPresets.normalized(mix)) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
func setItems(_ id: String, _ items: [StemPlaylistItem]) { edit(id) { $0.items = items } }
|
||||||
|
func remove(_ id: String, item: String) { edit(id) { $0.items.removeAll { $0.uid == item } } }
|
||||||
|
func setMix(_ id: String, item: String, _ mix: [String: StemSetting]) {
|
||||||
|
edit(id) { pl in if let i = pl.items.firstIndex(where: { $0.uid == item }) { pl.items[i].mix = MixPresets.normalized(mix) } }
|
||||||
|
}
|
||||||
|
func rename(_ id: String, _ title: String) { edit(id) { $0.title = title } }
|
||||||
|
|
||||||
|
func delete(_ id: String) {
|
||||||
|
lists.removeAll { $0.id == id }
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Instrumental", "A Cappella"… or "Custom Mix" for a hand-made one.
|
||||||
|
static func label(_ mix: [String: StemSetting]) -> String {
|
||||||
|
if MixPresets.normalized(mix).isEmpty { return MixPresets.full.label }
|
||||||
|
if MixPresets.current(allStems, mix) == MixPresets.karaoke { return "Instrumental" }
|
||||||
|
return MixPresets.current(allStems, mix)?.label ?? "Custom Mix"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func edit(_ id: String, _ change: (inout StemPlaylist) -> Void) {
|
||||||
|
guard let i = lists.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
change(&lists[i])
|
||||||
|
lists[i].updated = Date()
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
let all = lists, file = file
|
||||||
|
DispatchQueue.global(qos: .utility).async {
|
||||||
|
if let data = try? JSONEncoder().encode(all) { try? data.write(to: file, options: .atomic) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -405,7 +405,7 @@ extension PlexItem {
|
|||||||
func toArtist() -> Artist { Artist(ratingKey: ratingKey, title: title, sortTitle: titleSort ?? title, thumb: thumb) }
|
func toArtist() -> Artist { Artist(ratingKey: ratingKey, title: title, sortTitle: titleSort ?? title, thumb: thumb) }
|
||||||
|
|
||||||
func toPlaylist() -> Playlist {
|
func toPlaylist() -> Playlist {
|
||||||
Playlist(ratingKey: ratingKey, title: title, thumb: composite ?? thumb, trackCount: leafCount ?? 0,
|
Playlist(ratingKey: ratingKey, title: title, thumb: thumb ?? composite, trackCount: leafCount ?? 0,
|
||||||
durationMs: duration ?? 0, smart: smart ?? false)
|
durationMs: duration ?? 0, smart: smart ?? false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ struct MediaMenu {
|
|||||||
a.append(SheetAction("Remove Download", icon: "trash", destructive: true) { g.downloads.remove([t]) })
|
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 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 t.partFile != nil, g.stems.index.size > 0, g.stems.find(t) == nil { a.append(stemNow { [t] }) }
|
||||||
if allowGoTo, let k = t.albumKey {
|
if allowGoTo, let k = t.albumKey {
|
||||||
a.append(SheetAction("Go to Album", icon: "square.stack") {
|
a.append(SheetAction("Go to Album", icon: "square.stack") {
|
||||||
@@ -52,10 +53,49 @@ struct MediaMenu {
|
|||||||
|
|
||||||
func playlist(_ pl: Playlist, extra: [SheetAction] = []) -> [SheetAction] {
|
func playlist(_ pl: Playlist, extra: [SheetAction] = []) -> [SheetAction] {
|
||||||
let plex = g.plex
|
let plex = g.plex
|
||||||
return collection(pl.title, [], false, { try await plex.playlistTracks(pl.ratingKey) }, playlistKey: pl.ratingKey, extra: extra,
|
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)
|
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).
|
/// For songs already in hand (downloads with no Plex album behind them).
|
||||||
func tracks(_ title: String, _ tracks: [Track], extra: [SheetAction] = []) -> [SheetAction] {
|
func tracks(_ title: String, _ tracks: [Track], extra: [SheetAction] = []) -> [SheetAction] {
|
||||||
let have = tracks.filter { g.downloads.isDownloaded($0.ratingKey) }
|
let have = tracks.filter { g.downloads.isDownloaded($0.ratingKey) }
|
||||||
@@ -90,6 +130,7 @@ struct MediaMenu {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { withTracks(load) { addToPlaylist($0, skip: playlistKey) } })
|
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)) }
|
if !stemmed { a.append(stemNow(load)) }
|
||||||
return a + extra
|
return a + extra
|
||||||
}
|
}
|
||||||
@@ -160,6 +201,66 @@ struct MediaMenu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 = {}) {
|
func newPlaylist(_ tracks: [Track], onCreated: @escaping () -> Void = {}) {
|
||||||
let plex = g.plex
|
let plex = g.plex
|
||||||
overlays.prompt("New Playlist", "Name it; you can add more songs later.", "Create", placeholder: "Playlist name") { name in
|
overlays.prompt("New Playlist", "Name it; you can add more songs later.", "Create", placeholder: "Playlist name") { name in
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ enum Route: Hashable {
|
|||||||
case Artists, Albums, Songs, Playlists
|
case Artists, Albums, Songs, Playlists
|
||||||
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
|
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
|
||||||
case PlexAccount, StemServer, Quality, Storage, Appearance
|
case PlexAccount, StemServer, Quality, Storage, Appearance
|
||||||
case ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist)
|
case ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tabs, their stacks and the Now Playing sheet: the things any screen can ask of the shell.
|
/// Tabs, their stacks and the Now Playing sheet: the things any screen can ask of the shell.
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ struct RootView: View {
|
|||||||
case .ArtistPage(let a): ArtistScreen(artist: a)
|
case .ArtistPage(let a): ArtistScreen(artist: a)
|
||||||
case .AlbumPage(let a): AlbumScreen(album: a)
|
case .AlbumPage(let a): AlbumScreen(album: a)
|
||||||
case .PlaylistPage(let pl): PlaylistScreen(playlist: pl)
|
case .PlaylistPage(let pl): PlaylistScreen(playlist: pl)
|
||||||
|
case .StemPlaylistPage(let id): StemPlaylistScreen(id: id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -912,6 +912,15 @@ private struct LyricsPanel: View {
|
|||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 28).padding(.top, 8)
|
.padding(.horizontal, 28).padding(.top, 8)
|
||||||
|
if g.playback.karaokeVocals == .Soft {
|
||||||
|
let db = g.settings.karaokeSoftDb
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text("Softness").font(.subheadline.weight(.semibold)).foregroundStyle(p.label2)
|
||||||
|
Slider(value: Binding(get: { Double(db) }, set: { g.playback.setKaraokeSoftDb(Int($0.rounded())) }), in: -30...(-3), step: 1)
|
||||||
|
Text("\(db) dB").font(.caption).monospacedDigit().foregroundStyle(p.label2).frame(width: 46, alignment: .trailing)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 28).padding(.top, 8)
|
||||||
|
}
|
||||||
content
|
content
|
||||||
}
|
}
|
||||||
.task(id: track.ratingKey) { await follow() }
|
.task(id: track.ratingKey) { await follow() }
|
||||||
|
|||||||
@@ -2,15 +2,25 @@ import SwiftUI
|
|||||||
|
|
||||||
// ---- playlists ------------------------------------------------------------------------------
|
// ---- playlists ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum PlaylistKind: String, CaseIterable { case Plex, Stem }
|
||||||
|
|
||||||
struct PlaylistsScreen: View {
|
struct PlaylistsScreen: View {
|
||||||
@Environment(Ui.self) private var ui
|
@Environment(Ui.self) private var ui
|
||||||
@State private var lists: LoadState<[Playlist]> = .loading
|
@State private var lists: LoadState<[Playlist]> = .loading
|
||||||
@State private var reload = 0
|
@State private var reload = 0
|
||||||
|
@AppStorage("playlistKind") private var kind = PlaylistKind.Plex
|
||||||
private let sort = SortState("playlists", PLAYLIST_SORTS)
|
private let sort = SortState("playlists", PLAYLIST_SORTS)
|
||||||
private var g: AppGraph { .shared }
|
private var g: AppGraph { .shared }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Page(title: "Playlists") {
|
Page(title: "Playlists") {
|
||||||
|
Picker("Kind", selection: $kind) {
|
||||||
|
Text("Plex").tag(PlaylistKind.Plex)
|
||||||
|
Text("Stem").tag(PlaylistKind.Stem)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.padding(.horizontal, MARGIN).padding(.bottom, 8)
|
||||||
|
if kind == .Plex {
|
||||||
Loaded(state: lists, retry: { reload += 1 }) { list in
|
Loaded(state: lists, retry: { reload += 1 }) { list in
|
||||||
if list.isEmpty {
|
if list.isEmpty {
|
||||||
EmptyState(icon: "music.note.list", title: "No Playlists",
|
EmptyState(icon: "music.note.list", title: "No Playlists",
|
||||||
@@ -20,11 +30,21 @@ struct PlaylistsScreen: View {
|
|||||||
PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl))
|
PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
let stems = g.stemPlaylists.lists
|
||||||
|
if stems.isEmpty {
|
||||||
|
EmptyState(icon: "slider.horizontal.3", title: "No Stem Playlists",
|
||||||
|
message: "Each song plays with its own mix, like an instrumental or a cappella. Hold any song and choose “Add to Stem Playlist”, or copy a Plex playlist with +.")
|
||||||
|
}
|
||||||
|
ForEach(stems) { pl in
|
||||||
|
StemPlaylistRow(playlist: pl).onTapGesture { ui.push(.StemPlaylistPage(pl.id)) }.actionsMenu(ui.menu.stemPlaylist(pl))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) }
|
if kind == .Plex { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button { newPlaylist() } label: { Image(systemName: "plus") }
|
Button { kind == .Plex ? newPlaylist() : newStemPlaylist() } label: { Image(systemName: "plus") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task(id: "\(g.settings.musicSection)|\(sort.param)|\(reload)") {
|
.task(id: "\(g.settings.musicSection)|\(sort.param)|\(reload)") {
|
||||||
@@ -45,6 +65,130 @@ struct PlaylistsScreen: View {
|
|||||||
if acts.isEmpty { ui.overlays.hud("Play something first, or use “Add to Playlist” on any song.", "info.circle") }
|
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) }
|
else { ui.overlays.actions("New Playlist", acts) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func newStemPlaylist() {
|
||||||
|
let menu = ui.menu
|
||||||
|
let open: (StemPlaylist) -> Void = { ui.push(.StemPlaylistPage($0.id)) }
|
||||||
|
ui.overlays.actions("New Stem Playlist", [
|
||||||
|
SheetAction("Empty Stem Playlist…", icon: "plus") { menu.newStemPlaylist(onCreated: open) },
|
||||||
|
SheetAction("Copy from Plex Playlist…", icon: "doc.on.doc") {
|
||||||
|
Task {
|
||||||
|
guard let plex = try? await g.plex.playlists() else {
|
||||||
|
ui.overlays.hud("Couldn't load playlists", "exclamationmark.triangle")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if plex.isEmpty { ui.overlays.hud("No Plex playlists to copy", "info.circle"); return }
|
||||||
|
ui.overlays.actions("Copy from Plex", plex.map { pl in SheetAction(pl.title) { menu.copyToStems(pl, onCreated: open) } })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StemPlaylistRow: View {
|
||||||
|
let playlist: StemPlaylist
|
||||||
|
@Environment(\.palette) private var p
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Artwork(thumb: playlist.thumb, size: 60, corner: 6, placeholder: "slider.horizontal.3")
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(playlist.title).foregroundStyle(p.label).lineLimit(1)
|
||||||
|
Text(songs(playlist.items.count)).font(.subheadline).foregroundStyle(p.label2)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Tag(text: "STEMS")
|
||||||
|
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3)
|
||||||
|
}
|
||||||
|
.frame(height: 76)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.listRow(inset: 94, trailing: 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stem playlist: lives on this device, and every song plays with its saved mix.
|
||||||
|
struct StemPlaylistScreen: View {
|
||||||
|
let id: String
|
||||||
|
@Environment(Ui.self) private var ui
|
||||||
|
@Environment(\.palette) private var p
|
||||||
|
@State private var editing = false
|
||||||
|
@State private var items: [StemPlaylistItem] = []
|
||||||
|
private var g: AppGraph { .shared }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let pl = g.stemPlaylists.list(id) {
|
||||||
|
page(pl)
|
||||||
|
} else {
|
||||||
|
EmptyState(icon: "slider.horizontal.3", title: "Playlist Gone", message: "This stem playlist was deleted.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func page(_ pl: StemPlaylist) -> some View {
|
||||||
|
Group {
|
||||||
|
if editing {
|
||||||
|
List {
|
||||||
|
header(pl).listRowBackground(Color.clear).listRowSeparator(.hidden)
|
||||||
|
ForEach(items) { it in
|
||||||
|
TrackRow(track: it.track, subtitle: StemPlaylists.label(it.mix)) {}.listRowBackground(p.bg)
|
||||||
|
}
|
||||||
|
.onDelete { items.remove(atOffsets: $0) }
|
||||||
|
.onMove { items.move(fromOffsets: $0, toOffset: $1) }
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(p.bg)
|
||||||
|
.environment(\.editMode, .constant(.active))
|
||||||
|
.navigationTitle(pl.title)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
} else {
|
||||||
|
Page(title: pl.title, large: false) {
|
||||||
|
header(pl)
|
||||||
|
if pl.items.isEmpty {
|
||||||
|
EmptyState(icon: "slider.horizontal.3", title: "No Songs",
|
||||||
|
message: "Hold any song, album or playlist and choose “Add to Stem Playlist”.")
|
||||||
|
}
|
||||||
|
ForEach(Array(pl.items.enumerated()), id: \.element.id) { i, it in
|
||||||
|
TrackRow(track: it.track, subtitle: "\(it.track.artist) · \(StemPlaylists.label(it.mix))", menu: ui.menu.stemItem(it, in: pl.id)) {
|
||||||
|
g.playback.playTracks(pl.tracks, mixes: pl.mixes, start: i)
|
||||||
|
}
|
||||||
|
.listRow(inset: MARGIN + 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||||
|
if editing { Button("Done") { g.stemPlaylists.setItems(pl.id, items); editing = false }.bold() }
|
||||||
|
else if !pl.items.isEmpty { Button("Edit") { items = pl.items; editing = true } }
|
||||||
|
if !editing { MoreMenu(items: moreItems(pl)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func header(_ pl: StemPlaylist) -> some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Artwork(thumb: pl.thumb, size: 230, corner: 10, placeholder: "slider.horizontal.3")
|
||||||
|
Text(pl.title).font(.title2.bold()).foregroundStyle(p.label).multilineTextAlignment(.center).lineLimit(2)
|
||||||
|
.padding(.horizontal, 32).padding(.top, 14)
|
||||||
|
Text(pl.items.isEmpty ? "Stem Playlist" : "\(songs(pl.items.count)) · \(formatMinutes(pl.durationMs))").font(.subheadline).foregroundStyle(p.label2)
|
||||||
|
if !editing && !pl.items.isEmpty {
|
||||||
|
PlayShuffle(play: { g.playback.playTracks(pl.tracks, mixes: pl.mixes) },
|
||||||
|
shuffle: { g.playback.playTracks(pl.tracks, mixes: pl.mixes, shuffle: true) }).padding(.top, 14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 8).padding(.bottom, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func moreItems(_ pl: StemPlaylist) -> [SheetAction] {
|
||||||
|
let lists = g.stemPlaylists
|
||||||
|
let rename = SheetAction("Rename…", icon: "pencil") {
|
||||||
|
ui.overlays.prompt("Rename Stem Playlist", nil, initial: pl.title, "Save") { lists.rename(pl.id, $0) }
|
||||||
|
}
|
||||||
|
let mixAll = SheetAction("Set Mix for All Songs…", icon: "slider.horizontal.3") {
|
||||||
|
ui.menu.chooseMix(pl.tracks) { m in lists.setItems(pl.id, pl.items.map { var it = $0; it.mix = MixPresets.normalized(m); return it }) }
|
||||||
|
}
|
||||||
|
return ui.menu.stemPlaylist(pl, extra: pl.items.isEmpty ? [rename] : [rename, mixAll], onPage: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PlaylistRow: View {
|
struct PlaylistRow: View {
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,8 @@ options:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
MARKETING_VERSION: "1.0.7"
|
MARKETING_VERSION: "1.0.8"
|
||||||
CURRENT_PROJECT_VERSION: "8"
|
CURRENT_PROJECT_VERSION: "9"
|
||||||
targets:
|
targets:
|
||||||
Stemify:
|
Stemify:
|
||||||
type: application
|
type: application
|
||||||
|
|||||||
Reference in new issue
Block a user