353 lines
12 KiB
Swift
353 lines
12 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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) }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|