Files
stemify/ios/Stemify/UI/Screens/NowPlayingScreen.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

799 lines
35 KiB
Swift

import SwiftUI
import UIKit
enum NpPanel { case Art, Mixer, Queue }
func deviceIcon(_ d: OutputDevice) -> (String, UInt32) {
switch d.kind {
case .Speaker: ("iphone", 0x8E8E93)
case .Bluetooth: ("headphones", 0x007AFF)
case .Wired: ("headphones", 0x34C759)
case .Usb: ("cable.connector", 0xFF9500)
case .Hdmi: ("airplayaudio", 0x5856D6)
case .Car: ("car.fill", 0x34C759)
case .Other: ("hifispeaker", 0x8E8E93)
}
}
// ---- album colour ---------------------------------------------------------------------------
/// A solid colour from the album art, pushed dark (or light for the White theme) so text
/// stays readable. One flat colour: no gradients.
@MainActor
private func albumColor(_ thumb: String?, dark: Bool) async -> Color? {
guard let url = AppGraph.shared.plex.artUrl(thumb, px: 120) else { return nil }
let img: UIImage? = if let c = ArtLoader.shared.cached(url) { c } else { await ArtLoader.shared.image(url) }
guard let cg = img?.cgImage else { return nil }
return await Task.detached { () -> Color? in
guard let (h, s0, v0) = vibrant(cg) else { return nil }
let s = dark ? min(s0, 0.65) : min(s0, 0.22)
let v = dark ? min(max(v0, 0.18), 0.30) : 0.96
return Color(hue: h, saturation: s, brightness: v)
}.value
}
/// Roughly Palette's vibrant swatch: the average hue of the most saturated, mid-bright pixels.
private func vibrant(_ cg: CGImage) -> (CGFloat, CGFloat, CGFloat)? {
let n = 24
var px = [UInt8](repeating: 0, count: n * n * 4)
guard let ctx = CGContext(data: &px, width: n, height: n, bitsPerComponent: 8, bytesPerRow: n * 4,
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }
ctx.draw(cg, in: CGRect(x: 0, y: 0, width: n, height: n))
var all: [(CGFloat, CGFloat, CGFloat, CGFloat)] = []
for i in 0..<(n * n) {
let c = UIColor(red: CGFloat(px[i * 4]) / 255, green: CGFloat(px[i * 4 + 1]) / 255, blue: CGFloat(px[i * 4 + 2]) / 255, alpha: 1)
var h: CGFloat = 0, s: CGFloat = 0, v: CGFloat = 0, a: CGFloat = 0
c.getHue(&h, saturation: &s, brightness: &v, alpha: &a)
// Favour colourful pixels that aren't near black or white.
let score = s * (1 - abs(v - 0.55) * 1.4)
all.append((h, s, v, score))
}
let top = all.sorted { $0.3 > $1.3 }.prefix(max(1, all.count / 6))
// Average hue on the circle so reds either side of 0 don't cancel out.
var x: CGFloat = 0, y: CGFloat = 0, s: CGFloat = 0, v: CGFloat = 0
for p in top { x += cos(p.0 * 2 * .pi); y += sin(p.0 * 2 * .pi); s += p.1; v += p.2 }
let k = CGFloat(top.count)
var h = atan2(y, x) / (2 * .pi)
if h < 0 { h += 1 }
return (h, s / k, v / k)
}
private extension Palette {
func tinted(_ bg: Color) -> Palette {
let dark = UIColor(bg).luminance < 0.5
var c = self
c.dark = dark
c.bg = bg
c.groupedBg = bg
c.fill = dark ? .white.opacity(0.12) : .black.opacity(0.06)
c.fillStrong = dark ? .white.opacity(0.22) : .black.opacity(0.12)
c.label = dark ? .white : .black
c.label2 = dark ? .white.opacity(0.62) : .black.opacity(0.55)
c.label3 = dark ? .white.opacity(0.25) : .black.opacity(0.2)
c.separator = dark ? .white.opacity(0.14) : .black.opacity(0.1)
c.placeholder = c.fill
return c
}
}
private extension UIColor {
var luminance: CGFloat {
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
getRed(&r, green: &g, blue: &b, alpha: &a)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
}
// ---- now playing ----------------------------------------------------------------------------
struct NowPlayingScreen: View {
@Environment(Ui.self) private var ui
@Environment(\.palette) private var base
@Environment(\.dismiss) private var dismiss
@State private var panel = NpPanel.Art
@State private var tint: Color?
private var g: AppGraph { .shared }
var body: some View {
let pm = g.playback
let c = tint.map { base.tinted($0) } ?? base
let track = pm.current?.track
VStack(spacing: 0) {
if let track {
Group {
switch panel {
case .Art: ArtPanel(thumb: track.thumb, playing: pm.playWhenReady)
case .Mixer: VStack(spacing: 0) { MiniHeader(track: track); MixerPanel() }
case .Queue: VStack(spacing: 0) { MiniHeader(track: track); QueuePanel() }
}
}
.frame(maxHeight: .infinity)
.transition(.opacity)
.animation(.easeInOut(duration: 0.18), value: panel)
controls(track)
} else {
Text("Nothing Playing").font(.title3.weight(.semibold)).foregroundStyle(c.label2).frame(maxHeight: .infinity)
}
}
.padding(.top, 20)
.padding(.bottom, 8)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(c.bg.ignoresSafeArea())
.environment(\.palette, c)
.preferredColorScheme(c.dark ? .dark : .light)
.tint(c.label)
.animation(.easeInOut(duration: 0.3), value: tint)
.presentationDragIndicator(.visible)
.task(id: "\(track?.thumb ?? "")|\(g.settings.albumColorNowPlaying)|\(base.dark)") {
guard g.settings.albumColorNowPlaying, let thumb = track?.thumb else { tint = nil; return }
tint = await albumColor(thumb, dark: base.dark)
}
}
private func controls(_ track: Track) -> some View {
let pm = g.playback
let c = tint.map { base.tinted($0) } ?? base
return VStack(spacing: 0) {
if panel == .Art {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 1) {
Text(track.title).font(.title3.weight(.semibold)).foregroundStyle(c.label).lineLimit(1)
Text(track.artist).font(.title3).foregroundStyle(c.label2).lineLimit(1)
.onTapGesture {
if let k = track.artistKey { ui.showInLibrary(.ArtistPage(Artist(ratingKey: k, title: track.artist, sortTitle: track.artist))) }
}
}
Spacer(minLength: 8)
EllipsisButton(items: ui.menu.track(track))
}
.padding(.top, 14)
}
ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track))
.padding(.top, 18)
HStack {
Spacer()
TransportButton(symbol: "backward.fill", size: 34, action: pm.previous)
Spacer()
Button(action: pm.togglePlay) {
ZStack {
if pm.buffering && pm.playWhenReady { ProgressView().controlSize(.large).tint(c.label) }
else { Image(systemName: pm.playWhenReady ? "pause.fill" : "play.fill").font(.system(size: 48)).foregroundStyle(c.label) }
}
.frame(width: 80, height: 80)
.contentShape(Rectangle())
}
.buttonStyle(PressFade())
Spacer()
TransportButton(symbol: "forward.fill", size: 34, action: pm.next)
Spacer()
}
.frame(height: 84)
HStack {
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { panel = panel == .Mixer ? .Art : .Mixer }
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { panel = panel == .Queue ? .Art : .Queue }
}
.frame(height: 48)
}
.padding(.horizontal, 28)
}
private func routeLabel(_ c: Palette) -> some View {
let eq = g.eq
let device = eq.device
let eqLabel: String = {
guard eq.state.enabled, let p = eq.active else { return "" }
return p.name.caseInsensitiveCompare(device.label) == .orderedSame ? " · EQ" : " · \(p.name)"
}()
return HStack(spacing: 6) {
Image(systemName: deviceIcon(device).0).font(.system(size: 14))
Text(device.label + eqLabel).font(.footnote.weight(.medium)).lineLimit(1)
}
.foregroundStyle(c.label2)
}
private func badge(_ track: Track) -> String? {
let pm = g.playback
switch pm.source {
case .Stems:
let q = g.settings.stemQuality
return "\(pm.stemNames.count) STEMS · \(g.stems.effectiveQuality(q) == .Lossless ? "FLAC" : "OPUS \(q.kbps)")"
case .Original:
return ["ORIGINAL", track.codec?.uppercased()].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · ")
case .None:
return nil
}
}
}
/// Fades a little while pressed, like Apple Music's transport.
struct PressFade: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label.opacity(configuration.isPressed ? 0.45 : 1).scaleEffect(configuration.isPressed ? 0.92 : 1)
.animation(.easeOut(duration: 0.12), value: configuration.isPressed)
}
}
private struct TransportButton: View {
let symbol: String
let size: CGFloat
let action: () -> Void
@Environment(\.palette) private var p
var body: some View {
Button(action: action) {
Image(systemName: symbol).font(.system(size: size)).foregroundStyle(p.label).frame(width: 72, height: 72).contentShape(Rectangle())
}
.buttonStyle(PressFade())
}
}
private struct PanelToggle: View {
let symbol: String
let on: Bool
let action: () -> Void
@Environment(\.palette) private var p
var body: some View {
Button(action: action) {
Image(systemName: symbol).font(.system(size: 19, weight: .medium)).foregroundStyle(on ? p.bg : p.label2)
.frame(width: 40, height: 40)
.background(on ? p.label : .clear, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
.buttonStyle(PressFade())
.sensoryFeedback(.selection, trigger: on)
}
}
/// Round ••• button on a fill, opening the media menu.
private struct EllipsisButton: View {
let items: [SheetAction]
@Environment(Ui.self) private var ui
@Environment(\.palette) private var p
var body: some View {
Button { ui.overlays.actions("", items) } label: {
Image(systemName: "ellipsis").font(.system(size: 15, weight: .bold)).foregroundStyle(p.label)
.frame(width: 32, height: 32).background(p.fill, in: Circle())
}
.buttonStyle(PressFade())
}
}
/// Big art that eases back when paused, like Apple Music.
private struct ArtPanel: View {
let thumb: String?
let playing: Bool
var body: some View {
GeometryReader { geo in
let side = max(min(geo.size.width, geo.size.height), 1)
Artwork(thumb: thumb, size: side, corner: 12)
.shadow(color: .black.opacity(playing ? 0.35 : 0.15), radius: playing ? 24 : 10, y: 10)
.scaleEffect(playing ? 1 : 0.82)
.animation(.spring(response: 0.45, dampingFraction: 0.62), value: playing)
.frame(width: geo.size.width, height: geo.size.height)
}
.padding(.horizontal, 28).padding(.vertical, 8)
}
}
private struct MiniHeader: View {
let track: Track
@Environment(Ui.self) private var ui
@Environment(\.palette) private var p
var body: some View {
HStack(spacing: 14) {
Artwork(thumb: track.thumb, size: 64, corner: 8)
VStack(alignment: .leading, spacing: 1) {
Text(track.title).font(.headline).foregroundStyle(p.label).lineLimit(1)
Text(track.artist).font(.subheadline).foregroundStyle(p.label2).lineLimit(1)
}
Spacer(minLength: 8)
EllipsisButton(items: ui.menu.track(track))
}
.padding(.horizontal, 24).padding(.vertical, 10)
}
}
// ---- seek / volume bar ----------------------------------------------------------------------
/// 0..1 loudness per waveform bucket for the current mix: stems weighted by their faders.
func envelope(_ w: Waveform?, _ names: [String], _ mix: [String: StemSetting]) -> [Float]? {
guard let w else { return nil }
if let stems = w.stems, !stems.isEmpty {
let anySolo = names.contains { mix[$0]?.solo == true }
var gains: [String: Float] = [:]
for n in stems.keys {
let s = mix[n] ?? StemSetting()
gains[n] = (anySolo && !s.solo) || s.muted ? 0 : stemTaper(s.level)
}
// Scaled to the full mix's range, so muting a stem still shows as quieter bars.
return shape(mixDb(stems, gains), mixDb(stems, nil))
}
if let db = w.loudnessDb { return shape(db, db) }
return nil
}
private func mixDb(_ stems: [String: [Float]], _ gains: [String: Float]?) -> [Float] {
let n = stems.values.map(\.count).max() ?? 0
return (0..<n).map { i in
var e: Float = 0
for (name, arr) in stems {
let v = (i < arr.count ? arr[i] : 0) * (gains?[name] ?? 1)
e += v * v
}
return 20 * log10(e.squareRoot() + 1e-6)
}
}
/// Loudness to bar height, relative to the song itself: its loudest moments (99th percentile)
/// fill the bar and its quiet tenth sits near the bottom, over 6 to 24 dB, with a curve that
/// pulls the middle down. Modern masters barely move in absolute terms, so an absolute scale
/// draws nearly every song as a solid block.
private func shape(_ db: [Float], _ ref: [Float]) -> [Float] {
var audible = ref.filter { $0 > -60 }
if audible.isEmpty { audible = ref }
audible.sort()
if audible.isEmpty { return db.map { _ in 0.28 } }
func pct(_ p: Float) -> Float { audible[min(Int(p * Float(audible.count)), audible.count - 1)] }
let hi = pct(0.99)
let span = min(max(hi - pct(0.10), 6), 24)
let lo = hi - span
return db.map { 0.05 + 0.95 * pow(min(max(($0 - lo) / span, 0), 1), 1.8) }
}
/// Plexamp's seek bar: the track's loudness as bars, played part highlighted. The bars' height
/// follows the master volume, so the volume is drawn over the progress.
/// Drag sideways to seek, up/down to change volume, tap to jump.
struct ProgressVolumeBar: View {
let played: Color
let unplayed: Color
let centerLabel: String?
@Environment(\.palette) private var p
@State private var scrub: Double?
@State private var axis = 0 // 1 = seek, 2 = volume
@State private var startVol: Float = 0
@State private var lastStep = 0
@State private var haptic = 0
@State private var barWidth: Double = 1
private var g: AppGraph { .shared }
var body: some View {
let pm = g.playback
let master = pm.master
let env = envelope(pm.waveform, pm.stemNames, pm.mix)
let dur = pm.durationMs
let volScale = 0.14 + 0.86 * Double(masterTaper(master)).squareRoot().squareRoot()
let adjusting = axis == 2
TimelineView(.animation(paused: !pm.playWhenReady || scrub != nil)) { _ in
let pos = scrub.map { Int64($0 * Double(dur)) } ?? pm.positionMs()
let frac = dur > 0 ? min(max(Double(pos) / Double(dur), 0), 1) : 0
VStack(spacing: 4) {
Canvas { ctx, size in
let barW = 2.7, pitch = 4.6
let count = max(8, Int(size.width / pitch))
let gap = (size.width - Double(count) * barW) / Double(count - 1)
let maxH = size.height, minH = barW, cy = size.height / 2
if adjusting {
let gh = maxH * volScale / 2
var path = Path()
path.move(to: CGPoint(x: 0, y: cy - gh)); path.addLine(to: CGPoint(x: size.width, y: cy - gh))
path.move(to: CGPoint(x: 0, y: cy + gh)); path.addLine(to: CGPoint(x: size.width, y: cy + gh))
ctx.stroke(path, with: .color(p.label2.opacity(0.5)), lineWidth: 1)
}
var playedPath = Path(), restPath = Path()
for i in 0..<count {
var a: Float = 0.28
if let env, !env.isEmpty {
let from = i * env.count / count
let to = min(max(from + 1, (i + 1) * env.count / count), env.count)
a = env[from..<to].max() ?? 0
}
let h = minH + (maxH - minH) * Double(a) * volScale
let x = Double(i) * (barW + gap)
let r = CGRect(x: x, y: cy - h / 2, width: barW, height: h)
if (x + barW / 2) / size.width <= frac { playedPath.addRoundedRect(in: r, cornerSize: CGSize(width: barW / 2, height: barW / 2)) }
else { restPath.addRoundedRect(in: r, cornerSize: CGSize(width: barW / 2, height: barW / 2)) }
}
ctx.fill(playedPath, with: .color(played))
ctx.fill(restPath, with: .color(unplayed))
}
.frame(height: 56)
.onGeometryChange(for: Double.self) { Double($0.size.width) } action: { barWidth = max($0, 1) }
.animation(.easeOut(duration: 0.09), value: master)
.contentShape(Rectangle())
.gesture(drag(dur: dur))
ZStack {
HStack {
Text(formatDuration(pos / 1000 * 1000))
Spacer()
Text("-" + formatDuration(max(0, dur - pos / 1000 * 1000)))
}
.font(.caption.weight(.medium).monospacedDigit())
if adjusting {
HStack(spacing: 4) {
Image(systemName: master <= 0.001 ? "speaker.slash.fill" : "speaker.wave.2.fill").font(.system(size: 11))
Text("\(Int(master * 100))%").font(.caption.weight(.semibold).monospacedDigit())
}
} else if let centerLabel {
Text(centerLabel).font(.caption2.bold()).lineLimit(1)
}
}
.foregroundStyle(p.label2)
.frame(height: 18)
}
}
.sensoryFeedback(.impact(weight: .light), trigger: haptic)
}
private func drag(dur: Int64) -> some Gesture {
let pm = g.playback
return DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged { v in
let t = v.translation
if axis == 0, abs(t.width) > 8 || abs(t.height) > 8 {
axis = abs(t.width) >= abs(t.height) ? 1 : 2
startVol = pm.master
lastStep = Int(startVol * 20)
haptic += 1
}
switch axis {
case 1: scrub = width(v) > 0 ? min(max(v.location.x / width(v), 0), 1) : 0
case 2:
let nv = min(max(startVol - Float(t.height / (56 * 4.5)), 0), 1)
let step = Int(nv * 20)
if step != lastStep {
lastStep = step
if nv == 0 || nv == 1 { haptic += 1 }
}
pm.setMaster(nv)
default: break
}
}
.onEnded { v in
switch axis {
case 0: pm.seekTo(Int64(min(max(v.location.x / width(v), 0), 1) * Double(dur)))
case 1: if let scrub { pm.seekTo(Int64(scrub * Double(dur))) }
default: break
}
scrub = nil
axis = 0
}
}
private func width(_ v: DragGesture.Value) -> Double { barWidth }
}
// ---- stem mixer -----------------------------------------------------------------------------
/// Meter levels with a quick attack and ~130 ms fall, timed in seconds so they look the same
/// at 60 and 120 Hz. A plain class, so stepping it each frame doesn't invalidate views.
private final class Meters {
var raw = [Float](repeating: 0, count: MeterHistory.maxStems)
var shown: [Float] = []
var last: Int64 = 0
func step(_ engine: StemEngine?, _ n: Int) -> [Float] {
if shown.count != n { shown = Array(repeating: 0, count: n) }
let now = monoNanos()
let fall: Float = last == 0 ? 0 : exp(-Float(now - last) / 1e9 / 0.13)
last = now
let live = engine?.levelsNow(&raw) ?? false
for i in 0..<n { shown[i] = max(live && i < raw.count ? raw[i] : 0, shown[i] * fall) }
return shown
}
}
private struct MixerPanel: View {
@Environment(\.palette) private var p
@State private var meters = Meters()
private var g: AppGraph { .shared }
var body: some View {
let pm = g.playback
let names = pm.stemNames
let stems = pm.source == .Stems
ScrollView {
VStack(alignment: .leading, spacing: 0) {
if pm.source == .Original {
Text("No stems for this song yet, so you're hearing the original file. stemd on your server separates new music on its own; once this one's done, the app picks it up within a minute and plays it with stems next time.")
.font(.footnote).foregroundStyle(p.label2)
.padding(14).frame(maxWidth: .infinity, alignment: .leading)
.background(p.fill, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding(.vertical, 8)
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
let current = MixPresets.normalized(pm.mix)
ForEach(MixPresets.all) { preset in
if let target = preset.build(names) {
PresetChip(text: preset.label, selected: MixPresets.normalized(target) == current) { pm.applyMix(target) }
}
}
}
.padding(.vertical, 8)
}
}
TimelineView(.animation(paused: !stems || !pm.playWhenReady)) { _ in
let levels = stems ? meters.step(pm.engine, names.count) : []
VStack(spacing: 0) {
ForEach(Array(names.enumerated()), id: \.element) { i, name in
StemFader(name: name, setting: pm.mix[name] ?? StemSetting(), level: i < levels.count ? levels[i] : 0, enabled: stems)
}
}
}
p.separator.frame(height: 0.5).padding(.vertical, 10)
HStack {
Text("Master").font(.headline).foregroundStyle(p.label)
Spacer()
Text("\(Int(pm.master * 100))%").font(.footnote.monospacedDigit()).foregroundStyle(p.label2)
}
HStack(spacing: 8) {
Image(systemName: "speaker.fill").font(.system(size: 13)).foregroundStyle(p.label2)
FaderSlider(value: pm.master, fill: p.label, onChange: pm.setMaster)
Image(systemName: "speaker.wave.3.fill").font(.system(size: 14)).foregroundStyle(p.label2)
}
Spacer(minLength: 16)
}
.padding(.horizontal, 24)
}
.scrollIndicators(.hidden)
}
}
/// [Chip] in the sheet's own colours: selected is the label colour, so it reads on any tint.
private struct PresetChip: 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 ? p.bg : p.label)
.padding(.horizontal, 14).frame(height: 32)
.background(selected ? p.label : p.fill, in: Capsule())
}
.buttonStyle(.plain)
.sensoryFeedback(.selection, trigger: selected)
}
}
private struct StemFader: View {
let name: String
let setting: StemSetting
let level: Float
let enabled: Bool
@Environment(\.palette) private var p
private var pm: PlaybackManager { AppGraph.shared.playback }
var body: some View {
let color = Color(hex: Stems.color(name))
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 8) {
Circle().fill(setting.muted ? p.label3 : color).frame(width: 10, height: 10)
Text(Stems.label(name)).font(.headline).foregroundStyle(setting.muted ? p.label2 : p.label)
Spacer()
Text("\(Int(stemTaper(setting.level) * 100))%").font(.footnote.monospacedDigit()).foregroundStyle(p.label2).padding(.trailing, 4)
if enabled {
ToggleBox(text: "M", on: setting.muted, onColor: p.red) { pm.toggleMute(name) }
ToggleBox(text: "S", on: setting.solo, onColor: Color(hex: 0xFFCC00)) { pm.toggleSolo(name) }
}
}
FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled) {
pm.setStemLevel(name, $0)
}
// Live level under the fader, -48..0 dBFS.
GeometryReader { geo in
let f = CGFloat(min(1, max(0, (20 * log10(level + 1e-5) + 48) / 48)))
ZStack(alignment: .leading) {
Capsule().fill(p.fill)
Capsule().fill(color).frame(width: geo.size.width * f)
}
}
.frame(height: 3)
.padding(.horizontal, 12)
}
.padding(.top, 10)
}
}
private struct ToggleBox: View {
let text: String
let on: Bool
let onColor: Color
let action: () -> Void
@Environment(\.palette) private var p
var body: some View {
Button(action: action) {
Text(text).font(.footnote.bold()).foregroundStyle(on ? .black : p.label2)
.frame(width: 30, height: 26)
.background(on ? onColor : p.fill, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
}
.buttonStyle(.plain)
}
}
/// An iOS-style slider with a coloured fill and, optionally, a detent that catches the thumb
/// near one value (unity gain) with a tick.
struct FaderSlider: View {
let value: Float
var fill: Color
var detent: Float?
var enabled = true
var thumb: CGFloat = 24
let onChange: (Float) -> Void
@Environment(\.palette) private var p
@State private var start: Float?
@State private var caught = false
init(value: Float, fill: Color, detent: Float? = nil, enabled: Bool = true, thumb: CGFloat = 24, onChange: @escaping (Float) -> Void) {
self.value = value; self.fill = fill; self.detent = detent; self.enabled = enabled; self.thumb = thumb; self.onChange = onChange
}
var body: some View {
GeometryReader { geo in
let track = max(geo.size.width - thumb, 1)
let x = CGFloat(min(max(value, 0), 1)) * track
ZStack(alignment: .leading) {
Capsule().fill(p.fillStrong).frame(height: 4).padding(.horizontal, thumb / 2)
Capsule().fill(fill).frame(width: x + thumb / 2, height: 4).padding(.leading, thumb / 2 - 0.01)
if let detent {
p.label3.frame(width: 2, height: 10).offset(x: CGFloat(detent) * track + thumb / 2 - 1)
}
Circle().fill(.white).frame(width: thumb, height: thumb)
.shadow(color: .black.opacity(0.25), radius: 3, y: 1)
.offset(x: x)
}
.frame(height: geo.size.height)
.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance: 0).onChanged { g in
guard enabled else { return }
if start == nil { start = value }
var v = min(max((start ?? value) + Float(g.translation.width / track), 0), 1)
if let detent {
let near = abs(v - detent) < 0.025
if near { v = detent }
if near != caught { caught = near }
}
onChange(v)
}.onEnded { _ in start = nil })
}
.frame(height: 36)
.opacity(enabled ? 1 : 0.4)
.sensoryFeedback(.selection, trigger: caught) { _, now in now }
}
}
// ---- queue ----------------------------------------------------------------------------------
private struct QueuePanel: View {
@Environment(\.palette) private var p
private var g: AppGraph { .shared }
var body: some View {
let pm = g.playback
let first = pm.index + 1
let upcoming = Array(pm.queue.dropFirst(max(first, 0)))
VStack(spacing: 0) {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 1) {
Text("Playing Next").font(.headline).foregroundStyle(p.label)
Text(songs(upcoming.count)).font(.footnote).foregroundStyle(p.label2)
}
Spacer()
QueueToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
QueueToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat)
if !upcoming.isEmpty {
Button("Clear") { pm.clearUpNext() }.foregroundStyle(p.label2).padding(.leading, 6)
}
}
.padding(.horizontal, 24).padding(.vertical, 6)
// Hold a row to drag it; swipe for Play Next or Remove.
List {
ForEach(Array(upcoming.enumerated()), id: \.element.uid) { i, q in
HStack(spacing: 12) {
Artwork(thumb: q.track.thumb, size: 44, corner: 5)
VStack(alignment: .leading, spacing: 1) {
Text(q.track.title).foregroundStyle(p.label).lineLimit(1)
Text(q.track.artist).font(.subheadline).foregroundStyle(p.label2).lineLimit(1)
}
Spacer(minLength: 6)
Image(systemName: "line.3.horizontal").foregroundStyle(p.label3)
}
.frame(height: 60)
.contentShape(Rectangle())
.onTapGesture { pm.skipTo(first + i) }
.listRowInsets(EdgeInsets(top: 0, leading: 24, bottom: 0, trailing: 16))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.swipeActions(edge: .trailing) {
Button(role: .destructive) { pm.removeAt(first + i) } label: { Label("Remove", systemImage: "trash") }
}
.swipeActions(edge: .leading) {
Button { pm.move(first + i, first) } label: { Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") }
.tint(.indigo)
}
}
.onMove { from, to in
guard let f = from.first else { return }
pm.move(first + f, first + (to > f ? to - 1 : to))
}
if upcoming.isEmpty {
Text("Nothing queued. Use Add to Front of Queue or Add to End of Queue on any song.")
.font(.subheadline).foregroundStyle(p.label2)
.listRowBackground(Color.clear).listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24))
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
}
}
private struct QueueToggle: View {
let symbol: String
let on: Bool
let action: () -> Void
@Environment(\.palette) private var p
var body: some View {
Button(action: action) {
Image(systemName: symbol).font(.system(size: 15, weight: .semibold)).foregroundStyle(on ? p.bg : p.label2)
.frame(width: 36, height: 30)
.background(on ? p.label : p.fill, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
}
.buttonStyle(.plain)
}
}
// ---- mini player ----------------------------------------------------------------------------
struct MiniPlayer: View {
@Environment(Ui.self) private var ui
@Environment(\.palette) private var p
@Environment(\.displayScale) private var scale
private var g: AppGraph { .shared }
var body: some View {
let pm = g.playback
if let t = pm.current?.track {
HStack(spacing: 0) {
Artwork(thumb: t.thumb, size: 46, corner: 6).padding(.trailing, 12)
VStack(alignment: .leading, spacing: 1) {
Text(t.title).font(.subheadline.weight(.medium)).foregroundStyle(p.label).lineLimit(1)
Text(t.artist).font(.footnote).foregroundStyle(p.label2).lineLimit(1)
}
Spacer(minLength: 6)
Button(action: pm.togglePlay) {
ZStack {
if pm.buffering && pm.playWhenReady { ProgressView() }
else { Image(systemName: pm.playWhenReady ? "pause.fill" : "play.fill").font(.system(size: 24)) }
}
.foregroundStyle(p.label).frame(width: 48, height: 48).contentShape(Rectangle())
}
.buttonStyle(PressFade())
Button(action: pm.next) {
Image(systemName: "forward.fill").font(.system(size: 22)).foregroundStyle(p.label).frame(width: 48, height: 48).contentShape(Rectangle())
}
.buttonStyle(PressFade())
}
.padding(.leading, 16).padding(.trailing, 8)
.frame(height: 64)
.background(.bar)
.overlay(alignment: .top) { p.separator.frame(height: 1 / scale) }
.overlay(alignment: .bottom) {
// Hairline progress along the bottom edge.
TimelineView(.animation(minimumInterval: 0.25, paused: !pm.playWhenReady)) { _ in
let d = pm.durationMs
let f = d > 0 ? min(max(Double(pm.positionMs()) / Double(d), 0), 1) : 0
GeometryReader { geo in p.accent.frame(width: geo.size.width * f, height: 2) }.frame(height: 2)
}
}
.contentShape(Rectangle())
.onTapGesture { ui.openNowPlaying() }
.gesture(DragGesture(minimumDistance: 20).onEnded { v in if v.translation.height < -30 { ui.openNowPlaying() } })
.actionsMenu(ui.menu.track(t))
}
}
}