Files
stemify/ios/Stemify/UI/Screens/SettingsScreens.swift
T

1094 lines
47 KiB
Swift

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<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()
}
}
/// 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
let label = info?["StemifyVersionLabel"] as? String ?? info?["CFBundleShortVersionString"] as? String ?? "1.0"
return "\(label) (\(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 tipTitle = "Buy Me a Coffee"
var tipIcon = "cup.and.saucer.fill"
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: "https://venmo.com/code?user_id=2910252785729536546&created=1792897467", tipTitle: "Venmo", tipIcon: "dollarsign.circle.fill"),
]
/// 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: m.tipTitle, value: nil, icon: m.tipIcon, 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 tip 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 ServerListScreen: View {
@Environment(\.palette) private var p
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: "Servers") } footer: {
Footer(text: "Every server's picked libraries are pooled into one library. Playlists stay on the server they were made on.")
}
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)
}
}
struct ServersScreen: View {
@Environment(\.palette) private var p
@Environment(\.openURL) private var openURL
@State private var libs: [String: [MusicSection]] = [:]
@State private var busy = false
@State private var status: String?
@State private var signInTask: Task<Void, Never>?
private var g: AppGraph { .shared }
var body: some View {
let list = g.settings.servers
List {
Section {
if busy {
HStack { ProgressView(); Text(status ?? "Working…").foregroundStyle(p.label2) }.themedRow()
} else {
ActionCell(title: "Sign in with Plex") { signIn() }
if !g.settings.plexAccountToken.isEmpty {
ActionCell(title: "Refresh Servers") { refresh(g.settings.plexAccountToken) }
}
if let status { Text(status).font(.footnote).foregroundStyle(p.label2).themedRow() }
}
} header: { Header(text: "Plex Account") } footer: {
Footer(text: "Sign in at plex.tv to add every server on your account and all of their music libraries at once.")
}
Section {
NavCell(title: "Music Servers", value: list.isEmpty ? "None" : "\(list.count)", icon: "server.rack", iconBg: 0xE5A00D, route: .ServerList)
} 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") }
}
}
.listStyle(.insetGrouped)
.themedList()
.navigationTitle("Plex Account")
.navigationBarTitleDisplayMode(.inline)
.task {
for s in list where s.signedIn && libs[s.id] == nil {
libs[s.id] = (try? await g.plex.make(s).musicSections()) ?? []
}
}
.onDisappear { signInTask?.cancel() }
}
/// plex.tv sign-in in the browser, the same flow as the web app.
private func signIn() {
signInTask?.cancel()
busy = true; status = "Waiting for plex.tv…"
signInTask = Task {
let tv = g.plex.plexTv()
do {
let pin = try await tv.createStrongPin()
guard let url = tv.authUrl(code: pin.code) else { throw URLError(.badURL) }
openURL(url)
for _ in 0..<150 {
try await Task.sleep(for: .seconds(2))
if let t = try? await tv.checkPin(pin.id).authToken, !t.isEmpty {
await sync(t); return
}
}
status = "Sign-in timed out."
} catch is CancellationError {
} catch {
status = "Sign-in failed: \(error.localizedDescription)"
}
busy = false
}
}
private func refresh(_ token: String) {
busy = true; status = "Finding servers…"
signInTask = Task { await sync(token) }
}
private func sync(_ token: String) async {
status = "Finding servers…"
do {
let r = try await g.plex.syncAccount(token: token)
g.settings.plexAccountToken = token
libs = [:]
status = r.saved == 0 && r.unreachable == 0 ? "No Plex servers on this account."
: "\(r.saved) server\(r.saved == 1 ? "" : "s") added or updated" + (r.unreachable > 0 ? ", \(r.unreachable) unreachable." : ".")
for s in g.settings.servers where s.signedIn {
libs[s.id] = (try? await g.plex.make(s).musicSections()) ?? []
}
} catch {
status = "Sync failed: \(error.localizedDescription)"
}
busy = false
}
/// 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: "stemd", value: g.stems.liveLine ?? (g.stems.failed.isEmpty ? "Idle" : "\(g.stems.failed.count) failed")).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)
}
.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: "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() } }
}
}
}
}