Stemify iOS 1.1 and WebUI updates, repo restructure into desktop/ios/server/web

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Sonnet 5.5 committed 2026-10-24 17:08:51 -07:00
1 parent 894dd5a695
commit df5d847169
157 files changed
+3935 -742

No files matched your search

+118 -27
View File
@@ -92,6 +92,8 @@ struct NowPlayingScreen: View {
@Environment(\.dismiss) private var dismiss
@State private var panel = NpPanel.Art
@State private var tint: Color?
/// How far the screen is being dragged down to close it.
@State private var drop: CGFloat = 0
private var g: AppGraph { .shared }
var body: some View {
@@ -130,11 +132,22 @@ struct NowPlayingScreen: View {
.padding(.bottom, 8)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(c.bg.ignoresSafeArea())
.overlay(alignment: .top) {
// Stands in for the sheet's grabber: tap or drag down to close.
Capsule().fill(c.label3).frame(width: 36, height: 5)
.frame(width: 120, height: 20).contentShape(Rectangle())
.onTapGesture { dismiss() }
.gesture(screenDrag)
}
.offset(y: drop)
// On the art and the mixer: drag down anywhere to close, swipe up anywhere for the
// queue. The queue and lyrics scroll, so there it's the song header or the grabber.
.simultaneousGesture(screenDrag, including: panel == .Art || panel == .Mixer ? .all : .subviews)
.environment(\.palette, c)
.preferredColorScheme(c.dark ? .dark : .light)
.tint(c.label)
.animation(.easeInOut(duration: 0.3), value: tint)
.presentationDragIndicator(.visible)
.presentationBackground(.clear)
.onAppear { if g.playback.karaoke { panel = .Lyrics } }
.task(id: "\(track?.thumb ?? "")|\(g.settings.albumColorNowPlaying)|\(base.dark)") {
guard g.settings.albumColorNowPlaying, let thumb = track?.thumb else { tint = nil; return }
@@ -145,10 +158,10 @@ 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)
case .Art: ArtPanel(thumb: track.thumb, playing: g.playback.playWhenReady).contentShape(Rectangle())
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) }
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) }
}
}
@@ -189,21 +202,31 @@ struct NowPlayingScreen: View {
}
.buttonStyle(PressFade())
Spacer()
TransportButton(symbol: "forward.fill", size: 34, action: pm.next)
TransportButton(symbol: "forward.fill", size: 34, action: pm.next, hold: pm.nextAlbum)
Spacer()
SideToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat)
}
.frame(height: 84)
.padding(.top, 14)
HStack {
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
SpeedMenu()
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
AirPlayButton(tint: UIColor(c.label2), active: UIColor(c.accent)).frame(width: 40, height: 40)
PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() }
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) }
let hidden = NowPlayingButton.hidden(g.settings.hiddenNowPlaying)
if !hidden.contains(.Mixer) { PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) } }
if !hidden.contains(.Speed) { SpeedMenu() }
if !hidden.contains(.Route) { Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity) } else { Spacer() }
if !hidden.contains(.AirPlay) { AirPlayButton(tint: UIColor(c.label2), active: UIColor(c.accent)).frame(width: 40, height: 40) }
if !hidden.contains(.Karaoke) { PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() } }
}
.frame(height: 48)
// Swipe up on the art (or here) for the queue.
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)) }
}
.foregroundStyle(c.label3).frame(maxWidth: .infinity, minHeight: 30).contentShape(Rectangle())
}
.buttonStyle(PressFade())
.gesture(swipe(up: panel != .Queue))
}
.padding(.horizontal, 28)
}
@@ -223,6 +246,30 @@ struct NowPlayingScreen: View {
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = panel == to ? .Art : to }
}
/// Swipe up for the queue, down to go back to the art.
private func swipe(up: Bool) -> some Gesture {
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 }
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = up ? .Queue : .Art }
}
}
private var screenDrag: some Gesture {
DragGesture(minimumDistance: 20)
.onChanged { v in
let dy = v.translation.height
drop = dy > 0 && dy > abs(v.translation.width) ? dy : 0
}
.onEnded { v in
let dy = v.translation.height
let vertical = abs(dy) > abs(v.translation.width)
if 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 } }
}
}
private func routeLabel(_ c: Palette) -> some View {
let eq = g.eq
let device = eq.device
@@ -263,12 +310,32 @@ private struct TransportButton: View {
let symbol: String
let size: CGFloat
let action: () -> Void
var hold: (() -> Void)?
@Environment(\.palette) private var p
var body: some View {
Button(action: action) {
HoldButton(action: action, hold: hold) {
Image(systemName: symbol).font(.system(size: size)).foregroundStyle(p.label).frame(width: 72, height: 72).contentShape(Rectangle())
}
.buttonStyle(PressFade())
}
}
/// A PressFade button that runs [hold] on a long press instead of the tap (hold Next to skip the album).
struct HoldButton<Label: View>: View {
let action: () -> Void
var hold: (() -> Void)?
@ViewBuilder let label: () -> Label
@State private var held = false
@State private var fired = 0
var body: some View {
Button { if held { held = false } else { action() } } label: { label() }
.buttonStyle(PressFade())
.simultaneousGesture(LongPressGesture(minimumDuration: 0.5)
.onChanged { _ in held = false }
.onEnded { _ in
guard let hold else { return }
held = true; fired += 1; hold()
})
.sensoryFeedback(.impact(weight: .medium), trigger: fired)
}
}
@@ -612,9 +679,32 @@ private struct MixerPanel: View {
if let saved, !isSaved {
PresetChip(text: "This Song", selected: false) { pm.applyMix(saved) }
}
ForEach(MixPresets.all) { preset in
PresetChip(text: "New Preset", icon: "plus", selected: false) {
ui.overlays.prompt("Save Preset", "Saves this mix for every song.", "Save", placeholder: "Preset name") { name in
let n = name.trimmingCharacters(in: .whitespaces)
guard !n.isEmpty else { return }
CustomPreset.add(n, pm.mix)
ui.overlays.hud("Preset saved", "slider.horizontal.3")
}
}
let s = g.settings
ForEach(MixPresets.bar(s)) { preset in
if let target = preset.build(names) {
PresetChip(text: preset.label, selected: MixPresets.normalized(target) == current) { pm.applyMix(target) }
let on = MixPresets.normalized(target) == current
let locked = s.lockedPreset == preset.id
// Tapping the mix that's on goes back to the full song.
PresetChip(text: preset.label, icon: locked ? "lock.fill" : nil, selected: on) { pm.applyMix(on ? [:] : target) }
.contextMenu {
if locked {
Button("Unlock", systemImage: "lock.open") { s.lockedPreset = "" }
} else {
Button("Lock for All Songs", systemImage: "lock") { s.lockedPreset = preset.id; pm.applyMix(target) }
}
Button("Edit Stems", systemImage: "slider.horizontal.3") { ui.showInSettings(.StemEditor) }
if preset.custom {
Button("Delete Preset", systemImage: "trash", role: .destructive) { CustomPreset.remove(preset.label) }
}
}
}
}
}
@@ -638,6 +728,8 @@ private struct MixerPanel: View {
.padding(.horizontal, 24)
}
.scrollIndicators(.hidden)
// Holds still when everything fits, so a drag down closes Now Playing instead.
.scrollBounceBehavior(.basedOnSize)
}
}
}
@@ -702,7 +794,7 @@ private struct StemFader: View {
/// 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 {
struct FaderRow<Fader: View, Trailing: View>: View {
let dot: Color?
let label: String
let percent: Int
@@ -728,7 +820,7 @@ private struct FaderRow<Fader: View, Trailing: View>: View {
}
}
private struct ToggleBox: View {
struct ToggleBox: View {
let text: String
let on: Bool
let onColor: Color
@@ -799,6 +891,7 @@ struct FaderSlider: View {
// ---- queue ----------------------------------------------------------------------------------
private struct QueuePanel: View {
var close: () -> Void = {}
@Environment(\.palette) private var p
private var g: AppGraph { .shared }
@@ -820,7 +913,9 @@ private struct QueuePanel: View {
}
}
.padding(.horizontal, 24).padding(.vertical, 6)
// Hold a row to drag it; swipe for Play Next or Remove.
.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance: 20).onEnded { v in if v.translation.height > 50 { close() } })
// Drag the handles to reorder; tap to jump, long press for Play Next or Remove.
List {
ForEach(Array(upcoming.enumerated()), id: \.element.uid) { i, q in
HStack(spacing: 12) {
@@ -830,7 +925,6 @@ private struct QueuePanel: View {
Text(q.track.artist).font(.subheadline).foregroundStyle(p.label2).lineLimit(1)
}
Spacer(minLength: 6)
Image(systemName: "line.3.horizontal").foregroundStyle(p.label3)
}
.frame(height: 60)
.contentShape(Rectangle())
@@ -838,12 +932,9 @@ private struct QueuePanel: View {
.listRowInsets(EdgeInsets(top: 0, leading: 24, bottom: 0, trailing: 16))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.swipeActions(edge: .trailing) {
Button(role: .destructive) { pm.removeAt(first + i) } label: { Label("Remove", systemImage: "trash") }
}
.swipeActions(edge: .leading) {
.contextMenu {
Button { pm.move(first + i, first) } label: { Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") }
.tint(.indigo)
Button(role: .destructive) { pm.removeAt(first + i) } label: { Label("Remove", systemImage: "trash") }
}
}
.onMove { from, to in
@@ -851,7 +942,7 @@ private struct QueuePanel: View {
pm.move(first + f, first + (to > f ? to - 1 : to))
}
if upcoming.isEmpty {
Text("Nothing queued. Use Add to Front of Queue or Add to End of Queue on any song.")
Text("Nothing queued. Use Play Next or Add to Queue on any song.")
.font(.subheadline).foregroundStyle(p.label2)
.listRowBackground(Color.clear).listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24))
@@ -859,6 +950,7 @@ private struct QueuePanel: View {
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.environment(\.editMode, .constant(.active))
}
}
}
@@ -977,10 +1069,9 @@ private struct MiniTransport: View {
}
.buttonStyle(PressFade())
if next {
Button(action: pm.next) {
HoldButton(action: pm.next, hold: pm.nextAlbum) {
Image(systemName: "forward.fill").font(.system(size: 20)).foregroundStyle(color).frame(width: 44, height: 44).contentShape(Rectangle())
}
.buttonStyle(PressFade())
}
}
}