import SwiftUI import UniformTypeIdentifiers /// A Settings.app row that pushes [route]: icon, title, grey value, chevron. private struct NavCell: View { let title: String var value: String? var icon: String? var iconBg: UInt32 = 0x8E8E93 let route: Route var body: some View { NavigationLink(value: route) { ValueCell(title: title, value: value, icon: icon, iconBg: iconBg) }.themedRow() } } private struct ToggleCell: View { let title: String let icon: String let iconBg: UInt32 let isOn: Binding @Environment(\.palette) private var p var body: some View { Toggle(isOn: isOn) { HStack(spacing: 14) { SettingsIcon(symbol: icon, bg: iconBg) Text(title).foregroundStyle(p.label) } } .tint(Color(hex: 0x34C759)) .themedRow() } } /// How quiet karaoke's Soft vocals are. private struct SoftVocalsCell: View { @Environment(\.palette) private var p private var g: AppGraph { .shared } var body: some View { let db = g.settings.karaokeSoftDb VStack(alignment: .leading, spacing: 8) { HStack(spacing: 14) { SettingsIcon(symbol: "music.mic", bg: 0xFA2D48) Text("Karaoke Soft Vocals").foregroundStyle(p.label) Spacer() Text("\(db) dB").monospacedDigit().foregroundStyle(p.label2) } Slider(value: Binding(get: { Double(db) }, set: { g.playback.setKaraokeSoftDb(Int($0.rounded())) }), in: -30...(-3), step: 1) { Text("Karaoke Soft Vocals") } minimumValueLabel: { Text("Quieter").font(.caption).foregroundStyle(p.label2) } maximumValueLabel: { Text("Louder").font(.caption).foregroundStyle(p.label2) } } .padding(.vertical, 4) .themedRow() } } /// A tappable row in the accent (or red), optionally centred. private struct ActionCell: View { let title: String var color: Color? var centered = false var enabled = true let action: () -> Void @Environment(\.palette) private var p var body: some View { Button(action: action) { Text(title).foregroundStyle(enabled ? color ?? p.accent : p.label3) .frame(maxWidth: .infinity, alignment: centered ? .center : .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(!enabled) .themedRow() } } /// Label on the left, an inline text field on the right. private struct FieldCell: View { let label: String @Binding var text: String let placeholder: String var secret = false @Environment(\.palette) private var p var body: some View { HStack { Text(label).foregroundStyle(p.label).frame(width: 96, alignment: .leading) Group { if secret { SecureField(placeholder, text: $text) } else { TextField(placeholder, text: $text).keyboardType(.URL) } } .multilineTextAlignment(.trailing) .foregroundStyle(p.label2) .textInputAutocapitalization(.never) .autocorrectionDisabled() } .themedRow() } } private struct Header: View { let text: String @Environment(\.palette) private var p var body: some View { Text(text).font(.subheadline.weight(.semibold)).foregroundStyle(p.label2).textCase(nil) } } private struct Footer: View { let text: String @Environment(\.palette) private var p var body: some View { Text(text).foregroundStyle(p.label2) } } private func clean(_ url: String) -> String { var u = url.trimmingCharacters(in: .whitespaces) while u.hasSuffix("/") { u.removeLast() } return u } /// Four little faders in the stem colours: the Settings mark. private struct StemMark: View { var scale: CGFloat = 1 var body: some View { HStack(alignment: .bottom, spacing: 3 * scale) { ForEach(Array(zip(["vocals", "drums", "bass", "other"], [18.0, 26, 14, 22])), id: \.0) { name, h in Capsule().fill(Color(hex: Stems.color(name))).frame(width: 5 * scale, height: h * scale) } } } } /// One stop on the way from the servers to the phone. The rail runs through the dot and /// joins the rows above and below, so the three read as one path. private struct PathStop: View { let title: String let detail: String let color: UInt32 let on: Bool var first = false var last = false let route: Route @Environment(\.palette) private var p var body: some View { NavigationLink(value: route) { HStack(spacing: 14) { ZStack { VStack(spacing: 0) { Rectangle().fill(first ? Color.clear : p.separator).frame(width: 2) Rectangle().fill(last ? Color.clear : p.separator).frame(width: 2) } Circle().fill(on ? Color(hex: color) : p.cell) .overlay(Circle().strokeBorder(on ? Color.clear : p.label3, lineWidth: 2)) .frame(width: 13, height: 13) } .frame(width: 30) .frame(maxHeight: .infinity) VStack(alignment: .leading, spacing: 2) { Text(title).font(.body.weight(.semibold)).foregroundStyle(p.label) Text(detail).font(.subheadline).foregroundStyle(on ? p.label2 : p.label3).lineLimit(1) } } .frame(minHeight: 64) } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) .listRowSeparator(.hidden) .themedRow() } } private let appVersion: String = { let info = Bundle.main.infoDictionary return "\(info?["CFBundleShortVersionString"] as? String ?? "1.1") (\(info?["CFBundleVersion"] as? String ?? "1"))" }() // ---- settings ------------------------------------------------------------------------------- struct SettingsScreen: View { @Environment(\.palette) private var p private var g: AppGraph { .shared } var body: some View { @Bindable var s = g.settings let status = g.stems.status List { // The path music takes: servers, then stems, then this phone. Section { HStack(alignment: .bottom, spacing: 12) { StemMark() Text("Stemify").font(.system(size: 30, weight: .heavy, design: .rounded)).foregroundStyle(p.label) Spacer() Text(appVersion).font(.footnote.monospacedDigit()).foregroundStyle(p.label3).padding(.bottom, 4) } .padding(.top, 14).padding(.bottom, 6) .listRowSeparator(.hidden) .themedRow() PathStop(title: "Music Servers", detail: musicDetail(s), color: Stems.color("vocals"), on: s.libraryReady, first: true, route: .Servers) PathStop(title: "Stem Server", detail: stemDetail(status), color: Stems.color("drums"), on: status.isOnline, route: .StemServer) PathStop(title: "This \(UIDevice.current.model)", detail: "\(gb(g.cache.usage.cachedBytes)) cached" + (s.downloadsWifiOnly ? ", downloads on Wi-Fi" : ""), color: Stems.color("bass"), on: true, last: true, route: .Storage) } Section { NavCell(title: "Stem Editor", value: s.lockedPreset.isEmpty ? nil : "Locked", icon: "slider.horizontal.3", iconBg: 0xAF52DE, route: .StemEditor) NavCell(title: "Stem Quality", value: s.stemQuality.label, icon: "gauge.with.dots.needle.67percent", iconBg: 0xFF2D55, route: .Quality) ToggleCell(title: "Keep Mix Between Songs", icon: "speaker.wave.2.fill", iconBg: 0x5856D6, isOn: $s.keepStemMix) SoftVocalsCell() } header: { Header(text: "Listening") } Section { NavCell(title: "Equalizer", value: !g.eq.state.enabled ? "Off" : g.eq.active?.name ?? "On", icon: "slider.vertical.3", iconBg: 0x34C759, route: .Eq) NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance) } header: { Header(text: "Sound and look") } Section { StepperCell(title: "Cache Ahead", value: $s.precacheCount, icon: "arrow.down.to.line", iconBg: 0xFF9500) ToggleCell(title: "Download on Wi-Fi Only", icon: "wifi", iconBg: 0x30B0C7, isOn: $s.downloadsWifiOnly) BackupCells() } header: { Header(text: "On this phone") } footer: { Footer(text: "Export saves your stem playlists, their mixes and your sound settings to a file. Server addresses and sign-ins stay out of it.") } Section { NavCell(title: "About", value: nil, icon: "info.circle.fill", iconBg: 0x8E8E93, route: .About) } Section { DebugCells() } header: { Header(text: "Troubleshooting") } footer: { Footer(text: "Verbose logging adds playback and network detail. Sign-in tokens are stripped from the log.") } Section { VStack(spacing: 8) { StemMark(scale: 0.6).opacity(0.8) Text("Stemify \(appVersion)").font(.footnote.weight(.medium)).foregroundStyle(p.label2) Text("Made by Chris Nutter and Kevin Viers").font(.footnote).foregroundStyle(p.label3) } .frame(maxWidth: .infinity) .padding(.vertical, 8) .listRowBackground(Color.clear) } } .listStyle(.insetGrouped) .listSectionSpacing(22) .themedList() .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) } private func musicDetail(_ s: Settings) -> String { let ready = s.servers.filter(\.ready) if s.servers.isEmpty { return "Add a Plex or Jellyfin server" } if ready.isEmpty { return "No libraries switched on" } return ready.map(\.label).joined(separator: ", ") } private func stemDetail(_ st: StemServerStatus) -> String { switch st { case .online(let n, _): "\(n.formatted()) songs with stems" case .offline(_, let cached): cached > 0 ? "Can't reach it, \(cached.formatted()) songs saved" : "Can't reach it" case .unknown: "Checking…" } } } // ---- about ---------------------------------------------------------------------------------- private struct Maintainer: Identifiable { let name: String let role: String let avatar: String let color: UInt32 let site: (title: String, icon: String, value: String, url: String) let coffee: String? var id: String { name } } private let maintainers = [ Maintainer(name: "Chris Nutter", role: "Stemify WebUI and the iOS app", avatar: "AvatarChris", color: Stems.color("vocals"), site: ("Website", "globe", "iamchrisama.com", "https://iamchrisama.com"), coffee: "https://buymeacoffee.com/cdnutter"), Maintainer(name: "Kevin Viers", role: "stemd and the Android app", avatar: "AvatarKevin", color: Stems.color("drums"), site: ("GitHub", "chevron.left.forwardslash.chevron.right", "kevinViers", "https://github.com/kevinViers/"), coffee: nil), ] /// A maintainer's picture in a ring of their stem colour, their name and what they look after. private struct MaintainerCard: View { let m: Maintainer @Environment(\.palette) private var p var body: some View { HStack(spacing: 16) { Image(m.avatar).resizable().interpolation(.high).aspectRatio(contentMode: .fill) .frame(width: 60, height: 60) .clipShape(Circle()) .padding(3) .overlay(Circle().strokeBorder(Color(hex: m.color), lineWidth: 2.5)) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 3) { Text(m.name).font(.title3.weight(.bold)).foregroundStyle(p.label) Text(m.role).font(.subheadline).foregroundStyle(p.label2) } Spacer(minLength: 0) } .padding(.vertical, 8) .accessibilityElement(children: .combine) .themedRow() } } struct AboutScreen: View { @Environment(\.palette) private var p var body: some View { List { Section { VStack(spacing: 10) { StemMark(scale: 1.5) Text("Stemify").font(.system(size: 34, weight: .heavy, design: .rounded)).foregroundStyle(p.label) Text("Version \(appVersion)").font(.footnote.monospacedDigit()).foregroundStyle(p.label3) Text("Your Plex and Jellyfin music, split into stems you can mix.") .font(.subheadline).foregroundStyle(p.label2) .multilineTextAlignment(.center) .padding(.top, 4) } .frame(maxWidth: .infinity) .padding(.vertical, 12) .listRowBackground(Color.clear) } ForEach(maintainers) { m in Section { MaintainerCard(m: m) Link(destination: URL(string: m.site.url)!) { ValueCell(title: m.site.title, value: m.site.value, icon: m.site.icon, iconBg: 0x007AFF) } .themedRow() if let coffee = m.coffee { Link(destination: URL(string: coffee)!) { ValueCell(title: "Buy Me a Coffee", value: nil, icon: "cup.and.saucer.fill", iconBg: 0xFF9500) } .themedRow() } else { HStack(spacing: 14) { SettingsIcon(symbol: "cup.and.saucer.fill", bg: 0x8E8E93) Text("Buy Me a Coffee").foregroundStyle(p.label3) Spacer(minLength: 8) Text("Coming soon").foregroundStyle(p.label3) } .accessibilityElement(children: .combine) .themedRow() } } } Section { } footer: { Footer(text: "Stemify is free. A coffee goes straight to the person you pick.") .frame(maxWidth: .infinity) .multilineTextAlignment(.center) } } .listStyle(.insetGrouped) .listSectionSpacing(22) .themedList() .navigationTitle("About") .navigationBarTitleDisplayMode(.inline) } } struct BackupCells: View { private var g: AppGraph { .shared } @State private var doc = ExportDocument() @State private var exporting = false @State private var importing = false @State private var message: String? @Environment(\.palette) private var p var body: some View { Button { do { doc = ExportDocument(data: try StemifyExport.capture(g.stemPlaylists, g.settings).encoded()); exporting = true } catch { message = error.localizedDescription } } label: { ValueCell(title: "Export", value: "\(g.stemPlaylists.lists.count) playlists", icon: "square.and.arrow.up", iconBg: 0x007AFF) } .themedRow() Button { importing = true } label: { ValueCell(title: "Import", value: nil, icon: "square.and.arrow.down", iconBg: 0x34C759) } .themedRow() .fileExporter(isPresented: $exporting, document: doc, contentType: .json, defaultFilename: "Stemify \(Date().formatted(.iso8601.year().month().day()))") { r in if case .failure(let e) = r { message = e.localizedDescription } } .fileImporter(isPresented: $importing, allowedContentTypes: [.json]) { r in do { let url = try r.get() let scoped = url.startAccessingSecurityScopedResource() defer { if scoped { url.stopAccessingSecurityScopedResource() } } let x = try StemifyExport.decode(Data(contentsOf: url)) let n = g.stemPlaylists.merge(x.playlists) x.apply(to: g.settings) message = "Imported \(n) playlist\(n == 1 ? "" : "s") and your sound settings." } catch { message = error.localizedDescription } } .alert("Backup", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) { Button("OK", role: .cancel) {} } message: { Text(message ?? "") } } } // ---- music servers -------------------------------------------------------------------------- /// Said on both server screens: stems are found by file path, so libraries have to line up. private let matchNote = "For stems to work, every Plex or Jellyfin music library here has to match a stem library one-to-one: the same folders with the same files, as your stem server sees them. Stems are found by each song's file path, so a library with no matching stem folder plays without stems, and songs that are only on one side don't get stems at all." struct ServersScreen: View { @Environment(\.palette) private var p @State private var libs: [String: [MusicSection]] = [:] private var g: AppGraph { .shared } var body: some View { let list = g.settings.servers List { Section { if list.isEmpty { Text("No servers yet").foregroundStyle(p.label2).themedRow() } ForEach(list) { s in NavCell(title: s.label, value: s.ready ? s.sectionTitles : (s.signedIn ? "No Libraries" : "Not Signed In"), icon: "server.rack", iconBg: s.kind == .plex ? 0xE5A00D : 0xAA5CC3, route: .Server(s.id)) } } header: { Header(text: "Music Servers") } footer: { Footer(text: "Every server's picked libraries are pooled into one library. Playlists stay on the server they were made on.") } ForEach(list.filter(\.signedIn)) { s in Section { if let secs = libs[s.id] { if secs.isEmpty { Text("No music libraries").foregroundStyle(p.label2).themedRow() } ForEach(secs) { sec in ToggleCell(title: sec.title, icon: "music.note.house", iconBg: s.kind == .plex ? 0xE5A00D : 0xAA5CC3, isOn: Binding(get: { s.sections.contains(sec.key) }, set: { on in set(s, sec, on, secs) })) } } else { ProgressView().frame(maxWidth: .infinity).themedRow() } } header: { Header(text: s.label + " Libraries") } } Section { NavCell(title: "Add Plex Server", icon: "plus", iconBg: 0xE5A00D, route: .Server("new-plex")) NavCell(title: "Add Jellyfin Server", icon: "plus", iconBg: 0xAA5CC3, route: .Server("new-jellyfin")) } header: { Header(text: "Add") } footer: { Footer(text: matchNote) } } .listStyle(.insetGrouped) .themedList() .navigationTitle("Music Servers") .navigationBarTitleDisplayMode(.inline) .task { for s in list where s.signedIn && libs[s.id] == nil { libs[s.id] = (try? await g.plex.make(s).musicSections()) ?? [] } } } /// Turns one library of a server on or off. All off keeps the sign-in. private func set(_ s: MediaServer, _ sec: MusicSection, _ on: Bool, _ secs: [MusicSection]) { var srv = s if on { if !srv.sections.contains(sec.key) { srv.sections.append(sec.key) } } else { srv.sections.removeAll { $0 == sec.key } } srv.sectionTitles = srv.sections.compactMap { k in secs.first { $0.key == k }?.title }.joined(separator: " + ") g.settings.save(srv) } } /// One Plex or Jellyfin server: address and sign-in. Libraries are switched on in Music Servers. struct ServerScreen: View { @Environment(Ui.self) private var ui @Environment(\.palette) private var p @Environment(\.openURL) private var openURL @State private var draft: MediaServer @State private var manual = "" @State private var user = "" @State private var password = "" @State private var pinCode: String? @State private var pinId: Int64 = 0 @State private var signError: String? @State private var testing = false private var g: AppGraph { .shared } init(id: String) { let s = AppGraph.shared.settings if let saved = s.servers.first(where: { $0.id == id }) { _draft = State(initialValue: saved) } else { _draft = State(initialValue: MediaServer(id: s.nextServerId(), kind: id == "new-jellyfin" ? .jellyfin : .plex)) } } private var plex: Bool { draft.kind == .plex } var body: some View { List { Section { FieldCell(label: "Name", text: $draft.name, placeholder: draft.kindLabel) FieldCell(label: "Address", text: $draft.url, placeholder: plex ? "http://192.168.86.2:32400" : "http://192.168.86.2:8096") ActionCell(title: testing ? "Testing…" : "Test Connection") { test() } } header: { Header(text: draft.kindLabel + " Server") } footer: { Footer(text: "The server's LAN address (port \(plex ? "32400" : "8096")). Add each server or IP as its own entry.") } if !draft.signedIn { if plex { plexSignIn } else { jellyfinSignIn } } else { Section { ActionCell(title: "Remove Server", color: p.red, centered: true) { ui.overlays.confirm("Remove \(draft.label)?", "Downloads stay on this phone.", "Remove") { g.settings.removeServer(draft.id) ui.pop() } } } } } .listStyle(.insetGrouped) .themedList() .navigationTitle(draft.label) .navigationBarTitleDisplayMode(.inline) .onChange(of: draft.name) { persist() } // Poll plex.tv while a PIN is showing. .task(id: pinId) { guard pinId != 0 else { return } for _ in 0..<300 { try? await Task.sleep(nanoseconds: 2_000_000_000) if Task.isCancelled { return } if let t = (try? await g.plex.plexTv().checkPin(pinId))?.authToken, !t.isEmpty { pinCode = nil pinId = 0 await finishPlex(t) return } } pinCode = nil pinId = 0 signError = "The code expired. Try again." } } @ViewBuilder private var plexSignIn: some View { Section { ActionCell(title: pinCode == nil ? "Sign In with plex.tv" : "Get a New Code") { newPin() } if let pinCode { VStack(spacing: 0) { Text(pinCode).font(.system(size: 44, weight: .bold)).tracking(8).foregroundStyle(p.label).textSelection(.enabled) HStack(spacing: 8) { ProgressView().controlSize(.small) Text("Waiting for plex.tv/link…").font(.footnote).foregroundStyle(p.label2) } .padding(.top, 10) Button { openURL(URL(string: "https://plex.tv/link")!) } label: { Text("Open plex.tv/link").font(.headline).foregroundStyle(.white) .padding(.horizontal, 18).padding(.vertical, 9).background(p.accent, in: Capsule()) } .buttonStyle(.plain) .padding(.top, 14) } .frame(maxWidth: .infinity) .padding(.vertical, 18) .themedRow() } } header: { Header(text: "Sign In") } footer: { if let f = signError ?? (pinCode == nil ? "Enter the code at plex.tv/link while signed in to Plex." : nil) { Footer(text: f) } } Section { FieldCell(label: "Token", text: $manual, placeholder: "Required", secret: true) ActionCell(title: "Use Token", enabled: !manual.trimmingCharacters(in: .whitespaces).isEmpty) { usePlexToken() } } header: { Header(text: "Or Paste an X-Plex-Token") } } @ViewBuilder private var jellyfinSignIn: some View { Section { FieldCell(label: "Username", text: $user, placeholder: "Required") FieldCell(label: "Password", text: $password, placeholder: "Optional", secret: true) ActionCell(title: "Sign In", enabled: !user.trimmingCharacters(in: .whitespaces).isEmpty) { jellyfinSignIn { try await $0.login(user: user.trimmingCharacters(in: .whitespaces), password: password) } } } header: { Header(text: "Sign In") } footer: { if let signError { Footer(text: signError) } } Section { FieldCell(label: "API Key", text: $manual, placeholder: "Required", secret: true) ActionCell(title: "Use API Key", enabled: !manual.trimmingCharacters(in: .whitespaces).isEmpty) { jellyfinSignIn { try await $0.loginWithKey(manual.trimmingCharacters(in: .whitespaces)) } } } header: { Header(text: "Or Use an API Key") } footer: { Footer(text: "Dashboard → API Keys on the Jellyfin server. It signs in as the first admin user.") } } /// Saves the draft once it's signed in; before that there's nothing worth keeping. private func persist() { if draft.signedIn { g.settings.save(draft) } } private func test() { testing = true draft.url = clean(draft.url) Task { let ok = plex ? await g.plex.plexTv(draft.url).probe(draft.url, token: draft.token) : await g.plex.jellyfin(draft).probe() testing = false if ok { persist() ui.overlays.hud("Connected") } else { ui.overlays.hud(plex && !draft.signedIn ? "Sign in first" : "Couldn't reach \(draft.kindLabel) at that address", "exclamationmark.triangle") } } } private func newPin() { signError = nil Task { do { let pin = try await g.plex.plexTv().createPin() pinCode = pin.code pinId = pin.id } catch { signError = "Couldn't reach plex.tv: \(error.localizedDescription)" } } } private func usePlexToken() { let t = manual.trimmingCharacters(in: .whitespaces) draft.url = clean(draft.url) Task { if await g.plex.plexTv(draft.url).probe(draft.url, token: t) { await finishPlex(t) } else { ui.overlays.hud("That token didn't work on this server", "exclamationmark.triangle") } } } private func finishPlex(_ token: String) async { draft.url = clean(draft.url) let tv = g.plex.plexTv(draft.url) draft.user = await { guard let u = try? await tv.user(token: token) else { return "Plex user" } return u.title ?? u.username ?? "Plex user" }() draft.machineId = (try? await tv.machineId()) ?? "" draft.token = token await finish() } private func jellyfinSignIn(_ f: @escaping (JellyfinClient) async throws -> JellyfinLogin) { signError = nil draft.url = clean(draft.url) Task { do { let l = try await f(g.plex.jellyfin(draft)) draft.user = l.user draft.userId = l.userId draft.machineId = l.serverId draft.token = l.token password = "" await finish() } catch { signError = "Couldn't sign in: \(error.localizedDescription)" } } } /// Signed in: pick the only library if there's just one, and save. The rest are switched on /// in Music Servers. private func finish() async { manual = "" if let only = try? await g.plex.make(draft).musicSections(), only.count == 1 { draft.sections = [only[0].key] draft.sectionTitles = only[0].title } persist() ui.overlays.hud("Signed In") await g.stems.refresh() } } // ---- stem server ---------------------------------------------------------------------------- struct StemServerScreen: View { @Environment(Ui.self) private var ui @Environment(\.palette) private var p @State private var url = AppGraph.shared.settings.stemUrl @State private var token = AppGraph.shared.settings.stemToken @State private var busy = false private var g: AppGraph { .shared } var body: some View { let status = g.stems.status List { Section { FieldCell(label: "Address", text: $url, placeholder: "http://192.168.86.2:8765") FieldCell(label: "Token", text: $token, placeholder: "Optional", secret: true) ActionCell(title: busy ? "Connecting…" : "Save & Connect") { connect() } } header: { Header(text: "stemserve") } footer: { Footer(text: "stemserve shares the Stems folder that stemd fills (port 8765). The app checks it for new stems every minute.\n\n" + matchNote) } Section { ValueCell(title: "Connection", value: { switch status { case .online: "Online" case .offline: "Offline" case .unknown: "Checking" } }()).themedRow() ValueCell(title: "Songs with Stems", value: { switch status { case .online(let n, _): "\(n)" case .offline(_, let n): "\(n) (cached)" case .unknown: "—" } }()).themedRow() ValueCell(title: "Opus Transcoding", value: { if case .online(_, let t) = status { return t ? "Available" : "No ffmpeg" } return "—" }()).themedRow() if case .offline(let reason, _) = status { VStack(alignment: .leading, spacing: 2) { Text("Error").foregroundStyle(p.label) Text(reason).font(.footnote).foregroundStyle(p.label2) } .themedRow() } } header: { Header(text: "Status") } } .listStyle(.insetGrouped) .themedList() .navigationTitle("Stem Server") .navigationBarTitleDisplayMode(.inline) } private func connect() { let s = g.settings s.stemUrl = clean(url) s.stemToken = token.trimmingCharacters(in: .whitespaces) busy = true Task { await g.stems.refresh(force: true) busy = false let ok = g.stems.status.isOnline ui.overlays.hud(ok ? "Connected" : "Couldn't connect", ok ? "checkmark" : "exclamationmark.triangle") } } } // ---- quality -------------------------------------------------------------------------------- /// A row with a title, grey detail and a checkmark when chosen. private struct CheckCell: View { let title: String var subtitle: String? let checked: Bool let action: () -> Void @Environment(\.palette) private var p var body: some View { Button(action: action) { HStack { VStack(alignment: .leading, spacing: 2) { Text(title).foregroundStyle(p.label) if let subtitle { Text(subtitle).font(.footnote).foregroundStyle(p.label2) } } Spacer() if checked { Image(systemName: "checkmark").font(.body.weight(.semibold)).foregroundStyle(p.accent) } } .contentShape(Rectangle()) } .buttonStyle(.plain) .themedRow() } } struct QualityScreen: View { private var g: AppGraph { .shared } var body: some View { let s = g.settings List { Section { ForEach(StemQuality.allCases) { q in CheckCell(title: q.label, subtitle: q.detail, checked: s.stemQuality == q) { s.stemQuality = q } } } footer: { Footer(text: "For streaming and new downloads. Opus is made once on the server and kept there; 256 kbps is about an eighth of the FLAC size.") } } .listStyle(.insetGrouped) .themedList() .navigationTitle("Stem Quality") .navigationBarTitleDisplayMode(.inline) } } // ---- storage -------------------------------------------------------------------------------- struct StorageScreen: View { @Environment(Ui.self) private var ui @Environment(\.palette) private var p private var g: AppGraph { .shared } private let limits = [2, 5, 10, 20, 50, 100] var body: some View { @Bindable var s = g.settings let usage = g.cache.usage List { Section { Button { ui.openDownloads() } label: { HStack { ValueCell(title: "Downloads", value: gb(usage.pinnedBytes)) Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label3) } .contentShape(Rectangle()) } .buttonStyle(.plain) .themedRow() ValueCell(title: "Streaming Cache", value: gb(max(0, usage.cachedBytes - usage.pinnedBytes))).themedRow() } header: { Header(text: "On This Phone") } Section { Picker("Limit", selection: $s.cacheLimitGb) { ForEach(limits, id: \.self) { Text("\($0) GB").tag($0) } } .pickerStyle(.segmented) .listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16)) .themedRow() } header: { Header(text: "Streaming Cache Limit") } footer: { Footer(text: "Streamed songs stay for replay and offline use, oldest first out. Downloads don't count.") } Section { ActionCell(title: "Clear Streaming Cache", color: p.red, centered: true) { ui.overlays.confirm("Clear Cache?", "Downloads are kept.", "Clear") { g.cache.clearUnpinned() } } } } .listStyle(.insetGrouped) .themedList() .navigationTitle("Storage") .navigationBarTitleDisplayMode(.inline) } } // ---- appearance ----------------------------------------------------------------------------- struct AppearanceScreen: View { @Environment(\.palette) private var c private var g: AppGraph { .shared } var body: some View { @Bindable var s = g.settings List { Section { HStack { ForEach(ThemeMode.allCases) { m in let p = Palette.of(m, s.accent) let on = m == s.theme Button { s.theme = m } label: { VStack(spacing: 8) { VStack(alignment: .leading, spacing: 6) { Capsule().fill(p.label).frame(width: 40, height: 6) ForEach(0..<3, id: \.self) { _ in RoundedRectangle(cornerRadius: 4).fill(p.cell).frame(height: 18) } Capsule().fill(p.accent).frame(width: 24, height: 6) Spacer(minLength: 0) } .padding(8) .frame(width: 78, height: 140) .background(p.groupedBg, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(on ? c.accent : c.separator, lineWidth: on ? 3 : 1)) Text(m.label).font(.subheadline.weight(on ? .semibold : .regular)).foregroundStyle(c.label) } } .buttonStyle(.plain) .frame(maxWidth: .infinity) } } .padding(.vertical, 8) .themedRow() } header: { Header(text: "Theme") } footer: { Footer(text: "Black is true black for OLED. No gradients anywhere, by design.") } Section { HStack { ForEach(Accent.allCases) { a in Button { s.accent = a } label: { Circle().fill(Color(hex: c.dark ? a.dark : a.light)).frame(width: 32, height: 32) .overlay(Circle().stroke(a == s.accent ? c.label : .clear, lineWidth: 3)) } .buttonStyle(.plain) .accessibilityLabel(a.label) .frame(maxWidth: .infinity) } } .padding(.vertical, 8) .themedRow() } header: { Header(text: "Accent") } Section { Toggle(isOn: $s.albumColorNowPlaying) { Text("Album Colour Background").foregroundStyle(c.label) } .tint(Color(hex: 0x34C759)) .themedRow() } header: { Header(text: "Now Playing") } footer: { Footer(text: "Paints the Now Playing screen in one solid colour taken from the album art.") } Section { Toggle(isOn: $s.acapella) { Text("Spell It “Acapella”").foregroundStyle(c.label) } .tint(Color(hex: 0x34C759)) .themedRow() } header: { Header(text: "Mix Names") } footer: { Footer(text: "Shows the A Cappella mix as Acapella in the mixer, CarPlay, the lock screen and widgets.") } Section { NavigationLink(value: Route.TabBar) { Text("Tab Bar").foregroundStyle(c.label) }.themedRow() NavigationLink(value: Route.NowPlayingOptions) { Text("Now Playing Buttons").foregroundStyle(c.label) }.themedRow() } } .listStyle(.insetGrouped) .themedList() .navigationTitle("Appearance") .navigationBarTitleDisplayMode(.inline) } } // ---- tab bar editor --------------------------------------------------------------------------- struct TabBarScreen: View { @Environment(\.palette) private var c @State private var shown: [Tab] = Tab.layout(AppGraph.shared.settings.tabLayout) private func commit() { g.settings.tabLayout = Tab.encode(shown) } private var g: AppGraph { .shared } var body: some View { let hidden = Tab.allCases.filter { !shown.contains($0) } List { Section { ForEach(shown, id: \.self) { t in HStack { Label(t.rawValue, systemImage: t.icon).foregroundStyle(c.label) if t == .Settings { Spacer(); Text("Always on").font(.footnote).foregroundStyle(c.label2) } } .themedRow() .deleteDisabled(t == .Settings) } .onMove { from, to in shown.move(fromOffsets: from, toOffset: to) commit() } .onDelete { idx in let gone = Set(idx.map { shown[$0] }).subtracting([.Settings]) shown.removeAll { gone.contains($0) } if shown.count < 2 { shown = Tab.defaultLayout } commit() } } header: { Header(text: "In the bar") } footer: { Footer(text: "Drag to reorder, swipe to remove. The bar holds \(Tab.maxVisible) tabs.") } if !hidden.isEmpty { Section { ForEach(hidden, id: \.self) { t in Button { if shown.count < Tab.maxVisible { shown.append(t); commit() } } label: { HStack { Label(t.rawValue, systemImage: t.icon).foregroundStyle(c.label) Spacer() Image(systemName: "plus.circle.fill").foregroundStyle(shown.count < Tab.maxVisible ? c.accent : c.label2) } } .disabled(shown.count >= Tab.maxVisible) .themedRow() } } header: { Header(text: "Available") } } Section { Button("Reset to default") { shown = Tab.defaultLayout; commit() }.themedRow() } } .environment(\.editMode, .constant(.active)) .listStyle(.insetGrouped) .themedList() .navigationTitle("Tab Bar") .navigationBarTitleDisplayMode(.inline) } } // ---- debug ---------------------------------------------------------------------------------- private struct DebugCells: View { @AppStorage(Log.verboseKey) private var verbose = false @State private var logURL: URL? @State private var viewing = false @Environment(\.palette) private var p var body: some View { Toggle(isOn: $verbose) { HStack(spacing: 14) { SettingsIcon(symbol: "ladybug.fill", bg: 0xFF9500) Text("Verbose Logging").foregroundStyle(p.label) } } .tint(Color(hex: 0x34C759)) .themedRow() Button { viewing = true } label: { Text("View Log").foregroundStyle(p.label) }.themedRow() if let logURL { ShareLink(item: logURL) { Text("Export Log").foregroundStyle(p.accent) }.themedRow() } else { Button { logURL = Log.shared.export() } label: { Text("Prepare Log for Export").foregroundStyle(p.accent) }.themedRow() } Button(role: .destructive) { Log.shared.clear(); logURL = nil } label: { Text("Clear Log") }.themedRow() .sheet(isPresented: $viewing) { LogViewer() } } } private struct LogViewer: View { @State private var text = Log.shared.text() @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { ScrollView { Text(text.isEmpty ? "Nothing logged yet." : text) .font(.system(size: 11, design: .monospaced)) .frame(maxWidth: .infinity, alignment: .leading) .padding(12) .textSelection(.enabled) } .defaultScrollAnchor(.bottom) .navigationTitle("Log") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } } ToolbarItem(placement: .primaryAction) { Button("Refresh") { text = Log.shared.text() } } } } } }