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:
1 parent
894dd5a695
commit
df5d847169
157 files changed
+3935
-742
No files matched your search
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user