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
+374
-27
No files matched your search
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user