import SwiftUI enum Tab: String, CaseIterable, Hashable { case Library, Songs, Albums, Artists, Genres, Playlists, Search, Downloads, Settings static let defaultLayout: [Tab] = [.Library, .Playlists, .Search, .Downloads, .Settings] /// iOS folds anything past five tabs into "More", so the bar holds at most this many. static let maxVisible = 5 /// Parses the saved comma list; Settings is always kept so the app can't lock itself out. static func layout(_ raw: String) -> [Tab] { var out: [Tab] = [] for part in raw.split(separator: ",") { if let t = Tab(rawValue: String(part)), !out.contains(t) { out.append(t) } } if out.isEmpty { return defaultLayout } if !out.contains(.Settings) { out = Array(out.prefix(maxVisible - 1)) + [.Settings] } return Array(out.prefix(maxVisible)) } static func encode(_ tabs: [Tab]) -> String { tabs.map(\.rawValue).joined(separator: ",") } var icon: String { switch self { case .Library: "square.stack.fill" case .Songs: "music.note" case .Albums: "square.stack" case .Artists: "music.mic" case .Genres: "guitars.fill" case .Playlists: "music.note.list" case .Search: "magnifyingglass" case .Downloads: "arrow.down.circle.fill" case .Settings: "gearshape.fill" } } } enum Route: Hashable { case Artists, Albums, Songs, RecentlyAdded, Playlists, Genres, Downloads, DownloadQueue, TabBar, WebRadio case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String) case Servers, ServerList, Server(String), StemServer, Quality, Storage, Appearance, NowPlayingOptions, StemEditor, About case GenrePage(String), ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String) } /// Tabs, their stacks and the Now Playing sheet: the things any screen can ask of the shell. @MainActor @Observable final class Ui { var tab: Tab = .Library var paths: [Tab: [Route]] = [:] 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 } func path(_ t: Tab) -> Binding<[Route]> { Binding(get: { self.paths[t] ?? [] }, set: { self.paths[t] = $0 }) } func push(_ r: Route) { paths[tab, default: []].append(r) } func pop() { if paths[tab]?.isEmpty == false { paths[tab]?.removeLast() } } /// Tapping the selected tab again pops it to its root. func selectTab(_ t: Tab) { if t == tab { paths[t] = [] } else { tab = t } } /// The tabs currently in the bar, in order. var tabs: [Tab] { Tab.layout(AppGraph.shared.settings.tabLayout) } /// Where library pages open: Library if it's in the bar, else the first tab that is. private var home: Tab { tabs.contains(.Library) ? .Library : (tabs.first ?? .Settings) } /// Downloads as its own tab, or pushed onto the current one when it's hidden. func openDownloads() { if tabs.contains(.Downloads) { selectTab(.Downloads) } else { push(.Downloads) } } /// Falls back to the first tab when the selected one was just removed from the bar. func ensureValidTab() { if !tabs.contains(tab) { tab = tabs.first ?? .Settings } } /// 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) { nowPlayingOpen = false let t = home tab = t paths[t, default: []].append(r) } /// Switches to Settings and opens [r] (the Equalizer) there. func showInSettings(_ r: Route) { nowPlayingOpen = false tab = .Settings if paths[.Settings]?.last != r { paths[.Settings, default: []].append(r) } } }