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>
This commit is contained in:
1 parent
4e2816673d
commit
9c1e70da4d
2 files changed
+85
-48
No files matched your search
@@ -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
-1
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user