iOS: iPhone Duo split layout at the hinge, 1.0 RC 3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-11-09 07:57:58 -08:00
1 parent cf5f992e60
commit db55b5eea0
5 files changed
+86 -11

No files matched your search

+1
View File
@@ -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).
+10 -1
View File
@@ -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) {
+54 -3
View File
@@ -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<Library: View, Docked: View>: 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
}
}
+19 -5
View File
@@ -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 } }
}
+2 -2
View File
@@ -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