diff --git a/ios/Stemify/Core/Backup.swift b/ios/Stemify/Core/Backup.swift new file mode 100644 index 0000000..a82e056 --- /dev/null +++ b/ios/Stemify/Core/Backup.swift @@ -0,0 +1,84 @@ +import Foundation +import SwiftUI +import UniformTypeIdentifiers + +/// What an export file holds: stem playlists (with each song's mix) and the non-secret sound settings. +/// Plex and stem server tokens and URLs are never included. +struct StemifyExport: Codable { + static let format = "stemify-export" + static let version = 1 + + struct Prefs: Codable { + var stemQuality: String? + var keepStemMix: Bool? + var karaokeVocals: Int? + var karaokeSoftDb: Int? + var playbackSpeed: Double? + var downloadsWifiOnly: Bool? + } + + var format = StemifyExport.format + var version = StemifyExport.version + var exported = Date() + var playlists: [StemPlaylist] + var prefs: Prefs + + @MainActor + static func capture(_ lists: StemPlaylists, _ s: Settings) -> StemifyExport { + 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)) + } + + 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 + } + + @MainActor + 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 } + } +} + +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) } +} diff --git a/ios/Stemify/Playback/StemPlaylists.swift b/ios/Stemify/Playback/StemPlaylists.swift index a6f8cc0..70387be 100644 --- a/ios/Stemify/Playback/StemPlaylists.swift +++ b/ios/Stemify/Playback/StemPlaylists.swift @@ -70,6 +70,24 @@ final class StemPlaylists { 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]) diff --git a/ios/Stemify/UI/Screens/SettingsScreens.swift b/ios/Stemify/UI/Screens/SettingsScreens.swift index 425154d..38e24f9 100644 --- a/ios/Stemify/UI/Screens/SettingsScreens.swift +++ b/ios/Stemify/UI/Screens/SettingsScreens.swift @@ -1,4 +1,5 @@ import SwiftUI +import UniformTypeIdentifiers /// A Settings.app row that pushes [route]: icon, title, grey value, chevron. private struct NavCell: View { @@ -147,6 +148,11 @@ struct SettingsScreen: View { NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage) ToggleCell(title: "Download on Wi-Fi Only", icon: "arrow.down.circle.fill", iconBg: 0x30B0C7, isOn: $s.downloadsWifiOnly) } header: { Header(text: "Storage") } + Section { + BackupCells() + } header: { Header(text: "Backup") } footer: { + Footer(text: "Stem playlists, their mixes and sound settings. Tokens and server addresses are left out. Save to iCloud Drive from the Files sheet.") + } Section { NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance) } header: { Header(text: "Display") } footer: { @@ -159,6 +165,42 @@ struct SettingsScreen: View { } } +struct BackupCells: View { + private var g: AppGraph { .shared } + @State private var doc = ExportDocument() + @State private var exporting = false + @State private var importing = false + @State private var message: String? + @Environment(\.palette) private var p + + var body: some View { + Button { + do { doc = ExportDocument(data: try StemifyExport.capture(g.stemPlaylists, g.settings).encoded()); exporting = true } + catch { message = error.localizedDescription } + } label: { ValueCell(title: "Export", value: "\(g.stemPlaylists.lists.count) playlists", icon: "square.and.arrow.up", iconBg: 0x007AFF) } + .themedRow() + Button { importing = true } label: { ValueCell(title: "Import", value: nil, icon: "square.and.arrow.down", iconBg: 0x34C759) } + .themedRow() + .fileExporter(isPresented: $exporting, document: doc, contentType: .json, defaultFilename: "Stemify \(Date().formatted(.iso8601.year().month().day()))") { r in + if case .failure(let e) = r { message = e.localizedDescription } + } + .fileImporter(isPresented: $importing, allowedContentTypes: [.json]) { r in + do { + let url = try r.get() + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + let x = try StemifyExport.decode(Data(contentsOf: url)) + let n = g.stemPlaylists.merge(x.playlists) + x.apply(to: g.settings) + message = "Imported \(n) playlist\(n == 1 ? "" : "s") and your sound settings." + } catch { message = error.localizedDescription } + } + .alert("Backup", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) { + Button("OK", role: .cancel) {} + } message: { Text(message ?? "") } + } +} + // ---- plex ----------------------------------------------------------------------------------- struct PlexAccountScreen: View {