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).foregroundStyle(p.label2) } } 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 } // ---- settings ------------------------------------------------------------------------------- struct SettingsScreen: View { private var g: AppGraph { .shared } var body: some View { @Bindable var s = g.settings let status = g.stems.status List { // Everything on one screen: no footers but the last, one line each. Section { NavCell(title: "Plex", value: s.plexToken.isEmpty ? "Not Signed In" : (s.musicSectionTitle.isEmpty ? s.plexUser : s.musicSectionTitle), icon: "server.rack", iconBg: 0xE5A00D, route: .PlexAccount) NavCell(title: "Stem Server", value: { switch status { case .online(let n, _): "\(n) songs" case .offline: "Offline" case .unknown: "…" } }(), icon: "waveform", iconBg: 0xAF52DE, route: .StemServer) } header: { Header(text: "Servers") } 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: "Stem Quality", value: s.stemQuality.label, icon: "gauge.with.dots.needle.67percent", iconBg: 0xFF2D55, route: .Quality) ToggleCell(title: "Keep Stem Mix Between Songs", icon: "speaker.wave.2.fill", iconBg: 0x5856D6, isOn: $s.keepStemMix) SoftVocalsCell() } header: { Header(text: "Sound") } Section { NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage) ToggleCell(title: "Download on Wi-Fi Only", icon: "arrow.down.circle.fill", iconBg: 0x30B0C7, isOn: $s.downloadsWifiOnly) } header: { Header(text: "Storage") } Section { BackupCells() } header: { Header(text: "Backup") } footer: { Footer(text: "Stem playlists, their mixes and sound settings. Tokens and server addresses are left out. Save to iCloud Drive from the Files sheet.") } Section { NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance) } header: { Header(text: "Display") } footer: { Footer(text: "Stemify \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.1") · stems by stemd on your server · \(UIScreen.main.maximumFramesPerSecond) Hz") } } .listStyle(.insetGrouped) .themedList() .navigationTitle("Settings") } } 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 ?? "") } } } // ---- plex ----------------------------------------------------------------------------------- struct PlexAccountScreen: View { @Environment(Ui.self) private var ui @Environment(\.palette) private var p @Environment(\.openURL) private var openURL @State private var url = AppGraph.shared.settings.plexUrl @State private var manual = "" @State private var pinCode: String? @State private var pinId: Int64 = 0 @State private var pinError: String? @State private var sections: [MusicSection]? @State private var testing = false private var g: AppGraph { .shared } var body: some View { let s = g.settings List { Section { FieldCell(label: "Address", text: $url, placeholder: "http://192.168.86.2:32400") ActionCell(title: testing ? "Testing…" : "Test Connection") { test() } } header: { Header(text: "Server") } footer: { Footer(text: "Your server's LAN address (port 32400).") } if s.plexToken.isEmpty { 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 = pinError ?? (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) { useToken() } } header: { Header(text: "Or Paste an X-Plex-Token") } } else { Section { HStack(spacing: 14) { SettingsIcon(symbol: "person.fill", bg: 0xE5A00D) Text(s.plexUser.isEmpty ? "Signed In" : s.plexUser).foregroundStyle(p.label) } .themedRow() } header: { Header(text: "Account") } Section { if let list = sections { if list.isEmpty { Text("No music libraries found").foregroundStyle(p.label).themedRow() } ForEach(list) { sec in Button { s.musicSection = sec.key s.musicSectionTitle = sec.title } label: { HStack { VStack(alignment: .leading, spacing: 2) { Text(sec.title).foregroundStyle(p.label) if !sec.locations.isEmpty { Text(sec.locations.joined(separator: ", ")).font(.footnote).foregroundStyle(p.label2).lineLimit(2) } } Spacer() if sec.key == s.musicSection { Image(systemName: "checkmark").font(.body.weight(.semibold)).foregroundStyle(p.accent) } } .contentShape(Rectangle()) } .buttonStyle(.plain) .themedRow() } } else { ProgressView().frame(maxWidth: .infinity).themedRow() } } header: { Header(text: "Music Library") } Section { ActionCell(title: "Sign Out", color: p.red, centered: true) { ui.overlays.confirm("Sign Out of Plex?", "Downloads stay on this phone.", "Sign Out") { s.plexToken = "" s.plexUser = "" s.musicSection = "" s.musicSectionTitle = "" sections = nil } } } } } .listStyle(.insetGrouped) .themedList() .navigationTitle("Plex") .navigationBarTitleDisplayMode(.inline) .task(id: s.plexToken) { if !s.plexToken.isEmpty && sections == nil { sections = try? await g.plex.musicSections() } } // 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.checkPin(pinId))?.authToken, !t.isEmpty { pinCode = nil pinId = 0 await finish(t) ui.overlays.hud("Signed In") return } } pinCode = nil pinId = 0 pinError = "The code expired. Try again." } } private func test() { let s = g.settings testing = true Task { let ok = await g.plex.probe(url, token: s.plexToken) testing = false if ok { s.plexUrl = clean(url) ui.overlays.hud("Connected") } else { ui.overlays.hud(s.plexToken.isEmpty ? "Sign in first" : "Couldn't reach Plex at that address", "exclamationmark.triangle") } } } private func newPin() { pinError = nil Task { do { let pin = try await g.plex.createPin() pinCode = pin.code pinId = pin.id } catch { pinError = "Couldn't reach plex.tv: \(error.localizedDescription)" } } } private func useToken() { let t = manual.trimmingCharacters(in: .whitespaces) Task { if await g.plex.probe(url, token: t) { await finish(t) } else { ui.overlays.hud("That token didn't work on this server", "exclamationmark.triangle") } } } private func finish(_ token: String) async { let s = g.settings s.plexUrl = clean(url) s.plexToken = token s.plexUser = await { guard let u = try? await g.plex.user(token: token) else { return "Plex user" } return u.title ?? u.username ?? "Plex user" }() s.plexMachineId = (try? await g.plex.machineId()) ?? "" sections = try? await g.plex.musicSections() if let only = sections, only.count == 1 { s.musicSection = only[0].key s.musicSectionTitle = only[0].title } 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.") } 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.selectTab(.Downloads) } 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.") } } .listStyle(.insetGrouped) .themedList() .navigationTitle("Appearance") .navigationBarTitleDisplayMode(.inline) } }