You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
93 lines
3.2 KiB
93 lines
3.2 KiB
import Foundation
|
|
import MusubiCore
|
|
import Observation
|
|
|
|
/// What is running right now, saved continuously so a restart (or power loss) brings it back.
|
|
struct LiveState: Codable, Equatable {
|
|
var local = MachineConfig.empty
|
|
/// Control only: what each node should show.
|
|
var nodes: [String: MachineConfig] = [:]
|
|
var sceneID: UUID?
|
|
var sceneName: String?
|
|
/// The live state differs from the saved scene.
|
|
var modified = false
|
|
}
|
|
|
|
/// Saved scenes and the live state in ~/Library/Application Support/Musubi.
|
|
@MainActor @Observable
|
|
final class SceneStore {
|
|
private(set) var scenes: [Scene] = []
|
|
private let directory: URL
|
|
private var saveTask: Task<Void, Never>?
|
|
|
|
init() {
|
|
directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
.appending(path: "Musubi", directoryHint: .isDirectory)
|
|
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
scenes = (try? load([Scene].self, from: "scenes.json")) ?? []
|
|
}
|
|
|
|
// MARK: Live state
|
|
|
|
func loadLiveState() -> LiveState {
|
|
(try? load(LiveState.self, from: "live-state.json")) ?? LiveState()
|
|
}
|
|
|
|
/// Debounced: many small changes (gain slider) end up as one write.
|
|
func saveLiveState(_ state: LiveState) {
|
|
saveTask?.cancel()
|
|
saveTask = Task { [weak self] in
|
|
try? await Task.sleep(for: .milliseconds(500))
|
|
guard !Task.isCancelled else { return }
|
|
self?.write(state, to: "live-state.json")
|
|
}
|
|
}
|
|
|
|
// MARK: Scenes
|
|
|
|
func save(_ scene: Scene) {
|
|
if let index = scenes.firstIndex(where: { $0.id == scene.id }) {
|
|
scenes[index] = scene
|
|
} else {
|
|
scenes.append(scene)
|
|
scenes.sort { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
|
|
}
|
|
write(scenes, to: "scenes.json")
|
|
}
|
|
|
|
func delete(_ id: UUID) {
|
|
scenes.removeAll { $0.id == id }
|
|
write(scenes, to: "scenes.json")
|
|
}
|
|
|
|
func scene(_ id: UUID?) -> Scene? {
|
|
scenes.first { $0.id == id }
|
|
}
|
|
|
|
/// Imports a scene file; a scene with the same name is replaced.
|
|
func importScene(from url: URL) throws -> Scene {
|
|
let accessing = url.startAccessingSecurityScopedResource()
|
|
defer { if accessing { url.stopAccessingSecurityScopedResource() } }
|
|
var scene = try JSONDecoder().decode(Scene.self, from: Data(contentsOf: url))
|
|
if let existing = scenes.first(where: { $0.name == scene.name }) { scene.id = existing.id }
|
|
save(scene)
|
|
return scene
|
|
}
|
|
|
|
// MARK: Files
|
|
|
|
private func load<T: Decodable>(_ type: T.Type, from name: String) throws -> T {
|
|
try JSONDecoder().decode(type, from: Data(contentsOf: directory.appending(path: name)))
|
|
}
|
|
|
|
private func write<T: Encodable>(_ value: T, to name: String) {
|
|
do {
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
try encoder.encode(value).write(to: directory.appending(path: name), options: .atomic)
|
|
} catch {
|
|
Log.ndi.error("Saving \(name, privacy: .public) failed: \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
}
|