From db55b5eea0ce57bc8f14225a24554d7054f4a0f1 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Nov 2026 07:57:58 -0800 Subject: [PATCH] iOS: iPhone Duo split layout at the hinge, 1.0 RC 3 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + ios/Stemify/UI/Nav.swift | 11 +++- ios/Stemify/UI/RootView.swift | 57 ++++++++++++++++++- ios/Stemify/UI/Screens/NowPlayingScreen.swift | 24 ++++++-- ios/project.yml | 4 +- 5 files changed, 86 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2455e7..0d59aa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,3 +28,4 @@ 2026-11-06 iOS: stem playlist covers can come from Photos (Change Cover > Choose from Photos). Stored on the phone, 1200 px JPEG. 2026-11-06 iOS: Update Download on downloaded playlists, albums, artists and stem playlists (their menus and the Downloads tab), plus Update All Downloads. Pulls the current song list, downloads new songs, drops removed ones, retries failed ones. Downloads still sync on their own when the app opens. 2026-11-06 iOS: crossfade, Off or 1-12 s in Settings > Listening. Equal-power fade, Sweet Fades skips it between songs of the same album. Seeking during a fade finishes it. +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). diff --git a/ios/Stemify/UI/Nav.swift b/ios/Stemify/UI/Nav.swift index d190d80..63286b9 100644 --- a/ios/Stemify/UI/Nav.swift +++ b/ios/Stemify/UI/Nav.swift @@ -51,6 +51,8 @@ final class Ui { var nowPlayingOpen = false { didSet { overlays.level = nowPlayingOpen ? 1 : 0 } } + /// True while an unfolded iPhone Duo shows Now Playing docked beside the library. + private(set) var split = false let overlays: Overlays init(overlays: Overlays) { self.overlays = overlays } @@ -84,7 +86,14 @@ final class Ui { if !tabs.contains(tab) { tab = tabs.first ?? .Settings } } - func openNowPlaying() { nowPlayingOpen = true } + /// Docked Now Playing is already on screen, so there's nothing to open while split. + func openNowPlaying() { if !split { nowPlayingOpen = true } } + + func setSplit(_ on: Bool) { + guard on != split else { return } + split = on + if on { nowPlayingOpen = false } + } /// Switches to the Library tab and pushes [r] there. func showInLibrary(_ r: Route) { diff --git a/ios/Stemify/UI/RootView.swift b/ios/Stemify/UI/RootView.swift index 6f030c4..34684b1 100644 --- a/ios/Stemify/UI/RootView.swift +++ b/ios/Stemify/UI/RootView.swift @@ -1,6 +1,7 @@ import SwiftUI /// 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. struct RootView: View { @State private var ui = Ui(overlays: Overlays()) private var g: AppGraph { .shared } @@ -8,6 +9,7 @@ struct RootView: View { var body: some View { let s = g.settings let p = Palette.of(s.theme, s.accent) + DuoSplit(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)) { @@ -20,6 +22,10 @@ struct RootView: View { } } .modifier(GlassMiniPlayerHost()) + } docked: { + NowPlayingScreen(docked: true) + } + .background(p.bg.ignoresSafeArea()) .onChange(of: s.tabLayout) { _, _ in ui.ensureValidTab() } .fullScreenCover(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) { NowPlayingScreen() @@ -91,11 +97,12 @@ struct RootView: View { private let glassAccessory: Bool = { if #available(iOS 26.1, *) { return true } else { return false } }() private struct GlassMiniPlayerHost: ViewModifier { + @Environment(Ui.self) private var ui private var g: AppGraph { .shared } func body(content: Content) -> some View { if #available(iOS 26.1, *) { content - .tabViewBottomAccessory(isEnabled: g.playback.current != nil) { MiniPlayer() } + .tabViewBottomAccessory(isEnabled: g.playback.current != nil && !ui.split) { MiniPlayer() } .tabBarMinimizeBehavior(.onScrollDown) } else { content @@ -105,9 +112,10 @@ private struct GlassMiniPlayerHost: ViewModifier { /// Floats over the screens; each screen leaves room for it so nothing ends up underneath. private struct FloatingMiniPlayer: ViewModifier { + @Environment(Ui.self) private var ui private var g: AppGraph { .shared } func body(content: Content) -> some View { - if glassAccessory { + if glassAccessory || ui.split { content } else { content @@ -120,10 +128,11 @@ private struct FloatingMiniPlayer: ViewModifier { } private struct MiniPlayerRoom: ViewModifier { + @Environment(Ui.self) private var ui private var g: AppGraph { .shared } func body(content: Content) -> some View { content.safeAreaInset(edge: .bottom, spacing: 0) { - if !glassAccessory && g.playback.current != nil { Color.clear.frame(height: 66) } + if !glassAccessory && !ui.split && g.playback.current != nil { Color.clear.frame(height: 66) } } } } @@ -131,3 +140,45 @@ private struct MiniPlayerRoom: ViewModifier { 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 { + var onSplit: (Bool) -> Void + @ViewBuilder var library: Library + @ViewBuilder var docked: Docked + + var body: some View { + GeometryReader { geo in + let cut = Self.cut(geo) + 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) + if let cut { + docked + .frame(width: cut.vertical ? cut.second : geo.size.width, + height: cut.vertical ? geo.size.height : cut.second) + } + } + .onChange(of: cut != nil, initial: true) { _, on in onSplit(on) } + } + } + + /// Where the hinge cuts the view: sizes of the two sides and the gap between them. + private struct Cut { var vertical: Bool; var first: CGFloat; var gap: CGFloat; var second: CGFloat } + + private static func cut(_ geo: GeometryProxy) -> Cut? { + guard #available(iOS 27.1, *), + let r = geo.reservedRegions(kind: .division).first(where: \.isActive)?.frame else { return nil } + let size = geo.size + let vertical = r.height >= r.width + let c = vertical + ? Cut(vertical: true, first: r.minX, gap: r.width, second: size.width - r.maxX) + : Cut(vertical: false, first: r.minY, gap: r.height, second: size.height - r.maxY) + // A region that doesn't leave usable room on both sides isn't a hinge worth splitting at. + return c.first > 200 && c.second > 200 ? c : nil + } +} diff --git a/ios/Stemify/UI/Screens/NowPlayingScreen.swift b/ios/Stemify/UI/Screens/NowPlayingScreen.swift index 4e90b2c..4fafe2a 100644 --- a/ios/Stemify/UI/Screens/NowPlayingScreen.swift +++ b/ios/Stemify/UI/Screens/NowPlayingScreen.swift @@ -86,7 +86,21 @@ private extension UIColor { // ---- now playing ---------------------------------------------------------------------------- +/// Full screen, Now Playing owns the window's color scheme. Docked beside the library it only +/// sets its own pane's, since preferredColorScheme would repaint the library side too. +private struct NpScheme: ViewModifier { + var dark: Bool + var docked: Bool + func body(content: Content) -> some View { + if docked { content.environment(\.colorScheme, dark ? .dark : .light) } + else { content.preferredColorScheme(dark ? .dark : .light) } + } +} + struct NowPlayingScreen: View { + /// Shown beside the library on an unfolded iPhone Duo rather than as a full-screen cover: + /// no grabber, no drag to close, and its color scheme stays inside the pane. + var docked = false @Environment(Ui.self) private var ui @Environment(\.palette) private var base @Environment(\.dismiss) private var dismiss @@ -134,17 +148,17 @@ struct NowPlayingScreen: View { .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) + if !docked { Capsule().fill(c.label3).frame(width: 36, height: 5) .frame(width: 120, height: 20).contentShape(Rectangle()) .onTapGesture { dismiss() } - .gesture(screenDrag) + .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) + .modifier(NpScheme(dark: c.dark, docked: docked)) .tint(c.label) .animation(.easeInOut(duration: 0.3), value: tint) .presentationBackground(.clear) @@ -259,12 +273,12 @@ struct NowPlayingScreen: View { DragGesture(minimumDistance: 20) .onChanged { v in let dy = v.translation.height - drop = dy > 0 && dy > abs(v.translation.width) ? dy : 0 + drop = !docked && 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 } + if !docked && 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 } } } diff --git a/ios/project.yml b/ios/project.yml index bc9bd6f..35de7b6 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -8,8 +8,8 @@ settings: SWIFT_VERSION: "5.0" # iOS needs a plain number for the bundle version; the label is what the app and releases show. MARKETING_VERSION: "1.0" - VERSION_LABEL: "1.0 RC 2" - CURRENT_PROJECT_VERSION: "22" + VERSION_LABEL: "1.0 RC 3" + CURRENT_PROJECT_VERSION: "23" targets: Stemify: type: application