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

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

333 lines
11 KiB
Swift

import SwiftUI
let MARGIN: CGFloat = 20
func formatDuration(_ ms: Int64) -> String {
let s = max(ms / 1000, 0)
return s >= 3600 ? String(format: "%d:%02d:%02d", s / 3600, s / 60 % 60, s % 60) : String(format: "%d:%02d", s / 60, s % 60)
}
func formatMinutes(_ ms: Int64) -> String {
let m = Int(ms / 60000)
return m >= 60 ? "\(m / 60) hr \(m % 60) min" : "\(m) minute\(m == 1 ? "" : "s")"
}
func songs(_ n: Int) -> String { "\(n) song\(n == 1 ? "" : "s")" }
func gb(_ bytes: Int64) -> String {
let b = Double(bytes)
return b < 1e9 ? String(format: "%.0f MB", b / 1e6) : String(format: "%.1f GB", b / 1e9)
}
/// Plex artwork through the photo transcoder, sized to the screen's pixels.
struct Artwork: View {
let thumb: String?
let size: CGFloat
var corner: CGFloat = 6
var circle = false
var placeholder = "music.note"
@Environment(\.palette) private var p
@Environment(\.displayScale) private var scale
@State private var image: UIImage?
private var url: URL? {
// Round the request up so nearby sizes share cache entries.
let px = min(max(Int((size * scale / 120).rounded(.up)) * 120, 120), 1440)
return AppGraph.shared.plex.artUrl(thumb, px: px)
}
var body: some View {
let shape = circle ? AnyShape(Circle()) : AnyShape(RoundedRectangle(cornerRadius: corner, style: .continuous))
ZStack {
p.placeholder
Image(systemName: placeholder).font(.system(size: size * 0.38)).foregroundStyle(p.label3)
if let image {
Image(uiImage: image).resizable().scaledToFill()
}
}
.frame(width: size, height: size)
.clipShape(shape)
.overlay(shape.stroke(p.separator.opacity(0.6), lineWidth: 0.5))
.task(id: url) {
guard let url else { image = nil; return }
if let c = ArtLoader.shared.cached(url) { image = c; return }
image = nil
image = await ArtLoader.shared.image(url)
}
}
}
/// Settings.app-style coloured rounded-square icon.
struct SettingsIcon: View {
let symbol: String
let bg: UInt32
var body: some View {
Image(systemName: symbol).font(.system(size: 16, weight: .medium)).foregroundStyle(.white)
.frame(width: 30, height: 30)
.background(Color(hex: bg), in: RoundedRectangle(cornerRadius: 7, style: .continuous))
}
}
/// An icon, title and grey value, as in Settings.app.
struct ValueCell: View {
let title: String
var value: String?
var icon: String?
var iconBg: UInt32 = 0x8E8E93
@Environment(\.palette) private var p
var body: some View {
HStack(spacing: 14) {
if let icon { SettingsIcon(symbol: icon, bg: iconBg) }
Text(title).foregroundStyle(p.label)
Spacer(minLength: 8)
if let value { Text(value).foregroundStyle(p.label2).lineLimit(1) }
}
}
}
struct EmptyState: View {
let icon: String
let title: String
let message: String
var action: String?
var onAction: () -> Void = {}
@Environment(\.palette) private var p
var body: some View {
VStack(spacing: 0) {
Image(systemName: icon).font(.system(size: 52)).foregroundStyle(p.label3)
Text(title).font(.title3.weight(.semibold)).foregroundStyle(p.label).multilineTextAlignment(.center).padding(.top, 14)
Text(message).font(.subheadline).foregroundStyle(p.label2).multilineTextAlignment(.center).padding(.top, 6)
if let action {
Button(action: onAction) {
Text(action).font(.headline).foregroundStyle(.white).padding(.horizontal, 20).padding(.vertical, 10)
.background(p.accent, in: Capsule())
}
.buttonStyle(.plain)
.padding(.top, 16)
}
}
.padding(.horizontal, 40).padding(.vertical, 70)
.frame(maxWidth: .infinity)
}
}
struct LoadingRow: View {
var body: some View { ProgressView().controlSize(.large).frame(maxWidth: .infinity).padding(.vertical, 40) }
}
struct ErrorRow: View {
let message: String
let retry: () -> Void
@Environment(Ui.self) private var ui
var body: some View {
if !AppGraph.shared.plex.signedIn {
EmptyState(icon: "server.rack", title: "Not Connected", message: "Sign in to your Plex server to see your music here.", action: "Connect") {
ui.push(.PlexAccount)
}
} else {
EmptyState(icon: "exclamationmark.triangle", title: "Couldn't Load", message: message, action: "Try Again", onAction: retry)
}
}
}
struct PillButton: View {
let text: String
let icon: String
let action: () -> Void
@Environment(\.palette) private var p
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
Image(systemName: icon).font(.system(size: 17, weight: .semibold))
Text(text).font(.headline)
}
.foregroundStyle(p.accent)
.frame(maxWidth: .infinity, minHeight: 50)
.background(p.fill, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(.plain)
}
}
/// Play / Shuffle pair under a header.
struct PlayShuffle: View {
let play: () -> Void
let shuffle: () -> Void
var body: some View {
HStack(spacing: 14) {
PillButton(text: "Play", icon: "play.fill", action: play)
PillButton(text: "Shuffle", icon: "shuffle", action: shuffle)
}
.padding(.horizontal, MARGIN)
}
}
/// Capsule chip, accent-filled when selected.
struct Chip: View {
let text: String
let selected: Bool
let action: () -> Void
@Environment(\.palette) private var p
var body: some View {
Button(action: action) {
Text(text).font(.footnote.weight(.semibold)).foregroundStyle(selected ? .white : p.label)
.padding(.horizontal, 14).frame(height: 32)
.background(selected ? p.accent : p.fill, in: Capsule())
}
.buttonStyle(.plain)
}
}
/// Small outlined tag, e.g. "NO STEMS" next to a track.
struct Tag: View {
let text: String
var color: Color?
@Environment(\.palette) private var p
var body: some View {
let c = color ?? p.label2
Text(text).font(.system(size: 10, weight: .bold)).foregroundStyle(c)
.padding(.horizontal, 4).padding(.vertical, 1)
.overlay(RoundedRectangle(cornerRadius: 4).stroke(c, lineWidth: 1))
.fixedSize()
}
}
/// Downloaded marker: filled circle with a down arrow, as in Apple Music's lists.
struct DownloadedMark: View {
var color: Color?
var size: CGFloat = 14
@Environment(\.palette) private var p
var body: some View {
Image(systemName: "arrow.down.circle.fill").font(.system(size: size)).foregroundStyle(color ?? p.label2)
}
}
/// Download progress ring.
struct ProgressRing: View {
let progress: Float
var size: CGFloat = 18
var color: Color?
@Environment(\.palette) private var p
var body: some View {
ZStack {
Circle().stroke(color.map { $0.opacity(0.3) } ?? p.fillStrong, lineWidth: 2)
Circle().trim(from: 0, to: CGFloat(min(max(progress, 0), 1)))
.stroke(color ?? p.accent, style: StrokeStyle(lineWidth: 2, lineCap: .round))
.rotationEffect(.degrees(-90))
}
.frame(width: size - 2, height: size - 2)
.animation(.linear(duration: 0.2), value: progress)
}
}
/// Animated "now playing" bars used in place of a track number.
struct PlayingBars: View {
let playing: Bool
var color: Color?
@Environment(\.palette) private var p
var body: some View {
TimelineView(.animation(paused: !playing)) { tl in
let phase = tl.date.timeIntervalSinceReferenceDate * (2 * .pi / 1.1)
Canvas { ctx, size in
let w = size.width / 4.5
for i in 0..<3 {
let h = playing ? 0.35 + 0.65 * abs(sin(phase + Double(i) * 1.9)) : 0.3 + Double(i) * 0.15
let bh = size.height * h
let r = CGRect(x: Double(i) * w * 1.6 + w * 0.3, y: size.height - bh, width: w, height: bh)
ctx.fill(Path(roundedRect: r, cornerRadius: w / 3), with: .color(color ?? p.accent))
}
}
}
.frame(width: 16, height: 16)
}
}
/// Section title with an optional accent action, as in Apple Music ("Recently Added").
struct SectionTitle: View {
let title: String
var action: String?
var onAction: () -> Void = {}
@Environment(\.palette) private var p
var body: some View {
HStack {
Text(title).font(.title2.bold()).foregroundStyle(p.label)
Spacer()
if let action { Button(action, action: onAction).foregroundStyle(p.accent) }
}
.padding(.leading, MARGIN).padding(.trailing, 12).padding(.top, 22).padding(.bottom, 8)
}
}
/// What a screen's data is doing. A failed refresh keeps the data already on screen.
enum LoadState<T> {
case loading
case ok(T)
case err(String)
var value: T? { if case .ok(let v) = self { v } else { nil } }
}
extension LoadState {
/// Runs [f], keeping old data if it fails after a success.
@MainActor
static func load(_ state: Binding<LoadState<T>>, _ f: () async throws -> T) async {
do {
state.wrappedValue = .ok(try await f())
} catch is CancellationError {
} catch {
if state.wrappedValue.value == nil { state.wrappedValue = .err(error.localizedDescription) }
}
}
}
/// The loading spinner, error page or content for a [LoadState].
struct Loaded<T, Content: View>: View {
let state: LoadState<T>
let retry: () -> Void
@ViewBuilder let content: (T) -> Content
var body: some View {
switch state {
case .loading: LoadingRow()
case .err(let m): ErrorRow(message: m, retry: retry)
case .ok(let v): content(v)
}
}
}
/// A plain scrolling page with a large title, like the Android shell's Page.
struct Page<Content: View>: View {
let title: String
var large = true
@ViewBuilder let content: () -> Content
@Environment(\.palette) private var p
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) { content() }
.padding(.bottom, 24)
}
.background(p.bg)
.navigationTitle(title)
.navigationBarTitleDisplayMode(large ? .large : .inline)
}
}
extension View {
/// Row padding plus the inset hairline under it.
func listRow(inset: CGFloat = MARGIN, leading: CGFloat = MARGIN, trailing: CGFloat = 6, line: Bool = true) -> some View {
modifier(ListRow(inset: inset, leading: leading, trailing: trailing, line: line))
}
}
private struct ListRow: ViewModifier {
let inset: CGFloat, leading: CGFloat, trailing: CGFloat, line: Bool
@Environment(\.palette) private var p
@Environment(\.displayScale) private var scale
func body(content: Content) -> some View {
content.padding(.leading, leading).padding(.trailing, trailing)
.overlay(alignment: .bottom) {
if line { p.separator.frame(height: 1 / scale).padding(.leading, inset) }
}
}
}