104 lines
3.8 KiB
Swift
104 lines
3.8 KiB
Swift
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, 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 }
|
|
}
|
|
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 }
|
|
}
|
|
|
|
func openNowPlaying() { nowPlayingOpen = true }
|
|
|
|
/// 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) }
|
|
}
|
|
}
|