iOS: export/import stem playlists and sound settings
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9646b4c010
commit
4e2816673d
3 files changed
+144
No files matched your search
@@ -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) }
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user