Files
stemify/ios/Stemify/Playback/StemPlaylists.swift
T
rootandClaude Opus 5.5 fabca5d1aa iOS: Duo layout fixes, swipe-up option, fuller backup restore
- 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>
2026-11-10 09:07:22 -08:00

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