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

@@ -50,6 +50,18 @@ struct NowPlayingOptionsScreen: View {
.themedRow()
}
} footer: { Text("Choose which buttons show under the playback controls.").foregroundStyle(p.label2) }
Section {
Toggle(isOn: $s.swipeUpQueue) {
HStack(spacing: 14) {
SettingsIcon(symbol: "hand.draw", bg: 0x5856D6)
Text("Swipe Up for Playing Next").foregroundStyle(p.label)
}
}
.tint(Color(hex: 0x34C759))
.themedRow()
} header: { Text("Gestures").foregroundStyle(p.label2) } footer: {
Text("Off, swiping up on the art or the stems does nothing. Tap Playing Next under the controls instead.").foregroundStyle(p.label2)
}
Section {
Toggle(isOn: $s.lockScreenMix) {
HStack(spacing: 14) {
+69 -26
View File
@@ -108,6 +108,10 @@ struct NowPlayingScreen: View {
@State private var tint: Color?
/// How far the screen is being dragged down to close it.
@State private var drop: CGFloat = 0
/// Set once the phone has unfolded, so it's an iPhone Duo: its screens are shorter for their
/// width than a regular iPhone's, so the controls pack tighter and the art gets the room.
@AppStorage("duoSeen") private var duoSeen = false
private var tight: Bool { docked || duoSeen }
private var g: AppGraph { .shared }
var body: some View {
@@ -133,7 +137,7 @@ struct NowPlayingScreen: View {
panelView(track, header: true)
.frame(maxHeight: .infinity)
.transition(.opacity.combined(with: .scale(scale: 0.96)))
controls(track, wide: false).frame(maxWidth: 560)
controls(track, wide: false).frame(maxWidth: tight ? 520 : 560)
}
.frame(maxWidth: .infinity)
}
@@ -142,8 +146,8 @@ struct NowPlayingScreen: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.padding(.top, 20)
.padding(.bottom, 8)
.padding(.top, docked ? 4 : tight ? 14 : 20)
.padding(.bottom, tight ? 4 : 8)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(c.bg.ignoresSafeArea())
.overlay(alignment: .top) {
@@ -172,7 +176,7 @@ struct NowPlayingScreen: View {
@ViewBuilder
private func panelView(_ track: Track, header: Bool) -> some View {
switch panel {
case .Art: ArtPanel(thumb: track.thumb, playing: g.playback.playWhenReady).contentShape(Rectangle())
case .Art: ArtPanel(thumb: track.thumb, playing: g.playback.playWhenReady, inset: tight ? 18 : 28).contentShape(Rectangle())
case .Mixer: VStack(spacing: 0) { if header { MiniHeader(track: track) }; MixerPanel() }
case .Queue: VStack(spacing: 0) { if header { MiniHeader(track: track).contentShape(Rectangle()).gesture(screenDrag) }; QueuePanel(close: { show(.Queue) }) }
case .Lyrics: VStack(spacing: 0) { if header { MiniHeader(track: track).contentShape(Rectangle()).gesture(screenDrag) }; LyricsPanel(track: track) }
@@ -195,11 +199,11 @@ struct NowPlayingScreen: View {
Spacer(minLength: 8)
EllipsisButton(items: ui.menu.track(track))
}
.padding(.top, 14)
.padding(.top, tight ? 8 : 14)
.transition(.opacity.combined(with: .move(edge: .top)))
}
ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track))
.padding(.top, wide ? 24 : panel == .Art ? 34 : 30)
.padding(.top, tight ? (wide ? 14 : 18) : wide ? 24 : panel == .Art ? 34 : 30)
HStack {
SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
Spacer()
@@ -220,8 +224,8 @@ struct NowPlayingScreen: View {
Spacer()
SideToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat)
}
.frame(height: 84)
.padding(.top, 14)
.frame(height: tight ? 76 : 84)
.padding(.top, tight ? 4 : 14)
HStack {
let hidden = NowPlayingButton.hidden(g.settings.hiddenNowPlaying)
if !hidden.contains(.Mixer) { PanelToggle(symbol: "slider.vertical.3", on: docked ? ui.deck : panel == .Mixer) { show(.Mixer) } }
@@ -235,14 +239,14 @@ struct NowPlayingScreen: View {
if !docked { Button { show(.Queue) } label: {
VStack(spacing: 0) {
Image(systemName: panel == .Queue ? "chevron.compact.down" : "chevron.compact.up").font(.system(size: 22, weight: .medium))
if panel != .Queue { Text("Playing Next").font(.caption2.weight(.medium)) }
if panel != .Queue && !tight { Text("Playing Next").font(.caption2.weight(.medium)) }
}
.foregroundStyle(c.label3).frame(maxWidth: .infinity, minHeight: 30).contentShape(Rectangle())
.foregroundStyle(c.label3).frame(maxWidth: .infinity, minHeight: tight ? 24 : 30).contentShape(Rectangle())
}
.buttonStyle(PressFade())
.gesture(swipe(up: panel != .Queue)) }
}
.padding(.horizontal, 28)
.padding(.horizontal, tight ? 20 : 28)
}
/// Opens a panel, or goes back to the art if it's already open.
@@ -267,6 +271,7 @@ struct NowPlayingScreen: View {
DragGesture(minimumDistance: 20).onEnded { v in
let dy = v.translation.height
guard up ? dy < -50 : dy > 50, abs(dy) > abs(v.translation.width) else { return }
if up && !g.settings.swipeUpQueue { return }
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = up ? .Queue : .Art }
}
}
@@ -282,7 +287,10 @@ struct NowPlayingScreen: View {
let vertical = abs(dy) > abs(v.translation.width)
if !docked && vertical && (dy > 120 || v.predictedEndTranslation.height > 300) { dismiss(); return }
withAnimation(.spring(duration: 0.3, bounce: 0.1)) { drop = 0 }
if vertical && dy < -50 { withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = .Queue } }
// Never from the stems: a slightly upward fader drag or scroll there kept opening the queue.
if vertical && dy < -50 && panel != .Mixer && g.settings.swipeUpQueue {
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = .Queue }
}
}
}
@@ -434,6 +442,7 @@ private struct EllipsisButton: View {
private struct ArtPanel: View {
let thumb: String?
let playing: Bool
var inset: CGFloat = 28
var body: some View {
GeometryReader { geo in
let side = max(min(geo.size.width, geo.size.height), 1)
@@ -443,7 +452,7 @@ private struct ArtPanel: View {
.animation(.spring(response: 0.45, dampingFraction: 0.62), value: playing)
.frame(width: geo.size.width, height: geo.size.height)
}
.padding(.horizontal, 28).padding(.vertical, 8)
.padding(.horizontal, inset).padding(.vertical, 8)
}
}
@@ -659,6 +668,9 @@ private final class Meters {
}
private struct MixerPanel: View {
/// The iPhone Duo deck: each stem's name sits over its fader so the fader gets the row's full
/// width, with a bigger thumb for fine moves.
var roomy = false
@Environment(Ui.self) private var ui
@Environment(\.palette) private var p
@State private var meters = Meters()
@@ -670,7 +682,7 @@ private struct MixerPanel: View {
let stems = pm.source == .Stems
GeometryReader { geo in
// Spread spare height across the rows (stems + master) instead of leaving it all at the bottom.
let rowH = min(62, max(46, (geo.size.height - 80) / CGFloat(names.count + 1)))
let rowH = roomy ? Self.roomyRow : min(62, max(46, (geo.size.height - 80) / CGFloat(names.count + 1)))
ScrollView {
VStack(alignment: .leading, spacing: 0) {
if pm.source == .Original {
@@ -734,13 +746,13 @@ private struct MixerPanel: View {
let levels = stems ? meters.step(pm.engine, names.count) : []
VStack(spacing: 0) {
ForEach(Array(names.enumerated()), id: \.element) { i, name in
StemFader(name: name, setting: pm.mix[name] ?? StemSetting(), level: i < levels.count ? levels[i] : 0, enabled: stems, height: rowH)
StemFader(name: name, setting: pm.mix[name] ?? StemSetting(), level: i < levels.count ? levels[i] : 0, enabled: stems, height: rowH, roomy: roomy)
}
}
}
p.separator.frame(height: 0.5).padding(.vertical, 6)
FaderRow(dot: nil, label: "Master", percent: Int(pm.master * 100), dim: false, height: rowH) {
FaderSlider(value: pm.master, fill: p.label, onChange: pm.setMaster)
FaderRow(dot: nil, label: "Master", percent: Int(pm.master * 100), dim: false, height: rowH, stacked: roomy) {
FaderSlider(value: pm.master, fill: p.label, thumb: roomy ? 30 : 24, onChange: pm.setMaster)
} trailing: { EmptyView() }
Spacer(minLength: 16)
}
@@ -753,6 +765,12 @@ private struct MixerPanel: View {
}
}
extension MixerPanel {
static let roomyRow: CGFloat = 60
/// Height that shows the presets, every stem and the master without scrolling, roomy rows.
static func roomyHeight(_ stems: Int) -> CGFloat { 48 + CGFloat(stems + 1) * roomyRow + 13 + 16 }
}
/// [Chip] in the sheet's own colours: selected is the label colour, so it reads on any tint.
private struct PresetChip: View {
let text: String
@@ -781,14 +799,15 @@ private struct StemFader: View {
let level: Float
let enabled: Bool
var height: CGFloat = 46
var roomy = false
@Environment(\.palette) private var p
private var pm: PlaybackManager { AppGraph.shared.playback }
var body: some View {
let color = Color(hex: Stems.color(name))
FaderRow(dot: setting.muted ? p.label3 : color, label: Stems.label(name), percent: Int(stemTaper(setting.level) * 100), dim: setting.muted, height: height) {
FaderRow(dot: setting.muted ? p.label3 : color, label: Stems.label(name), percent: Int(stemTaper(setting.level) * 100), dim: setting.muted, height: height, stacked: roomy) {
VStack(spacing: 0) {
FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled) {
FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled, thumb: roomy ? 30 : 24) {
pm.setStemLevel(name, $0)
}
// Live level under the fader, -48..0 dBFS.
@@ -804,8 +823,8 @@ private struct StemFader: View {
}
} 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) }
ToggleBox(text: "M", on: setting.muted, onColor: p.red, big: roomy) { pm.toggleMute(name) }
ToggleBox(text: "S", on: setting.solo, onColor: Color(hex: 0xFFCC00), big: roomy) { pm.toggleSolo(name) }
}
}
}
@@ -819,10 +838,30 @@ struct FaderRow<Fader: View, Trailing: View>: View {
let percent: Int
let dim: Bool
var height: CGFloat = 46
/// Name and level on a line of their own over the fader, so the fader runs the full width.
var stacked = false
@ViewBuilder let fader: Fader
@ViewBuilder let trailing: Trailing
@Environment(\.palette) private var p
var body: some View {
if stacked {
VStack(alignment: .leading, spacing: 0) {
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)
Text("\(percent)%").font(.caption.monospacedDigit()).foregroundStyle(p.label2)
Spacer(minLength: 0)
}
.frame(height: 18)
HStack(spacing: 8) { fader; trailing }
}
.frame(height: height)
} else {
row
}
}
private var row: some View {
HStack(spacing: 8) {
HStack(spacing: 6) {
if let dot { Circle().fill(dot).frame(width: 8, height: 8) }
@@ -843,12 +882,13 @@ struct ToggleBox: View {
let text: String
let on: Bool
let onColor: Color
var big = false
let action: () -> Void
@Environment(\.palette) private var p
var body: some View {
Button(action: action) {
Text(text).font(.footnote.bold()).foregroundStyle(on ? .black : p.label2)
.frame(width: 30, height: 26)
.frame(width: big ? 38 : 30, height: big ? 32 : 26)
.background(on ? onColor : p.fill, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
}
.buttonStyle(.plain)
@@ -873,13 +913,15 @@ struct FaderSlider: View {
self.value = value; self.fill = fill; self.detent = detent; self.enabled = enabled; self.thumb = thumb; self.onChange = onChange
}
private var bar: CGFloat { thumb >= 28 ? 6 : 4 }
var body: some View {
GeometryReader { geo in
let track = max(geo.size.width - thumb, 1)
let x = CGFloat(min(max(value, 0), 1)) * track
ZStack(alignment: .leading) {
Capsule().fill(p.fillStrong).frame(height: 4).padding(.horizontal, thumb / 2)
Capsule().fill(fill).frame(width: x + thumb / 2, height: 4).padding(.leading, thumb / 2 - 0.01)
Capsule().fill(p.fillStrong).frame(height: bar).padding(.horizontal, thumb / 2)
Capsule().fill(fill).frame(width: x + thumb / 2, height: bar).padding(.leading, thumb / 2 - 0.01)
if let detent {
p.label3.frame(width: 2, height: 10).offset(x: CGFloat(detent) * track + thumb / 2 - 1)
}
@@ -901,7 +943,7 @@ struct FaderSlider: View {
onChange(v)
}.onEnded { _ in start = nil })
}
.frame(height: 36)
.frame(height: max(36, thumb + 8))
.opacity(enabled ? 1 : 0.4)
.sensoryFeedback(.selection, trigger: caught) { _, now in now }
}
@@ -923,7 +965,8 @@ struct NowPlayingDeck: View {
VStack(spacing: 0) {
Text("Stems").font(.headline).foregroundStyle(c.label)
.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 24)
MixerPanel().frame(height: geo.size.height * 0.52)
// As much as the stems need to show without scrolling, leaving the queue a few rows.
MixerPanel(roomy: true).frame(height: min(MixerPanel.roomyHeight(max(g.playback.stemNames.count, 4)), geo.size.height * 0.68))
Rectangle().fill(c.label3.opacity(0.35)).frame(height: 0.5).padding(.horizontal, 24).padding(.bottom, 8)
QueuePanel()
}
+3 -4
View File
@@ -397,7 +397,7 @@ struct BackupCells: View {
var body: some View {
Button {
do { doc = ExportDocument(data: try StemifyExport.capture(g.stemPlaylists, g.settings).encoded()); exporting = true }
do { doc = ExportDocument(data: try StemifyExport.capture(g.stemPlaylists, g.playback, 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()
@@ -412,9 +412,8 @@ struct BackupCells: View {
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."
let r = x.restore(g.stemPlaylists, g.playback, g.settings)
message = "Restored \(r.playlists) playlist\(r.playlists == 1 ? "" : "s"), \(r.mixes) song mix\(r.mixes == 1 ? "" : "es"), your presets and sound settings."
} catch { message = error.localizedDescription }
}
.alert("Backup", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) {