iOS: Duo unfolded layout with right-edge tab rail, stems+queue deck and swap sides
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f4af7b6cf8
commit
08f7af9421
4 files changed
+138
-14
No files matched your search
@@ -53,6 +53,8 @@ final class Ui {
|
||||
}
|
||||
/// True while an unfolded iPhone Duo shows Now Playing docked beside the library.
|
||||
private(set) var split = false
|
||||
/// Split only: the side opposite Now Playing shows the stems and queue (true) or the selected tab.
|
||||
var deck = true
|
||||
let overlays: Overlays
|
||||
|
||||
init(overlays: Overlays) { self.overlays = overlays }
|
||||
@@ -67,6 +69,7 @@ final class Ui {
|
||||
|
||||
/// Tapping the selected tab again pops it to its root.
|
||||
func selectTab(_ t: Tab) {
|
||||
if split && deck { deck = false; tab = t; return }
|
||||
if t == tab { paths[t] = [] } else { tab = t }
|
||||
}
|
||||
|
||||
@@ -89,6 +92,9 @@ final class Ui {
|
||||
/// Docked Now Playing is already on screen, so there's nothing to open while split.
|
||||
func openNowPlaying() { if !split { nowPlayingOpen = true } }
|
||||
|
||||
/// Split only: brings the stems and queue back beside Now Playing.
|
||||
func showDeck() { deck = true }
|
||||
|
||||
func setSplit(_ on: Bool) {
|
||||
guard on != split else { return }
|
||||
split = on
|
||||
@@ -98,6 +104,7 @@ final class Ui {
|
||||
/// Switches to the Library tab and pushes [r] there.
|
||||
func showInLibrary(_ r: Route) {
|
||||
nowPlayingOpen = false
|
||||
deck = false
|
||||
let t = home
|
||||
tab = t
|
||||
paths[t, default: []].append(r)
|
||||
@@ -106,6 +113,7 @@ final class Ui {
|
||||
/// Switches to Settings and opens [r] (the Equalizer) there.
|
||||
func showInSettings(_ r: Route) {
|
||||
nowPlayingOpen = false
|
||||
deck = false
|
||||
tab = .Settings
|
||||
if paths[.Settings]?.last != r { paths[.Settings, default: []].append(r) }
|
||||
}
|
||||
|
||||
@@ -2,15 +2,18 @@ import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// The tab bar, a navigation stack per tab, the mini player and the Now Playing sheet.
|
||||
/// On an unfolded iPhone Duo the library sits on one side of the hinge and Now Playing on the other.
|
||||
/// On an unfolded iPhone Duo Now Playing takes one side of the hinge, the stems and queue (or a tab)
|
||||
/// the other, and the tabs move to a rail on the right edge beside the status bar.
|
||||
struct RootView: View {
|
||||
@State private var ui = Ui(overlays: Overlays())
|
||||
/// Unfolded iPhone Duo: Now Playing on the left of the hinge instead of the right.
|
||||
@AppStorage("duoSwap") private var duoSwap = false
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let s = g.settings
|
||||
let p = Palette.of(s.theme, s.accent)
|
||||
DuoSplit(onSplit: { ui.setSplit($0) }) {
|
||||
DuoSplit(swap: duoSwap, showDeck: ui.deck, onSplit: { ui.setSplit($0) }) {
|
||||
TabView(selection: Binding(get: { ui.tab }, set: { ui.selectTab($0) })) {
|
||||
ForEach(Tab.layout(s.tabLayout), id: \.self) { t in
|
||||
NavigationStack(path: ui.path(t)) {
|
||||
@@ -23,8 +26,12 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
.modifier(GlassMiniPlayerHost())
|
||||
} deck: {
|
||||
NowPlayingDeck()
|
||||
} docked: {
|
||||
NowPlayingScreen(docked: true)
|
||||
} rail: {
|
||||
DuoRail(swap: $duoSwap)
|
||||
}
|
||||
.background(p.bg.ignoresSafeArea())
|
||||
.onChange(of: s.tabLayout) { _, _ in ui.ensureValidTab() }
|
||||
@@ -135,6 +142,8 @@ private struct MiniPlayerRoom: ViewModifier {
|
||||
content.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if !glassAccessory && !ui.split && g.playback.current != nil { Color.clear.frame(height: 66) }
|
||||
}
|
||||
// Split, the rail stands in for the tab bar.
|
||||
.toolbar(ui.split ? .hidden : .automatic, for: .tabBar)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,28 +151,55 @@ private extension View {
|
||||
func miniPlayerRoom() -> some View { modifier(MiniPlayerRoom()) }
|
||||
}
|
||||
|
||||
/// Splits the screen at the iPhone Duo's hinge (the system's division region): [library] on the
|
||||
/// leading or top side, [docked] on the other. Anywhere without an active hinge it's just [library].
|
||||
/// One layout for both states keeps the library's tab and scroll state across fold and unfold.
|
||||
private struct DuoSplit<Library: View, Docked: View>: View {
|
||||
/// Splits the screen at the iPhone Duo's hinge (the system's division region). [library] (with
|
||||
/// [deck] over it while [showDeck]) goes on one side and [docked] on the other, left and right
|
||||
/// trading places with [swap]; [rail] runs down the right edge in the status bar's strip.
|
||||
/// Anywhere without a hinge it's just [library]. The library stays put in the view tree, so its
|
||||
/// tab and scroll state survive folding, unfolding and swapping.
|
||||
private struct DuoSplit<Library: View, Deck: View, Docked: View, Rail: View>: View {
|
||||
var swap: Bool
|
||||
var showDeck: Bool
|
||||
var onSplit: (Bool) -> Void
|
||||
@ViewBuilder var library: Library
|
||||
@ViewBuilder var deck: Deck
|
||||
@ViewBuilder var docked: Docked
|
||||
@ViewBuilder var rail: Rail
|
||||
|
||||
private static var railWidth: CGFloat { 64 }
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let cut = Self.cut(geo)
|
||||
let inset = geo.safeAreaInsets.trailing
|
||||
// The rail lives in the status bar's strip; if that's too thin it borrows from the right pane.
|
||||
let steal = cut == nil ? 0 : max(0, Self.railWidth - inset)
|
||||
let flip = swap && cut?.vertical == true
|
||||
let layout = cut?.vertical == false ? AnyLayout(VStackLayout(spacing: cut?.gap ?? 0)) : AnyLayout(HStackLayout(spacing: cut?.gap ?? 0))
|
||||
layout {
|
||||
library
|
||||
.frame(width: cut.map { $0.vertical ? $0.first : geo.size.width } ?? geo.size.width,
|
||||
height: cut.map { $0.vertical ? geo.size.height : $0.first } ?? geo.size.height)
|
||||
ZStack {
|
||||
library
|
||||
if cut != nil && showDeck { deck.transition(.opacity) }
|
||||
}
|
||||
.environment(\.layoutDirection, .leftToRight)
|
||||
.frame(width: cut.map { $0.vertical ? (flip ? $0.second - steal : $0.first) : geo.size.width - steal } ?? geo.size.width,
|
||||
height: cut.map { $0.vertical ? geo.size.height : $0.first } ?? geo.size.height)
|
||||
if let cut {
|
||||
docked
|
||||
.frame(width: cut.vertical ? cut.second : geo.size.width,
|
||||
.environment(\.layoutDirection, .leftToRight)
|
||||
.frame(width: cut.vertical ? (flip ? cut.first : cut.second - steal) : geo.size.width - steal,
|
||||
height: cut.vertical ? geo.size.height : cut.second)
|
||||
}
|
||||
}
|
||||
// Right-to-left lays the same two children out mirrored, so swapping doesn't rebuild either.
|
||||
.environment(\.layoutDirection, flip ? .rightToLeft : .leftToRight)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if cut != nil {
|
||||
rail.frame(width: max(inset, Self.railWidth), height: geo.size.height).offset(x: inset)
|
||||
}
|
||||
}
|
||||
.animation(.spring(duration: 0.4, bounce: 0.1), value: flip)
|
||||
.animation(.easeInOut(duration: 0.2), value: showDeck)
|
||||
.onChange(of: cut != nil, initial: true) { _, on in onSplit(on) }
|
||||
}
|
||||
}
|
||||
@@ -193,3 +229,41 @@ private struct DuoSplit<Library: View, Docked: View>: View {
|
||||
return c.first > 200 && c.second > 200 ? c : nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The tab bar's stand-in on an unfolded iPhone Duo: Now Playing's stems and queue, the tabs, and
|
||||
/// a button that swaps which side of the hinge Now Playing sits on.
|
||||
private struct DuoRail: View {
|
||||
@Binding var swap: Bool
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Spacer(minLength: 48)
|
||||
item("Playing", "waveform", on: ui.deck) { ui.showDeck() }
|
||||
ForEach(ui.tabs, id: \.self) { t in
|
||||
item(t.rawValue, t.icon, on: !ui.deck && ui.tab == t) { ui.selectTab(t) }
|
||||
}
|
||||
Capsule().fill(p.label3.opacity(0.4)).frame(width: 24, height: 2).padding(.vertical, 8)
|
||||
item("Swap", "arrow.left.arrow.right", on: false) { swap.toggle() }
|
||||
Spacer(minLength: 48)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func item(_ title: String, _ icon: String, on: Bool, _ action: @escaping () -> Void) -> some View {
|
||||
Button { withAnimation(.snappy(duration: 0.25)) { action() } } label: {
|
||||
VStack(spacing: 3) {
|
||||
Image(systemName: icon).font(.system(size: 19, weight: on ? .semibold : .regular)).frame(height: 24)
|
||||
Text(title).font(.system(size: 10, weight: .medium)).lineLimit(1).minimumScaleFactor(0.7)
|
||||
}
|
||||
.foregroundStyle(on ? p.accent : p.label2)
|
||||
.frame(width: 56, height: 52)
|
||||
.background(on ? p.accent.opacity(0.14) : .clear, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
.accessibilityLabel(title)
|
||||
.accessibilityAddTraits(on ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
@@ -224,15 +224,15 @@ struct NowPlayingScreen: View {
|
||||
.padding(.top, 14)
|
||||
HStack {
|
||||
let hidden = NowPlayingButton.hidden(g.settings.hiddenNowPlaying)
|
||||
if !hidden.contains(.Mixer) { PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) } }
|
||||
if !hidden.contains(.Mixer) { PanelToggle(symbol: "slider.vertical.3", on: docked ? ui.deck : 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: {
|
||||
// Swipe up on the art (or here) for the queue. Docked, the queue is already beside it.
|
||||
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)) }
|
||||
@@ -240,7 +240,7 @@ struct NowPlayingScreen: View {
|
||||
.foregroundStyle(c.label3).frame(maxWidth: .infinity, minHeight: 30).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
.gesture(swipe(up: panel != .Queue))
|
||||
.gesture(swipe(up: panel != .Queue)) }
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
}
|
||||
@@ -257,6 +257,8 @@ struct NowPlayingScreen: View {
|
||||
}
|
||||
|
||||
private func show(_ to: NpPanel) {
|
||||
// Docked, the stems and the queue have the other side of the hinge to themselves.
|
||||
if docked && (to == .Mixer || to == .Queue) { withAnimation(.snappy) { ui.showDeck() }; return }
|
||||
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = panel == to ? .Art : to }
|
||||
}
|
||||
|
||||
@@ -905,6 +907,45 @@ struct FaderSlider: View {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- deck -----------------------------------------------------------------------------------
|
||||
|
||||
/// Unfolded iPhone Duo: the stems over the queue, on the other side of the hinge from Now Playing.
|
||||
struct NowPlayingDeck: View {
|
||||
@Environment(\.palette) private var base
|
||||
@State private var tint: Color?
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let c = tint.map { base.tinted($0) } ?? base
|
||||
let track = g.playback.current?.track
|
||||
GeometryReader { geo in
|
||||
if track != nil {
|
||||
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)
|
||||
Rectangle().fill(c.label3.opacity(0.35)).frame(height: 0.5).padding(.horizontal, 24).padding(.bottom, 8)
|
||||
QueuePanel()
|
||||
}
|
||||
} else {
|
||||
Text("Nothing Playing").font(.title3.weight(.semibold)).foregroundStyle(c.label2)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(.top, 12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(c.bg.ignoresSafeArea())
|
||||
.environment(\.palette, c)
|
||||
.modifier(NpScheme(dark: c.dark, docked: true))
|
||||
.tint(c.label)
|
||||
.animation(.easeInOut(duration: 0.3), value: tint)
|
||||
.task(id: "\(track?.thumb ?? "")|\(g.settings.albumColorNowPlaying)|\(base.dark)") {
|
||||
guard g.settings.albumColorNowPlaying, let thumb = track?.thumb else { tint = nil; return }
|
||||
tint = await albumColor(thumb, dark: base.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- queue ----------------------------------------------------------------------------------
|
||||
|
||||
private struct QueuePanel: View {
|
||||
|
||||
Reference in new issue
Block a user