- DuoRail sits at the bottom of the strip, clear of the back buttons - Setting to turn off swipe up for Playing Next - Tighter Now Playing on iPhone Duo (folded and docked), regular phones unchanged - Bigger stacked stem faders in the Duo deck - Backup v2 restores song mixes, custom presets and stem playlist covers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
190 lines
8.6 KiB
Swift
190 lines
8.6 KiB
Swift
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:<file>") 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"
|
|
}
|
|
|
|
/// Import from a backup: the backup's copy of each list wins, lists only on this phone stay.
|
|
@discardableResult
|
|
func restore(_ incoming: [StemPlaylist]) -> Int {
|
|
for pl in incoming {
|
|
if let i = lists.firstIndex(where: { $0.id == pl.id }) { lists[i] = pl } else { lists.append(pl) }
|
|
}
|
|
if !incoming.isEmpty { save() }
|
|
return incoming.count
|
|
}
|
|
|
|
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) }
|
|
}
|
|
}
|
|
}
|