import AVKit import SwiftUI import UIKit 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) } } } /// A settings icon: the glyph in its colour on a faint wash of the same colour. struct SettingsIcon: View { let symbol: String let bg: UInt32 var body: some View { Image(systemName: symbol).font(.system(size: 15, weight: .semibold)).foregroundStyle(Color(hex: bg)) .frame(width: 30, height: 30) .background(Color(hex: bg).opacity(0.16), in: RoundedRectangle(cornerRadius: 8, 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: "Add your Plex or Jellyfin server to see your music here.", action: "Connect") { ui.push(.Servers) } } 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 { 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>, _ 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: View { let state: LoadState 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: 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) } } } } /// The system AirPlay / Bluetooth output picker. The mix is rendered on the phone, so stem edits keep working over AirPlay. struct AirPlayButton: UIViewRepresentable { var tint: UIColor = .label var active: UIColor = .systemBlue func makeUIView(context: Context) -> AVRoutePickerView { let v = AVRoutePickerView() v.prioritizesVideoDevices = false v.backgroundColor = .clear return v } func updateUIView(_ v: AVRoutePickerView, context: Context) { v.tintColor = tint v.activeTintColor = active } }