import Foundation import Observation import UIKit /// 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() /// Cover picked by the user; nil follows the first song. var artThumb: String? var tracks: [Track] { items.map(\.track) } var mixes: [[String: StemSetting]] { items.map(\.mix) } /// A cover picked from Photos ("local:") falls back to the songs' art if its file is gone /// (an imported list, say). var thumb: String? { if let a = artThumb, !a.hasPrefix(StemPlaylists.localPrefix) || StemPlaylists.localArt(a) != nil { return a } return 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. nonisolated 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 setArt(_ id: String, _ thumb: String?) { let old = list(id)?.artThumb edit(id) { $0.artThumb = thumb } if old != thumb { Self.removeLocalArt(old) } } // ---- covers from Photos ----------------------------------------------------------------- nonisolated static let localPrefix = "local:" nonisolated static var artDir: URL { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("playlist-art", isDirectory: true) } /// The file behind a "local:" thumb, if it's still there. nonisolated static func localArt(_ thumb: String) -> URL? { guard thumb.hasPrefix(localPrefix) else { return nil } let name = String(thumb.dropFirst(localPrefix.count)) guard !name.isEmpty, !name.contains("/") else { return nil } let u = artDir.appendingPathComponent(name) return FileManager.default.fileExists(atPath: u.path) ? u : nil } nonisolated private static func removeLocalArt(_ thumb: String?) { if let thumb, let u = localArt(thumb) { try? FileManager.default.removeItem(at: u) } } /// Saves [data] (any image format Photos hands over) as the list's cover, scaled to 1200 px. /// Each pick gets a new file name so cached art doesn't show the old one. @discardableResult func setPhotoArt(_ id: String, _ data: Data) -> Bool { guard let img = UIImage(data: data) else { return false } let side: CGFloat = 1200 let scale = min(1, side / max(img.size.width, img.size.height, 1)) let size = CGSize(width: (img.size.width * scale).rounded(), height: (img.size.height * scale).rounded()) let fmt = UIGraphicsImageRendererFormat.default(); fmt.scale = 1 let jpeg = UIGraphicsImageRenderer(size: size, format: fmt).jpegData(withCompressionQuality: 0.85) { _ in img.draw(in: CGRect(origin: .zero, size: size)) } let name = "\(id)-\(UUID().uuidString.prefix(8).lowercased()).jpg" do { try FileManager.default.createDirectory(at: Self.artDir, withIntermediateDirectories: true) try jpeg.write(to: Self.artDir.appendingPathComponent(name), options: .atomic) } catch { return false } setArt(id, Self.localPrefix + name) return true } /// Re-reads every song from the library so renamed, retagged or re-added songs match again. /// Keeps each item's uid and mix, and leaves songs the library can't find as they are. /// Returns true when anything changed. @discardableResult func refresh(albumTracks: (String) async throws -> [Track], search: ((String) async throws -> [Track])? = nil) async -> Bool { var fresh: [String: [Track]] = [:] for key in Set(lists.flatMap { $0.items.compactMap(\.track.albumKey) }) { if let t = try? await albumTracks(key) { fresh[key] = t } } var found: [String: [Track]] = [:] func norm(_ s: String) -> String { s.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil).trimmingCharacters(in: .whitespaces) } func pick(_ old: Track, _ pool: [Track]) -> Track? { pool.first { $0.ratingKey == old.ratingKey } ?? old.partFile.flatMap { f in pool.first { $0.partFile == f } } ?? pool.first { norm($0.title) == norm(old.title) && norm($0.artist) == norm(old.artist) && (old.album.isEmpty || norm($0.album) == norm(old.album)) } ?? pool.first { norm($0.title) == norm(old.title) && norm($0.artist) == norm(old.artist) } } var changed = false, missed = 0 for li in lists.indices { for ii in lists[li].items.indices { let old = lists[li].items[ii].track var new = old.albumKey.flatMap { fresh[$0] }.flatMap { pick(old, $0) } if new == nil, let search { if found[old.title] == nil { found[old.title] = (try? await search(old.title)) ?? [] } new = pick(old, found[old.title] ?? []) } guard var new else { missed += 1; continue } if new.stemKey == nil { new.stemKey = old.stemKey } if new != old { lists[li].items[ii].track = new; changed = true } } } print("[stemplaylists] refresh: \(fresh.count) albums, changed=\(changed), unmatched=\(missed)") if changed { save() } return changed } 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) { Self.removeLocalArt(list(id)?.artThumb) 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" } /// Brings in playlists from an export: new ids are added, ids already here are replaced when the /// import was updated more recently. Returns how many lists were added or replaced. @discardableResult func merge(_ incoming: [StemPlaylist]) -> Int { var n = 0 for pl in incoming { if let i = lists.firstIndex(where: { $0.id == pl.id }) { guard pl.updated > lists[i].updated else { continue } lists[i] = pl } else { lists.append(pl) } n += 1 } if n > 0 { save() } return n } 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) } } } }