- DuoRail sits at the bottom of the strip, clear of the back buttons - Setting to turn off swipe up for Playing Next - Tighter Now Playing on iPhone Duo (folded and docked), regular phones unchanged - Bigger stacked stem faders in the Duo deck - Backup v2 restores song mixes, custom presets and stem playlist covers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
272 lines
12 KiB
Swift
272 lines
12 KiB
Swift
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 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(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)) {
|
|
root(t).miniPlayerRoom()
|
|
.navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() }
|
|
}
|
|
.modifier(FloatingMiniPlayer())
|
|
.tabItem { Label(t.rawValue, systemImage: t.icon) }
|
|
.tag(t)
|
|
}
|
|
}
|
|
.modifier(GlassMiniPlayerHost())
|
|
} deck: {
|
|
NowPlayingDeck()
|
|
} docked: {
|
|
NowPlayingScreen(docked: true)
|
|
} rail: {
|
|
DuoRail(swap: $duoSwap)
|
|
}
|
|
.background(p.bg.ignoresSafeArea())
|
|
.onChange(of: s.tabLayout) { _, _ in ui.ensureValidTab() }
|
|
.fullScreenCover(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
|
|
NowPlayingScreen()
|
|
.overlayHost(ui.overlays, level: 1)
|
|
.environment(ui)
|
|
.environment(\.palette, p)
|
|
.preferredColorScheme(s.theme == .White ? .light : .dark)
|
|
.tint(p.accent)
|
|
}
|
|
.overlayHost(ui.overlays, level: 0)
|
|
.onChange(of: g.playback.message) { _, m in
|
|
if let m { ui.overlays.hud(m.text, "info.circle") }
|
|
}
|
|
.environment(ui)
|
|
.environment(\.palette, p)
|
|
.preferredColorScheme(s.theme == .White ? .light : .dark)
|
|
.tint(p.accent)
|
|
}
|
|
|
|
@ViewBuilder private func root(_ t: Tab) -> some View {
|
|
switch t {
|
|
case .Library: LibraryHome()
|
|
case .Songs: SongsScreen()
|
|
case .Albums: AlbumsScreen()
|
|
case .Artists: ArtistsScreen()
|
|
case .Genres: GenresScreen()
|
|
case .Playlists: PlaylistsScreen()
|
|
case .Search: SearchScreen()
|
|
case .Downloads: DownloadsScreen()
|
|
case .Settings: SettingsScreen()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private func destination(_ r: Route) -> some View {
|
|
switch r {
|
|
case .Artists: ArtistsScreen()
|
|
case .Albums: AlbumsScreen()
|
|
case .Songs: SongsScreen()
|
|
case .RecentlyAdded: SongsScreen(recent: true)
|
|
case .Playlists: PlaylistsScreen()
|
|
case .Genres: GenresScreen()
|
|
case .Downloads: DownloadsScreen()
|
|
case .DownloadQueue: DownloadQueueScreen()
|
|
case .TabBar: TabBarScreen()
|
|
case .WebRadio: WebRadioScreen()
|
|
case .GenrePage(let g): GenreScreen(genre: g)
|
|
case .Eq: EqScreen()
|
|
case .EqFilters(let id): EqFiltersScreen(profileId: id)
|
|
case .EqFilterPage(let id, let f): EqFilterPage(profileId: id, filterId: f)
|
|
case .Servers: ServersScreen()
|
|
case .ServerList: ServerListScreen()
|
|
case .Server(let id): ServerScreen(id: id)
|
|
case .StemServer: StemServerScreen()
|
|
case .Quality: QualityScreen()
|
|
case .Storage: StorageScreen()
|
|
case .Appearance: AppearanceScreen()
|
|
case .NowPlayingOptions: NowPlayingOptionsScreen()
|
|
case .StemEditor: StemEditorScreen()
|
|
case .About: AboutScreen()
|
|
case .ArtistPage(let a): ArtistScreen(artist: a)
|
|
case .AlbumPage(let a): AlbumScreen(album: a)
|
|
case .PlaylistPage(let pl): PlaylistScreen(playlist: pl)
|
|
case .StemPlaylistPage(let id): StemPlaylistScreen(id: id)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// iOS 26.1 and up put the mini player in the tab bar's glass accessory; older ones float a card over each tab.
|
|
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 && !ui.split) { MiniPlayer() }
|
|
.tabBarMinimizeBehavior(.onScrollDown)
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 || ui.split {
|
|
content
|
|
} else {
|
|
content
|
|
.overlay(alignment: .bottom) {
|
|
if g.playback.current != nil { MiniPlayer().transition(.move(edge: .bottom).combined(with: .opacity)) }
|
|
}
|
|
.animation(.spring(duration: 0.35), value: g.playback.current != nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 && !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)
|
|
}
|
|
}
|
|
|
|
private extension View {
|
|
func miniPlayerRoom() -> some View { modifier(MiniPlayerRoom()) }
|
|
}
|
|
|
|
/// 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 {
|
|
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
|
|
.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) }
|
|
}
|
|
}
|
|
|
|
/// 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? {
|
|
let size = geo.size
|
|
var c: Cut?
|
|
// The hinge reports inactive while the phone lies flat, so ask for inactive regions too.
|
|
if #available(iOS 27.1, *),
|
|
let r = geo.reservedRegions(kind: .division, options: .includeInactive).first?.frame {
|
|
let vertical = r.height >= r.width
|
|
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)
|
|
} else if UIDevice.current.userInterfaceIdiom == .phone, size.width >= 760, size.height >= 560 {
|
|
// No region reported: only an unfolded Duo gives an iPhone this much room both ways.
|
|
// The hinge sits at the middle of the screen, not of the safe area, so place it from there.
|
|
let screenW = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.screen.bounds.width
|
|
let half = ((screenW ?? size.width) / 2 - geo.frame(in: .global).minX).rounded()
|
|
c = Cut(vertical: true, first: half - 12, gap: 24, second: size.width - half - 12)
|
|
}
|
|
guard let c else { return nil }
|
|
// 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
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
// Sits at the bottom of the strip: the top is where the panes' back buttons and the
|
|
// status bar live, and the tabs used to land right on them.
|
|
VStack(spacing: 4) {
|
|
Spacer(minLength: 160)
|
|
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() }
|
|
}
|
|
.padding(.bottom, 20)
|
|
.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 : [])
|
|
}
|
|
}
|