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) } } } }