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>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-11-10 09:07:22 -08:00
1 parent e8bc920454
commit fabca5d1aa
9 files changed
+166 -54

No files matched your search

+52 -7
View File
@@ -2,11 +2,13 @@ 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.
/// 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"
static let version = 1
/// 2 added song mixes, presets and playlist covers. Version 1 files still import.
static let version = 2
struct Prefs: Codable {
var stemQuality: String?
@@ -15,6 +17,10 @@ struct StemifyExport: Codable {
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
@@ -22,12 +28,23 @@ struct StemifyExport: Codable {
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, _ s: Settings) -> StemifyExport {
StemifyExport(playlists: lists.lists, prefs: Prefs(
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))
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 {
@@ -46,14 +63,42 @@ struct StemifyExport: Codable {
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 apply(to s: Settings) {
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 }
}
}