From 9c1e70da4d25fbea671768e392da86e2207ae139 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 20 Oct 2026 13:37:38 -0700 Subject: [PATCH] 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 --- ios/Stemify/UI/Screens/NowPlayingScreen.swift | 131 +++++++++++------- ios/project.yml | 2 +- 2 files changed, 85 insertions(+), 48 deletions(-) diff --git a/ios/Stemify/UI/Screens/NowPlayingScreen.swift b/ios/Stemify/UI/Screens/NowPlayingScreen.swift index ed03fbb..c7cd205 100644 --- a/ios/Stemify/UI/Screens/NowPlayingScreen.swift +++ b/ios/Stemify/UI/Screens/NowPlayingScreen.swift @@ -98,21 +98,32 @@ struct NowPlayingScreen: View { let pm = g.playback let c = tint.map { base.tinted($0) } ?? base 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 { - Group { - switch panel { - case .Art: ArtPanel(thumb: track.thumb, playing: pm.playWhenReady) - case .Mixer: VStack(spacing: 0) { MiniHeader(track: track); MixerPanel() } - case .Queue: VStack(spacing: 0) { MiniHeader(track: track); QueuePanel() } - case .Lyrics: VStack(spacing: 0) { MiniHeader(track: track); LyricsPanel(track: track) } + if wide { + HStack(spacing: 0) { + panelView(track, header: false) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.opacity.combined(with: .scale(scale: 0.96))) + 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 { - 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) @@ -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 c = tint.map { base.tinted($0) } ?? base return VStack(spacing: 0) { - if panel == .Art { + if wide || panel == .Art { HStack(alignment: .center) { VStack(alignment: .leading, spacing: 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))) } 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 { SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle) Spacer() @@ -604,18 +625,11 @@ private struct MixerPanel: View { } } } - p.separator.frame(height: 0.5).padding(.vertical, 10) - HStack { - 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) + p.separator.frame(height: 0.5).padding(.vertical, 6) + FaderRow(dot: nil, label: "Master", percent: Int(pm.master * 100), dim: false) { FaderSlider(value: pm.master, fill: p.label, onChange: pm.setMaster) - Image(systemName: "speaker.wave.3.fill").font(.system(size: 14)).foregroundStyle(p.label2) - } - Spacer(minLength: 16) + } trailing: { EmptyView() } + Spacer(minLength: 12) } .padding(.horizontal, 24) } @@ -655,32 +669,55 @@ private struct StemFader: View { var body: some View { let color = Color(hex: Stems.color(name)) - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 8) { - Circle().fill(setting.muted ? p.label3 : color).frame(width: 10, height: 10) - Text(Stems.label(name)).font(.headline).foregroundStyle(setting.muted ? p.label2 : p.label) - 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) } + FaderRow(dot: setting.muted ? p.label3 : color, label: Stems.label(name), percent: Int(stemTaper(setting.level) * 100), dim: setting.muted) { + VStack(spacing: 0) { + FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled) { + pm.setStemLevel(name, $0) } - } - FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled) { - pm.setStemLevel(name, $0) - } - // Live level under the fader, -48..0 dBFS. - GeometryReader { geo in - 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) + // Live level under the fader, -48..0 dBFS. + GeometryReader { geo in + 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: 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) } } diff --git a/ios/project.yml b/ios/project.yml index 5721c3f..163dbed 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -32,7 +32,7 @@ targets: CFBundleVersion: $(CURRENT_PROJECT_VERSION) UILaunchScreen: {} UIBackgroundModes: [audio, fetch] - UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait] + UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight] CADisableMinimumFrameDurationOnPhone: true ITSAppUsesNonExemptEncryption: false NSLocalNetworkUsageDescription: Stemify talks to Plex and the stems server on your home network.