Compare commits
2
Commits
ios-v1.0.7
...
ios-v1.0.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2cf62cb07 | ||
|
|
6f634522e5 |
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,29 +2,49 @@ 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") {
|
||||||
Loaded(state: lists, retry: { reload += 1 }) { list in
|
Picker("Kind", selection: $kind) {
|
||||||
if list.isEmpty {
|
Text("Plex").tag(PlaylistKind.Plex)
|
||||||
EmptyState(icon: "music.note.list", title: "No Playlists",
|
Text("Stem").tag(PlaylistKind.Stem)
|
||||||
message: "Hold any song, album, artist or playlist and choose “Add to Playlist”, or + to make one from your queue. They sync with Plexamp.")
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.padding(.horizontal, MARGIN).padding(.bottom, 8)
|
||||||
|
if kind == .Plex {
|
||||||
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ForEach(list) { pl in
|
} else {
|
||||||
PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl))
|
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
|
||||||
|
|||||||
@@ -15,6 +15,15 @@
|
|||||||
"tintColor": "#FA2D48",
|
"tintColor": "#FA2D48",
|
||||||
"category": "entertainment",
|
"category": "entertainment",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "1.0.7",
|
||||||
|
"buildVersion": "8",
|
||||||
|
"date": "2026-10-20T09:39:03-07:00",
|
||||||
|
"localizedDescription": "- Mini player now sits in the tab bar's Liquid Glass accessory on iOS 26.1 and later, like Music. The tab bar shrinks when you scroll down and the player collapses to title and play/pause. Older iOS keeps the floating card.\n- Settings > Sound > Karaoke Soft Vocals: set how quiet Soft vocals are (-30 to -3 dB, default -12).\n\nUnsigned IPA: sideload with AltStore, SideStore or Feather.",
|
||||||
|
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.7/stemify-1.0.7.ipa",
|
||||||
|
"size": 1390623,
|
||||||
|
"minOSVersion": "17.0"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"buildVersion": "7",
|
"buildVersion": "7",
|
||||||
|
|||||||
Reference in new issue
Block a user