diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e8fefe..64388e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,3 +31,4 @@ 2026-11-09 iOS: iPhone Duo unfolded uses both sides of the hinge: library on one side, Now Playing docked on the other (no mini player while split). Folded and other iPhones unchanged. 1.0 RC 3 (build 23). 2026-11-09 iOS: Duo split now engages with the phone lying flat (hinge region reports inactive then), with a screen-size fallback if no region is reported. 1.0 RC 4 (build 24). 2026-11-09 iOS: dev builds (`ios/scripts/build-remote.sh dev`): bundle id org.cdnutter.stemify-dev, "Stemify Dev", own App Group, into ios/build/dev. Internal tests only, never on Gitea or Sideload. +2026-11-09 iOS: Duo unfolded redesign. Tabs move to a rail on the right edge by the status bar, Now Playing fills the screen with stems and queue on the left and the player on the right, picking a tab swaps the left pane, Swap flips the sides (remembered). Folded screen unchanged. diff --git a/ios/Stemify/UI/Nav.swift b/ios/Stemify/UI/Nav.swift index 63286b9..57c5258 100644 --- a/ios/Stemify/UI/Nav.swift +++ b/ios/Stemify/UI/Nav.swift @@ -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) } } diff --git a/ios/Stemify/UI/RootView.swift b/ios/Stemify/UI/RootView.swift index 6a03650..00ba748 100644 --- a/ios/Stemify/UI/RootView.swift +++ b/ios/Stemify/UI/RootView.swift @@ -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: 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: 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: 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 : []) + } +} diff --git a/ios/Stemify/UI/Screens/NowPlayingScreen.swift b/ios/Stemify/UI/Screens/NowPlayingScreen.swift index 4fafe2a..75ed7c9 100644 --- a/ios/Stemify/UI/Screens/NowPlayingScreen.swift +++ b/ios/Stemify/UI/Screens/NowPlayingScreen.swift @@ -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 {