SwiftUI port of the Android app under ios/, with CarPlay browse and Now Playing, Siri play-media intents, and a build-ipa.sh script that makes an unsigned IPA for sideloading. README gets an iOS section and the vibecoded disclaimer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
539 lines
22 KiB
Swift
539 lines
22 KiB
Swift
import SwiftUI
|
|
|
|
/// A Settings.app row that pushes [route]: icon, title, grey value, chevron.
|
|
private struct NavCell: View {
|
|
let title: String
|
|
var value: String?
|
|
var icon: String?
|
|
var iconBg: UInt32 = 0x8E8E93
|
|
let route: Route
|
|
var body: some View {
|
|
NavigationLink(value: route) { ValueCell(title: title, value: value, icon: icon, iconBg: iconBg) }.themedRow()
|
|
}
|
|
}
|
|
|
|
private struct ToggleCell: View {
|
|
let title: String
|
|
let icon: String
|
|
let iconBg: UInt32
|
|
let isOn: Binding<Bool>
|
|
@Environment(\.palette) private var p
|
|
var body: some View {
|
|
Toggle(isOn: isOn) {
|
|
HStack(spacing: 14) {
|
|
SettingsIcon(symbol: icon, bg: iconBg)
|
|
Text(title).foregroundStyle(p.label)
|
|
}
|
|
}
|
|
.tint(Color(hex: 0x34C759))
|
|
.themedRow()
|
|
}
|
|
}
|
|
|
|
/// A tappable row in the accent (or red), optionally centred.
|
|
private struct ActionCell: View {
|
|
let title: String
|
|
var color: Color?
|
|
var centered = false
|
|
var enabled = true
|
|
let action: () -> Void
|
|
@Environment(\.palette) private var p
|
|
var body: some View {
|
|
Button(action: action) {
|
|
Text(title).foregroundStyle(enabled ? color ?? p.accent : p.label3)
|
|
.frame(maxWidth: .infinity, alignment: centered ? .center : .leading)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(!enabled)
|
|
.themedRow()
|
|
}
|
|
}
|
|
|
|
/// Label on the left, an inline text field on the right.
|
|
private struct FieldCell: View {
|
|
let label: String
|
|
@Binding var text: String
|
|
let placeholder: String
|
|
var secret = false
|
|
@Environment(\.palette) private var p
|
|
var body: some View {
|
|
HStack {
|
|
Text(label).foregroundStyle(p.label).frame(width: 96, alignment: .leading)
|
|
Group {
|
|
if secret { SecureField(placeholder, text: $text) } else { TextField(placeholder, text: $text).keyboardType(.URL) }
|
|
}
|
|
.multilineTextAlignment(.trailing)
|
|
.foregroundStyle(p.label2)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
}
|
|
.themedRow()
|
|
}
|
|
}
|
|
|
|
private struct Header: View {
|
|
let text: String
|
|
@Environment(\.palette) private var p
|
|
var body: some View { Text(text).foregroundStyle(p.label2) }
|
|
}
|
|
|
|
private struct Footer: View {
|
|
let text: String
|
|
@Environment(\.palette) private var p
|
|
var body: some View { Text(text).foregroundStyle(p.label2) }
|
|
}
|
|
|
|
private func clean(_ url: String) -> String {
|
|
var u = url.trimmingCharacters(in: .whitespaces)
|
|
while u.hasSuffix("/") { u.removeLast() }
|
|
return u
|
|
}
|
|
|
|
// ---- settings -------------------------------------------------------------------------------
|
|
|
|
struct SettingsScreen: View {
|
|
private var g: AppGraph { .shared }
|
|
|
|
var body: some View {
|
|
@Bindable var s = g.settings
|
|
let status = g.stems.status
|
|
List {
|
|
// Everything on one screen: no footers but the last, one line each.
|
|
Section {
|
|
NavCell(title: "Plex", value: s.plexToken.isEmpty ? "Not Signed In" : (s.musicSectionTitle.isEmpty ? s.plexUser : s.musicSectionTitle),
|
|
icon: "server.rack", iconBg: 0xE5A00D, route: .PlexAccount)
|
|
NavCell(title: "Stem Server", value: {
|
|
switch status {
|
|
case .online(let n, _): "\(n) songs"
|
|
case .offline: "Offline"
|
|
case .unknown: "…"
|
|
}
|
|
}(), icon: "waveform", iconBg: 0xAF52DE, route: .StemServer)
|
|
} header: { Header(text: "Servers") }
|
|
Section {
|
|
NavCell(title: "Equalizer", value: !g.eq.state.enabled ? "Off" : g.eq.active?.name ?? "On", icon: "slider.vertical.3", iconBg: 0x34C759, route: .Eq)
|
|
NavCell(title: "Stem Quality", value: s.stemQuality.label, icon: "gauge.with.dots.needle.67percent", iconBg: 0xFF2D55, route: .Quality)
|
|
ToggleCell(title: "Keep Stem Mix Between Songs", icon: "speaker.wave.2.fill", iconBg: 0x5856D6, isOn: $s.keepStemMix)
|
|
} header: { Header(text: "Sound") }
|
|
Section {
|
|
NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage)
|
|
ToggleCell(title: "Download on Wi-Fi Only", icon: "arrow.down.circle.fill", iconBg: 0x30B0C7, isOn: $s.downloadsWifiOnly)
|
|
} header: { Header(text: "Storage") }
|
|
Section {
|
|
NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance)
|
|
} header: { Header(text: "Display") } footer: {
|
|
Footer(text: "Stemify \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.1") · stems by stemd on your server · \(UIScreen.main.maximumFramesPerSecond) Hz")
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
.themedList()
|
|
.navigationTitle("Settings")
|
|
}
|
|
}
|
|
|
|
// ---- plex -----------------------------------------------------------------------------------
|
|
|
|
struct PlexAccountScreen: View {
|
|
@Environment(Ui.self) private var ui
|
|
@Environment(\.palette) private var p
|
|
@Environment(\.openURL) private var openURL
|
|
@State private var url = AppGraph.shared.settings.plexUrl
|
|
@State private var manual = ""
|
|
@State private var pinCode: String?
|
|
@State private var pinId: Int64 = 0
|
|
@State private var pinError: String?
|
|
@State private var sections: [MusicSection]?
|
|
@State private var testing = false
|
|
private var g: AppGraph { .shared }
|
|
|
|
var body: some View {
|
|
let s = g.settings
|
|
List {
|
|
Section {
|
|
FieldCell(label: "Address", text: $url, placeholder: "http://192.168.86.2:32400")
|
|
ActionCell(title: testing ? "Testing…" : "Test Connection") { test() }
|
|
} header: { Header(text: "Server") } footer: { Footer(text: "Your server's LAN address (port 32400).") }
|
|
if s.plexToken.isEmpty {
|
|
Section {
|
|
ActionCell(title: pinCode == nil ? "Sign In with plex.tv" : "Get a New Code") { newPin() }
|
|
if let pinCode {
|
|
VStack(spacing: 0) {
|
|
Text(pinCode).font(.system(size: 44, weight: .bold)).tracking(8).foregroundStyle(p.label).textSelection(.enabled)
|
|
HStack(spacing: 8) {
|
|
ProgressView().controlSize(.small)
|
|
Text("Waiting for plex.tv/link…").font(.footnote).foregroundStyle(p.label2)
|
|
}
|
|
.padding(.top, 10)
|
|
Button { openURL(URL(string: "https://plex.tv/link")!) } label: {
|
|
Text("Open plex.tv/link").font(.headline).foregroundStyle(.white)
|
|
.padding(.horizontal, 18).padding(.vertical, 9).background(p.accent, in: Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.top, 14)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 18)
|
|
.themedRow()
|
|
}
|
|
} header: { Header(text: "Sign In") } footer: {
|
|
if let f = pinError ?? (pinCode == nil ? "Enter the code at plex.tv/link while signed in to Plex." : nil) { Footer(text: f) }
|
|
}
|
|
Section {
|
|
FieldCell(label: "Token", text: $manual, placeholder: "Required", secret: true)
|
|
ActionCell(title: "Use Token", enabled: !manual.trimmingCharacters(in: .whitespaces).isEmpty) { useToken() }
|
|
} header: { Header(text: "Or Paste an X-Plex-Token") }
|
|
} else {
|
|
Section {
|
|
HStack(spacing: 14) {
|
|
SettingsIcon(symbol: "person.fill", bg: 0xE5A00D)
|
|
Text(s.plexUser.isEmpty ? "Signed In" : s.plexUser).foregroundStyle(p.label)
|
|
}
|
|
.themedRow()
|
|
} header: { Header(text: "Account") }
|
|
Section {
|
|
if let list = sections {
|
|
if list.isEmpty { Text("No music libraries found").foregroundStyle(p.label).themedRow() }
|
|
ForEach(list) { sec in
|
|
Button {
|
|
s.musicSection = sec.key
|
|
s.musicSectionTitle = sec.title
|
|
} label: {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(sec.title).foregroundStyle(p.label)
|
|
if !sec.locations.isEmpty {
|
|
Text(sec.locations.joined(separator: ", ")).font(.footnote).foregroundStyle(p.label2).lineLimit(2)
|
|
}
|
|
}
|
|
Spacer()
|
|
if sec.key == s.musicSection { Image(systemName: "checkmark").font(.body.weight(.semibold)).foregroundStyle(p.accent) }
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.themedRow()
|
|
}
|
|
} else {
|
|
ProgressView().frame(maxWidth: .infinity).themedRow()
|
|
}
|
|
} header: { Header(text: "Music Library") }
|
|
Section {
|
|
ActionCell(title: "Sign Out", color: p.red, centered: true) {
|
|
ui.overlays.confirm("Sign Out of Plex?", "Downloads stay on this phone.", "Sign Out") {
|
|
s.plexToken = ""
|
|
s.plexUser = ""
|
|
s.musicSection = ""
|
|
s.musicSectionTitle = ""
|
|
sections = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
.themedList()
|
|
.navigationTitle("Plex")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.task(id: s.plexToken) {
|
|
if !s.plexToken.isEmpty && sections == nil { sections = try? await g.plex.musicSections() }
|
|
}
|
|
// Poll plex.tv while a PIN is showing.
|
|
.task(id: pinId) {
|
|
guard pinId != 0 else { return }
|
|
for _ in 0..<300 {
|
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
|
if Task.isCancelled { return }
|
|
if let t = (try? await g.plex.checkPin(pinId))?.authToken, !t.isEmpty {
|
|
pinCode = nil
|
|
pinId = 0
|
|
await finish(t)
|
|
ui.overlays.hud("Signed In")
|
|
return
|
|
}
|
|
}
|
|
pinCode = nil
|
|
pinId = 0
|
|
pinError = "The code expired. Try again."
|
|
}
|
|
}
|
|
|
|
private func test() {
|
|
let s = g.settings
|
|
testing = true
|
|
Task {
|
|
let ok = await g.plex.probe(url, token: s.plexToken)
|
|
testing = false
|
|
if ok {
|
|
s.plexUrl = clean(url)
|
|
ui.overlays.hud("Connected")
|
|
} else {
|
|
ui.overlays.hud(s.plexToken.isEmpty ? "Sign in first" : "Couldn't reach Plex at that address", "exclamationmark.triangle")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func newPin() {
|
|
pinError = nil
|
|
Task {
|
|
do {
|
|
let pin = try await g.plex.createPin()
|
|
pinCode = pin.code
|
|
pinId = pin.id
|
|
} catch {
|
|
pinError = "Couldn't reach plex.tv: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|
|
|
|
private func useToken() {
|
|
let t = manual.trimmingCharacters(in: .whitespaces)
|
|
Task {
|
|
if await g.plex.probe(url, token: t) { await finish(t) }
|
|
else { ui.overlays.hud("That token didn't work on this server", "exclamationmark.triangle") }
|
|
}
|
|
}
|
|
|
|
private func finish(_ token: String) async {
|
|
let s = g.settings
|
|
s.plexUrl = clean(url)
|
|
s.plexToken = token
|
|
s.plexUser = await {
|
|
guard let u = try? await g.plex.user(token: token) else { return "Plex user" }
|
|
return u.title ?? u.username ?? "Plex user"
|
|
}()
|
|
s.plexMachineId = (try? await g.plex.machineId()) ?? ""
|
|
sections = try? await g.plex.musicSections()
|
|
if let only = sections, only.count == 1 {
|
|
s.musicSection = only[0].key
|
|
s.musicSectionTitle = only[0].title
|
|
}
|
|
await g.stems.refresh()
|
|
}
|
|
}
|
|
|
|
// ---- stem server ----------------------------------------------------------------------------
|
|
|
|
struct StemServerScreen: View {
|
|
@Environment(Ui.self) private var ui
|
|
@Environment(\.palette) private var p
|
|
@State private var url = AppGraph.shared.settings.stemUrl
|
|
@State private var token = AppGraph.shared.settings.stemToken
|
|
@State private var busy = false
|
|
private var g: AppGraph { .shared }
|
|
|
|
var body: some View {
|
|
let status = g.stems.status
|
|
List {
|
|
Section {
|
|
FieldCell(label: "Address", text: $url, placeholder: "http://192.168.86.2:8765")
|
|
FieldCell(label: "Token", text: $token, placeholder: "Optional", secret: true)
|
|
ActionCell(title: busy ? "Connecting…" : "Save & Connect") { connect() }
|
|
} header: { Header(text: "stemserve") } footer: {
|
|
Footer(text: "stemserve shares the Stems folder that stemd fills (port 8765). The app checks it for new stems every minute.")
|
|
}
|
|
Section {
|
|
ValueCell(title: "Connection", value: {
|
|
switch status {
|
|
case .online: "Online"
|
|
case .offline: "Offline"
|
|
case .unknown: "Checking"
|
|
}
|
|
}()).themedRow()
|
|
ValueCell(title: "Songs with Stems", value: {
|
|
switch status {
|
|
case .online(let n, _): "\(n)"
|
|
case .offline(_, let n): "\(n) (cached)"
|
|
case .unknown: "—"
|
|
}
|
|
}()).themedRow()
|
|
ValueCell(title: "Opus Transcoding", value: {
|
|
if case .online(_, let t) = status { return t ? "Available" : "No ffmpeg" }
|
|
return "—"
|
|
}()).themedRow()
|
|
if case .offline(let reason, _) = status {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Error").foregroundStyle(p.label)
|
|
Text(reason).font(.footnote).foregroundStyle(p.label2)
|
|
}
|
|
.themedRow()
|
|
}
|
|
} header: { Header(text: "Status") }
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
.themedList()
|
|
.navigationTitle("Stem Server")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
|
|
private func connect() {
|
|
let s = g.settings
|
|
s.stemUrl = clean(url)
|
|
s.stemToken = token.trimmingCharacters(in: .whitespaces)
|
|
busy = true
|
|
Task {
|
|
await g.stems.refresh(force: true)
|
|
busy = false
|
|
let ok = g.stems.status.isOnline
|
|
ui.overlays.hud(ok ? "Connected" : "Couldn't connect", ok ? "checkmark" : "exclamationmark.triangle")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- quality --------------------------------------------------------------------------------
|
|
|
|
/// A row with a title, grey detail and a checkmark when chosen.
|
|
private struct CheckCell: View {
|
|
let title: String
|
|
var subtitle: String?
|
|
let checked: Bool
|
|
let action: () -> Void
|
|
@Environment(\.palette) private var p
|
|
var body: some View {
|
|
Button(action: action) {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title).foregroundStyle(p.label)
|
|
if let subtitle { Text(subtitle).font(.footnote).foregroundStyle(p.label2) }
|
|
}
|
|
Spacer()
|
|
if checked { Image(systemName: "checkmark").font(.body.weight(.semibold)).foregroundStyle(p.accent) }
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.themedRow()
|
|
}
|
|
}
|
|
|
|
struct QualityScreen: View {
|
|
private var g: AppGraph { .shared }
|
|
var body: some View {
|
|
let s = g.settings
|
|
List {
|
|
Section {
|
|
ForEach(StemQuality.allCases) { q in
|
|
CheckCell(title: q.label, subtitle: q.detail, checked: s.stemQuality == q) { s.stemQuality = q }
|
|
}
|
|
} footer: {
|
|
Footer(text: "For streaming and new downloads. Opus is made once on the server and kept there; 256 kbps is about an eighth of the FLAC size.")
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
.themedList()
|
|
.navigationTitle("Stem Quality")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
|
|
// ---- storage --------------------------------------------------------------------------------
|
|
|
|
struct StorageScreen: View {
|
|
@Environment(Ui.self) private var ui
|
|
@Environment(\.palette) private var p
|
|
private var g: AppGraph { .shared }
|
|
private let limits = [2, 5, 10, 20, 50, 100]
|
|
|
|
var body: some View {
|
|
@Bindable var s = g.settings
|
|
let usage = g.cache.usage
|
|
List {
|
|
Section {
|
|
Button { ui.selectTab(.Downloads) } label: {
|
|
HStack {
|
|
ValueCell(title: "Downloads", value: gb(usage.pinnedBytes))
|
|
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label3)
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.themedRow()
|
|
ValueCell(title: "Streaming Cache", value: gb(max(0, usage.cachedBytes - usage.pinnedBytes))).themedRow()
|
|
} header: { Header(text: "On This Phone") }
|
|
Section {
|
|
Picker("Limit", selection: $s.cacheLimitGb) {
|
|
ForEach(limits, id: \.self) { Text("\($0) GB").tag($0) }
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
|
|
.themedRow()
|
|
} header: { Header(text: "Streaming Cache Limit") } footer: {
|
|
Footer(text: "Streamed songs stay for replay and offline use, oldest first out. Downloads don't count.")
|
|
}
|
|
Section {
|
|
ActionCell(title: "Clear Streaming Cache", color: p.red, centered: true) {
|
|
ui.overlays.confirm("Clear Cache?", "Downloads are kept.", "Clear") { g.cache.clearUnpinned() }
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
.themedList()
|
|
.navigationTitle("Storage")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
|
|
// ---- appearance -----------------------------------------------------------------------------
|
|
|
|
struct AppearanceScreen: View {
|
|
@Environment(\.palette) private var c
|
|
private var g: AppGraph { .shared }
|
|
|
|
var body: some View {
|
|
@Bindable var s = g.settings
|
|
List {
|
|
Section {
|
|
HStack {
|
|
ForEach(ThemeMode.allCases) { m in
|
|
let p = Palette.of(m, s.accent)
|
|
let on = m == s.theme
|
|
Button { s.theme = m } label: {
|
|
VStack(spacing: 8) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Capsule().fill(p.label).frame(width: 40, height: 6)
|
|
ForEach(0..<3, id: \.self) { _ in RoundedRectangle(cornerRadius: 4).fill(p.cell).frame(height: 18) }
|
|
Capsule().fill(p.accent).frame(width: 24, height: 6)
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(8)
|
|
.frame(width: 78, height: 140)
|
|
.background(p.groupedBg, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(on ? c.accent : c.separator, lineWidth: on ? 3 : 1))
|
|
Text(m.label).font(.subheadline.weight(on ? .semibold : .regular)).foregroundStyle(c.label)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
.themedRow()
|
|
} header: { Header(text: "Theme") } footer: { Footer(text: "Black is true black for OLED. No gradients anywhere, by design.") }
|
|
Section {
|
|
HStack {
|
|
ForEach(Accent.allCases) { a in
|
|
Button { s.accent = a } label: {
|
|
Circle().fill(Color(hex: c.dark ? a.dark : a.light)).frame(width: 32, height: 32)
|
|
.overlay(Circle().stroke(a == s.accent ? c.label : .clear, lineWidth: 3))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(a.label)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
.themedRow()
|
|
} header: { Header(text: "Accent") }
|
|
Section {
|
|
Toggle(isOn: $s.albumColorNowPlaying) { Text("Album Colour Background").foregroundStyle(c.label) }
|
|
.tint(Color(hex: 0x34C759))
|
|
.themedRow()
|
|
} header: { Header(text: "Now Playing") } footer: { Footer(text: "Paints the Now Playing screen in one solid colour taken from the album art.") }
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
.themedList()
|
|
.navigationTitle("Appearance")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|