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:
1 parent
894dd5a695
commit
df5d847169
157 files changed
+3935
-742
No files matched your search
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user