Stemify iOS 1.1 and WebUI updates, repo restructure into desktop/ios/server/web

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Sonnet 5.5 committed 2026-10-24 17:08:51 -07:00
1 parent 894dd5a695
commit df5d847169
157 files changed
+3935 -742

No files matched your search

+429 -148
View File
@@ -102,7 +102,7 @@ private struct FieldCell: View {
private struct Header: View {
let text: String
@Environment(\.palette) private var p
var body: some View { Text(text).foregroundStyle(p.label2) }
var body: some View { Text(text).font(.subheadline.weight(.semibold)).foregroundStyle(p.label2).textCase(nil) }
}
private struct Footer: View {
@@ -117,57 +117,246 @@ private func clean(_ url: String) -> String {
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 {
// Everything on one screen: no footers but the last, one line each.
// The path music takes: servers, then stems, then this phone.
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") }
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: "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") }
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: "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.")
} 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: "Debug") } footer: {
Footer(text: "Verbose adds playback and network detail. Tokens are removed from the log.")
} header: { Header(text: "Troubleshooting") } footer: {
Footer(text: "Verbose logging adds playback and network detail. Sign-in tokens are stripped from the log.")
}
Section {
NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance)
ToggleCell(title: "Only Show Songs With Stems", icon: "waveform", iconBg: 0xAF52DE, isOn: $s.stemsOnly)
} 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")
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)
}
}
@@ -207,103 +396,109 @@ struct BackupCells: View {
}
}
// ---- plex -----------------------------------------------------------------------------------
// ---- music servers --------------------------------------------------------------------------
struct PlexAccountScreen: View {
@Environment(Ui.self) private var ui
/// 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
@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
@State private var libs: [String: [MusicSection]] = [:]
private var g: AppGraph { .shared }
var body: some View {
let s = g.settings
let list = g.settings.servers
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) }
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 {
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 {
// Tapping adds or drops a library; the picked ones are pooled. One always stays.
var keys = s.musicSection.split(separator: ",").map(String.init)
if let i = keys.firstIndex(of: sec.key) { if keys.count > 1 { keys.remove(at: i) } } else { keys.append(sec.key) }
s.musicSection = keys.joined(separator: ",")
s.musicSectionTitle = keys.compactMap { k in list.first { $0.key == k }?.title }.joined(separator: " + ")
} 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 s.musicSection.split(separator: ",").contains(Substring(sec.key)) { Image(systemName: "checkmark").font(.body.weight(.semibold)).foregroundStyle(p.accent) }
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.themedRow()
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: "Music Libraries") } footer: { Footer(text: "Pick more than one to pool them into one library.") }
} 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: "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
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()
}
}
}
@@ -311,81 +506,157 @@ struct PlexAccountScreen: View {
}
.listStyle(.insetGrouped)
.themedList()
.navigationTitle("Plex")
.navigationTitle(draft.label)
.navigationBarTitleDisplayMode(.inline)
.task(id: s.plexToken) {
if !s.plexToken.isEmpty && sections == nil { sections = try? await g.plex.musicSections() }
}
.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.checkPin(pinId))?.authToken, !t.isEmpty {
if let t = (try? await g.plex.plexTv().checkPin(pinId))?.authToken, !t.isEmpty {
pinCode = nil
pinId = 0
await finish(t)
ui.overlays.hud("Signed In")
await finishPlex(t)
return
}
}
pinCode = nil
pinId = 0
pinError = "The code expired. Try again."
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() {
let s = g.settings
testing = true
draft.url = clean(draft.url)
Task {
let ok = await g.plex.probe(url, token: s.plexToken)
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 {
s.plexUrl = clean(url)
persist()
ui.overlays.hud("Connected")
} else {
ui.overlays.hud(s.plexToken.isEmpty ? "Sign in first" : "Couldn't reach Plex at that address", "exclamationmark.triangle")
ui.overlays.hud(plex && !draft.signedIn ? "Sign in first" : "Couldn't reach \(draft.kindLabel) at that address", "exclamationmark.triangle")
}
}
}
private func newPin() {
pinError = nil
signError = nil
Task {
do {
let pin = try await g.plex.createPin()
let pin = try await g.plex.plexTv().createPin()
pinCode = pin.code
pinId = pin.id
} catch {
pinError = "Couldn't reach plex.tv: \(error.localizedDescription)"
signError = "Couldn't reach plex.tv: \(error.localizedDescription)"
}
}
}
private func useToken() {
private func usePlexToken() {
let t = manual.trimmingCharacters(in: .whitespaces)
draft.url = clean(draft.url)
Task {
if await g.plex.probe(url, token: t) { await finish(t) }
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 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" }
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"
}()
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
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()
}
}
@@ -408,7 +679,7 @@ struct StemServerScreen: View {
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.")
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: {
@@ -607,8 +878,16 @@ struct AppearanceScreen: View {
.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)
@@ -622,11 +901,11 @@ struct AppearanceScreen: View {
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 {
@Bindable var s = g.settings
let shown = Tab.layout(s.tabLayout)
let hidden = Tab.allCases.filter { !shown.contains($0) }
List {
Section {
@@ -639,18 +918,20 @@ struct TabBarScreen: View {
.deleteDisabled(t == .Settings)
}
.onMove { from, to in
var l = shown
l.move(fromOffsets: from, toOffset: to)
s.tabLayout = Tab.encode(l)
shown.move(fromOffsets: from, toOffset: to)
commit()
}
.onDelete { idx in
s.tabLayout = Tab.encode(shown.enumerated().filter { !idx.contains($0.offset) }.map(\.element))
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 { s.tabLayout = Tab.encode(shown + [t]) } label: {
Button { if shown.count < Tab.maxVisible { shown.append(t); commit() } } label: {
HStack {
Label(t.rawValue, systemImage: t.icon).foregroundStyle(c.label)
Spacer()
@@ -663,7 +944,7 @@ struct TabBarScreen: View {
} header: { Header(text: "Available") }
}
Section {
Button("Reset to default") { s.tabLayout = Tab.encode(Tab.defaultLayout) }.themedRow()
Button("Reset to default") { shown = Tab.defaultLayout; commit() }.themedRow()
}
}
.environment(\.editMode, .constant(.active))