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: 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 { 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 : []) } }