- 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>
130 lines
5.7 KiB
Swift
130 lines
5.7 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
/// What an export file holds: stem playlists (with each song's mix and any cover picked from
|
|
/// Photos), each song's saved mix, custom presets and the preset bar, and the non-secret sound
|
|
/// settings. Plex and stem server tokens and URLs are never included.
|
|
struct StemifyExport: Codable {
|
|
static let format = "stemify-export"
|
|
/// 2 added song mixes, presets and playlist covers. Version 1 files still import.
|
|
static let version = 2
|
|
|
|
struct Prefs: Codable {
|
|
var stemQuality: String?
|
|
var keepStemMix: Bool?
|
|
var karaokeVocals: Int?
|
|
var karaokeSoftDb: Int?
|
|
var playbackSpeed: Double?
|
|
var downloadsWifiOnly: Bool?
|
|
var presets: String?
|
|
var presetOrder: String?
|
|
var hiddenPresets: String?
|
|
var lockedPreset: String?
|
|
}
|
|
|
|
var format = StemifyExport.format
|
|
var version = StemifyExport.version
|
|
var exported = Date()
|
|
var playlists: [StemPlaylist]
|
|
var prefs: Prefs
|
|
/// Saved per-song mixes, by rating key.
|
|
var songMixes: [String: [String: StemSetting]]?
|
|
/// Covers picked from Photos ("local:<file>"), base64 by file name.
|
|
var playlistArt: [String: String]?
|
|
|
|
@MainActor
|
|
static func capture(_ lists: StemPlaylists, _ pm: PlaybackManager, _ s: Settings) -> StemifyExport {
|
|
var art: [String: String] = [:]
|
|
for pl in lists.lists {
|
|
guard let t = pl.artThumb, let u = StemPlaylists.localArt(t), let d = try? Data(contentsOf: u) else { continue }
|
|
art[u.lastPathComponent] = d.base64EncodedString()
|
|
}
|
|
return StemifyExport(playlists: lists.lists, prefs: Prefs(
|
|
stemQuality: s.stemQuality.rawValue, keepStemMix: s.keepStemMix, karaokeVocals: s.karaokeVocals,
|
|
karaokeSoftDb: s.karaokeSoftDb, playbackSpeed: s.playbackSpeed, downloadsWifiOnly: s.downloadsWifiOnly,
|
|
presets: s.presets, presetOrder: s.presetOrder, hiddenPresets: s.hiddenPresets, lockedPreset: s.lockedPreset),
|
|
songMixes: pm.songMixes, playlistArt: art)
|
|
}
|
|
|
|
func encoded() throws -> Data {
|
|
let e = JSONEncoder()
|
|
e.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
e.dateEncodingStrategy = .iso8601
|
|
return try e.encode(self)
|
|
}
|
|
|
|
static func decode(_ data: Data) throws -> StemifyExport {
|
|
let d = JSONDecoder()
|
|
d.dateDecodingStrategy = .iso8601
|
|
let x = try d.decode(StemifyExport.self, from: data)
|
|
guard x.format == Self.format else { throw BackupError.notAnExport }
|
|
guard x.version <= Self.version else { throw BackupError.newerVersion }
|
|
return x
|
|
}
|
|
|
|
/// Restores everything: playlists and song mixes from the file replace the ones with the same
|
|
/// id or song, presets merge by name, and the rest is left alone.
|
|
@MainActor
|
|
func restore(_ lists: StemPlaylists, _ pm: PlaybackManager, _ s: Settings) -> (playlists: Int, mixes: Int) {
|
|
if let art = playlistArt, !art.isEmpty {
|
|
let dir = StemPlaylists.artDir
|
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
for (name, b64) in art where !name.isEmpty && !name.contains("/") {
|
|
if let d = Data(base64Encoded: b64) { try? d.write(to: dir.appendingPathComponent(name), options: .atomic) }
|
|
}
|
|
}
|
|
let n = lists.restore(playlists)
|
|
let m = songMixes ?? [:]
|
|
pm.importSongMixes(m)
|
|
apply(to: s)
|
|
return (n, m.count)
|
|
}
|
|
|
|
@MainActor
|
|
private func apply(to s: Settings) {
|
|
if let v = prefs.stemQuality.flatMap(StemQuality.init(rawValue:)) { s.stemQuality = v }
|
|
if let v = prefs.keepStemMix { s.keepStemMix = v }
|
|
if let v = prefs.karaokeVocals { s.karaokeVocals = v }
|
|
if let v = prefs.karaokeSoftDb { s.karaokeSoftDb = v }
|
|
if let v = prefs.playbackSpeed { s.playbackSpeed = v }
|
|
if let v = prefs.downloadsWifiOnly { s.downloadsWifiOnly = v }
|
|
if let v = prefs.presets {
|
|
// Merge by name: the file's presets win, ones only on this phone stay.
|
|
let incoming = (v.data(using: .utf8)).flatMap { try? JSONDecoder().decode([CustomPreset].self, from: $0) } ?? []
|
|
let names = Set(incoming.map(\.name))
|
|
let merged = CustomPreset.load(s).filter { !names.contains($0.name) } + incoming
|
|
if let d = try? JSONEncoder().encode(merged), let str = String(data: d, encoding: .utf8) { s.presets = str }
|
|
}
|
|
if let v = prefs.presetOrder, !v.isEmpty { s.presetOrder = v }
|
|
if let v = prefs.hiddenPresets { s.hiddenPresets = v }
|
|
if let v = prefs.lockedPreset { s.lockedPreset = v }
|
|
}
|
|
}
|
|
|
|
enum BackupError: LocalizedError {
|
|
case notAnExport, newerVersion
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .notAnExport: "That file isn't a Stemify export."
|
|
case .newerVersion: "That export is from a newer version of Stemify."
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Where an export goes and comes from. The Files sheet covers local storage and iCloud Drive;
|
|
/// a direct iCloud (ubiquity container / CloudKit) backend would be another conformer.
|
|
protocol BackupDestination {
|
|
func write(_ data: Data, name: String) async throws
|
|
func read() async throws -> Data
|
|
}
|
|
|
|
/// The export as a document for fileExporter, which also reaches iCloud Drive through Files.
|
|
struct ExportDocument: FileDocument {
|
|
static var readableContentTypes: [UTType] { [.json] }
|
|
var data: Data
|
|
init(data: Data = Data()) { self.data = data }
|
|
init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() }
|
|
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: data) }
|
|
}
|