Author SHA1 Message Date
cdnutterandClaude Sonnet 5.5 d773b3e5a5 iOS: bump to 1.0.13
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 13:40:13 -07:00
cdnutterandClaude Opus 5.5 9c1e70da4d iOS: one-line mixer faders so every stem fits; wide now-playing layout and landscape for iPhone Duo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:37:38 -07:00
cdnutterandClaude Sonnet 5.5 4e2816673d iOS: export/import stem playlists and sound settings
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 13:33:54 -07:00
cdnutterandClaude Sonnet 5.5 9646b4c010 source.json: add 1.0.12
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 13:29:08 -07:00
6 changed files with 240 additions and 50 deletions

No files matched your search

+84
View File
@@ -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) }
}
+18
View File
@@ -70,6 +70,24 @@ final class StemPlaylists {
return MixPresets.current(allStems, mix)?.label ?? "Custom Mix" 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) { private func edit(_ id: String, _ change: (inout StemPlaylist) -> Void) {
guard let i = lists.firstIndex(where: { $0.id == id }) else { return } guard let i = lists.firstIndex(where: { $0.id == id }) else { return }
change(&lists[i]) change(&lists[i])
+84 -47
View File
@@ -98,21 +98,32 @@ struct NowPlayingScreen: View {
let pm = g.playback let pm = g.playback
let c = tint.map { base.tinted($0) } ?? base let c = tint.map { base.tinted($0) } ?? base
let track = pm.current?.track let track = pm.current?.track
VStack(spacing: 0) { // Wide when there's no room to stack art over controls: landscape, or iPhone Duo's
// outer display. Tall layouts (including the unfolded Duo) cap the controls' width.
GeometryReader { geo in
let wide = geo.size.width > geo.size.height * 0.9
if let track { if let track {
Group { if wide {
switch panel { HStack(spacing: 0) {
case .Art: ArtPanel(thumb: track.thumb, playing: pm.playWhenReady) panelView(track, header: false)
case .Mixer: VStack(spacing: 0) { MiniHeader(track: track); MixerPanel() } .frame(maxWidth: .infinity, maxHeight: .infinity)
case .Queue: VStack(spacing: 0) { MiniHeader(track: track); QueuePanel() } .transition(.opacity.combined(with: .scale(scale: 0.96)))
case .Lyrics: VStack(spacing: 0) { MiniHeader(track: track); LyricsPanel(track: track) } controls(track, wide: true)
.frame(width: min(geo.size.width * 0.48, 440))
.frame(maxHeight: .infinity)
} }
} else {
VStack(spacing: 0) {
panelView(track, header: true)
.frame(maxHeight: .infinity)
.transition(.opacity.combined(with: .scale(scale: 0.96)))
controls(track, wide: false).frame(maxWidth: 560)
}
.frame(maxWidth: .infinity)
} }
.frame(maxHeight: .infinity)
.transition(.opacity.combined(with: .scale(scale: 0.96)))
controls(track)
} else { } else {
Text("Nothing Playing").font(.title3.weight(.semibold)).foregroundStyle(c.label2).frame(maxHeight: .infinity) Text("Nothing Playing").font(.title3.weight(.semibold)).foregroundStyle(c.label2)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
} }
.padding(.top, 20) .padding(.top, 20)
@@ -131,11 +142,21 @@ struct NowPlayingScreen: View {
} }
} }
private func controls(_ track: Track) -> some View { @ViewBuilder
private func panelView(_ track: Track, header: Bool) -> some View {
switch panel {
case .Art: ArtPanel(thumb: track.thumb, playing: g.playback.playWhenReady)
case .Mixer: VStack(spacing: 0) { if header { MiniHeader(track: track) }; MixerPanel() }
case .Queue: VStack(spacing: 0) { if header { MiniHeader(track: track) }; QueuePanel() }
case .Lyrics: VStack(spacing: 0) { if header { MiniHeader(track: track) }; LyricsPanel(track: track) }
}
}
private func controls(_ track: Track, wide: Bool) -> some View {
let pm = g.playback let pm = g.playback
let c = tint.map { base.tinted($0) } ?? base let c = tint.map { base.tinted($0) } ?? base
return VStack(spacing: 0) { return VStack(spacing: 0) {
if panel == .Art { if wide || panel == .Art {
HStack(alignment: .center) { HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 1) {
Text(track.title).font(.title3.weight(.semibold)).foregroundStyle(c.label).lineLimit(1) Text(track.title).font(.title3.weight(.semibold)).foregroundStyle(c.label).lineLimit(1)
@@ -151,7 +172,7 @@ struct NowPlayingScreen: View {
.transition(.opacity.combined(with: .move(edge: .top))) .transition(.opacity.combined(with: .move(edge: .top)))
} }
ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track)) ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track))
.padding(.top, panel == .Art ? 34 : 18) .padding(.top, wide ? 24 : panel == .Art ? 34 : 18)
HStack { HStack {
SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle) SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
Spacer() Spacer()
@@ -604,18 +625,11 @@ private struct MixerPanel: View {
} }
} }
} }
p.separator.frame(height: 0.5).padding(.vertical, 10) p.separator.frame(height: 0.5).padding(.vertical, 6)
HStack { FaderRow(dot: nil, label: "Master", percent: Int(pm.master * 100), dim: false) {
Text("Master").font(.headline).foregroundStyle(p.label)
Spacer()
Text("\(Int(pm.master * 100))%").font(.footnote.monospacedDigit()).foregroundStyle(p.label2)
}
HStack(spacing: 8) {
Image(systemName: "speaker.fill").font(.system(size: 13)).foregroundStyle(p.label2)
FaderSlider(value: pm.master, fill: p.label, onChange: pm.setMaster) FaderSlider(value: pm.master, fill: p.label, onChange: pm.setMaster)
Image(systemName: "speaker.wave.3.fill").font(.system(size: 14)).foregroundStyle(p.label2) } trailing: { EmptyView() }
} Spacer(minLength: 12)
Spacer(minLength: 16)
} }
.padding(.horizontal, 24) .padding(.horizontal, 24)
} }
@@ -655,32 +669,55 @@ private struct StemFader: View {
var body: some View { var body: some View {
let color = Color(hex: Stems.color(name)) let color = Color(hex: Stems.color(name))
VStack(alignment: .leading, spacing: 0) { FaderRow(dot: setting.muted ? p.label3 : color, label: Stems.label(name), percent: Int(stemTaper(setting.level) * 100), dim: setting.muted) {
HStack(spacing: 8) { VStack(spacing: 0) {
Circle().fill(setting.muted ? p.label3 : color).frame(width: 10, height: 10) FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled) {
Text(Stems.label(name)).font(.headline).foregroundStyle(setting.muted ? p.label2 : p.label) pm.setStemLevel(name, $0)
Spacer()
Text("\(Int(stemTaper(setting.level) * 100))%").font(.footnote.monospacedDigit()).foregroundStyle(p.label2).padding(.trailing, 4)
if enabled {
ToggleBox(text: "M", on: setting.muted, onColor: p.red) { pm.toggleMute(name) }
ToggleBox(text: "S", on: setting.solo, onColor: Color(hex: 0xFFCC00)) { pm.toggleSolo(name) }
} }
} // Live level under the fader, -48..0 dBFS.
FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled) { GeometryReader { geo in
pm.setStemLevel(name, $0) let f = CGFloat(min(1, max(0, (20 * log10(level + 1e-5) + 48) / 48)))
} ZStack(alignment: .leading) {
// Live level under the fader, -48..0 dBFS. Capsule().fill(p.fill)
GeometryReader { geo in Capsule().fill(color).frame(width: geo.size.width * f)
let f = CGFloat(min(1, max(0, (20 * log10(level + 1e-5) + 48) / 48))) }
ZStack(alignment: .leading) {
Capsule().fill(p.fill)
Capsule().fill(color).frame(width: geo.size.width * f)
} }
.frame(height: 3)
.padding(.horizontal, 12)
}
} trailing: {
if enabled {
ToggleBox(text: "M", on: setting.muted, onColor: p.red) { pm.toggleMute(name) }
ToggleBox(text: "S", on: setting.solo, onColor: Color(hex: 0xFFCC00)) { pm.toggleSolo(name) }
} }
.frame(height: 3)
.padding(.horizontal, 12)
} }
.padding(.top, 10) }
}
/// One mixer line: name, fader, level and buttons side by side, so all six stems and the
/// master fit without scrolling (the faders grab drags, so the list can't scroll well).
private struct FaderRow<Fader: View, Trailing: View>: View {
let dot: Color?
let label: String
let percent: Int
let dim: Bool
@ViewBuilder let fader: Fader
@ViewBuilder let trailing: Trailing
@Environment(\.palette) private var p
var body: some View {
HStack(spacing: 8) {
HStack(spacing: 6) {
if let dot { Circle().fill(dot).frame(width: 8, height: 8) }
Text(label).font(.subheadline.weight(.semibold)).foregroundStyle(dim ? p.label2 : p.label)
.lineLimit(1).minimumScaleFactor(0.8)
}
.frame(width: 70, alignment: .leading)
fader
Text("\(percent)%").font(.caption.monospacedDigit()).foregroundStyle(p.label2)
.frame(width: 36, alignment: .trailing)
trailing
}
.frame(height: 46)
} }
} }
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import UniformTypeIdentifiers
/// A Settings.app row that pushes [route]: icon, title, grey value, chevron. /// A Settings.app row that pushes [route]: icon, title, grey value, chevron.
private struct NavCell: View { 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) 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) ToggleCell(title: "Download on Wi-Fi Only", icon: "arrow.down.circle.fill", iconBg: 0x30B0C7, isOn: $s.downloadsWifiOnly)
} header: { Header(text: "Storage") } } 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 { Section {
NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance) NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance)
} header: { Header(text: "Display") } footer: { } 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 ----------------------------------------------------------------------------------- // ---- plex -----------------------------------------------------------------------------------
struct PlexAccountScreen: View { struct PlexAccountScreen: View {
+3 -3
View File
@@ -6,8 +6,8 @@ options:
settings: settings:
base: base:
SWIFT_VERSION: "5.0" SWIFT_VERSION: "5.0"
MARKETING_VERSION: "1.0.12" MARKETING_VERSION: "1.0.13"
CURRENT_PROJECT_VERSION: "13" CURRENT_PROJECT_VERSION: "14"
targets: targets:
Stemify: Stemify:
type: application type: application
@@ -32,7 +32,7 @@ targets:
CFBundleVersion: $(CURRENT_PROJECT_VERSION) CFBundleVersion: $(CURRENT_PROJECT_VERSION)
UILaunchScreen: {} UILaunchScreen: {}
UIBackgroundModes: [audio, fetch] UIBackgroundModes: [audio, fetch]
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait] UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
CADisableMinimumFrameDurationOnPhone: true CADisableMinimumFrameDurationOnPhone: true
ITSAppUsesNonExemptEncryption: false ITSAppUsesNonExemptEncryption: false
NSLocalNetworkUsageDescription: Stemify talks to Plex and the stems server on your home network. NSLocalNetworkUsageDescription: Stemify talks to Plex and the stems server on your home network.
+9
View File
@@ -15,6 +15,15 @@
"tintColor": "#FA2D48", "tintColor": "#FA2D48",
"category": "entertainment", "category": "entertainment",
"versions": [ "versions": [
{
"version": "1.0.12",
"buildVersion": "13",
"date": "2026-10-20T13:29:05-07:00",
"localizedDescription": "More space between the seek bar and the transport buttons on Now Playing. Unsigned IPA: re-sign with Sideloadly, AltStore, SideStore or Feather.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.12/stemify-1.0.12.ipa",
"size": 1529939,
"minOSVersion": "17.0"
},
{ {
"version": "1.0.11", "version": "1.0.11",
"buildVersion": "12", "buildVersion": "12",