Files
stemify/ios/Stemify/UI/Screens/SettingsScreens.swift
T
cdnutterandClaude Opus 5.5 fdf9e970b2 Add iOS app with CarPlay and Siri
SwiftUI port of the Android app under ios/, with CarPlay browse and
Now Playing, Siri play-media intents, and a build-ipa.sh script that
makes an unsigned IPA for sideloading. README gets an iOS section and
the vibecoded disclaimer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:15:47 -07:00

539 lines
22 KiB
Swift

import SwiftUI
/// 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<Bool>
@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()
}
}
/// 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)
} 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 {
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")
}
}
// ---- 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)
}
}