913 lines
40 KiB
Swift
913 lines
40 KiB
Swift
import SwiftUI
|
|
import UIKit
|
|
|
|
enum NpPanel { case Art, Mixer, Queue, Lyrics }
|
|
|
|
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() }
|
|
case .Lyrics: VStack(spacing: 0) { MiniHeader(track: track); LyricsPanel(track: track) }
|
|
}
|
|
}
|
|
.frame(maxHeight: .infinity)
|
|
.transition(.opacity.combined(with: .scale(scale: 0.96)))
|
|
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)
|
|
.onAppear { if g.playback.karaoke { panel = .Lyrics } }
|
|
.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)
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track))
|
|
.padding(.top, panel == .Art ? 34 : 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).contentTransition(.symbolEffect(.replace)) }
|
|
}
|
|
.animation(.snappy(duration: 0.25), value: pm.playWhenReady)
|
|
.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) { show(.Mixer) }
|
|
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
|
|
PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() }
|
|
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) }
|
|
}
|
|
.frame(height: 48)
|
|
}
|
|
.padding(.horizontal, 28)
|
|
}
|
|
|
|
/// Opens a panel, or goes back to the art if it's already open.
|
|
/// Karaoke: vocals out and lyrics up. If it's on but another panel is showing, brings the lyrics back first.
|
|
private func karaoke() {
|
|
let pm = g.playback
|
|
withAnimation(.spring(duration: 0.38, bounce: 0.12)) {
|
|
if pm.karaoke && panel != .Lyrics { panel = .Lyrics; return }
|
|
pm.setKaraoke(!pm.karaoke)
|
|
panel = pm.karaoke ? .Lyrics : .Art
|
|
}
|
|
}
|
|
|
|
private func show(_ to: NpPanel) {
|
|
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = panel == to ? .Art : to }
|
|
}
|
|
|
|
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.6 : 1).scaleEffect(configuration.isPressed ? 0.9 : 1)
|
|
.animation(configuration.isPressed ? .easeOut(duration: 0.1) : .spring(duration: 0.32, bounce: 0.45), 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(Ui.self) private var ui
|
|
@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)
|
|
let saved = pm.songMix.map(MixPresets.normalized)
|
|
let isSaved = saved == current
|
|
PresetChip(text: isSaved ? "Saved" : "Save", icon: isSaved ? "bookmark.fill" : "bookmark", selected: isSaved) {
|
|
if isSaved {
|
|
pm.forgetSongMix()
|
|
ui.overlays.hud("Removed this song's mix", "bookmark.slash")
|
|
} else {
|
|
pm.saveSongMix()
|
|
ui.overlays.hud("Mix saved for this song", "bookmark.fill")
|
|
}
|
|
}
|
|
if let saved, !isSaved {
|
|
PresetChip(text: "This Song", selected: false) { pm.applyMix(saved) }
|
|
}
|
|
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
|
|
var icon: String? = nil
|
|
let selected: Bool
|
|
let action: () -> Void
|
|
@Environment(\.palette) private var p
|
|
var body: some View {
|
|
Button(action: action) {
|
|
HStack(spacing: 5) {
|
|
if let icon { Image(systemName: icon) }
|
|
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: 44, corner: 8).padding(.trailing, 12)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(t.title).font(.subheadline.weight(.semibold)).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: 22)).contentTransition(.symbolEffect(.replace)) }
|
|
}
|
|
.animation(.snappy(duration: 0.25), value: pm.playWhenReady)
|
|
.foregroundStyle(p.label).frame(width: 44, height: 44).contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(PressFade())
|
|
Button(action: pm.next) {
|
|
Image(systemName: "forward.fill").font(.system(size: 20)).foregroundStyle(p.label).frame(width: 44, height: 44).contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(PressFade())
|
|
}
|
|
.padding(.leading, 8).padding(.trailing, 6)
|
|
.frame(height: 60)
|
|
.background {
|
|
// A solid card in the app's own colours instead of system glass.
|
|
let shape = RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
ZStack {
|
|
shape.fill(p.cell)
|
|
shape.fill(p.fill.opacity(p.dark ? 0.5 : 0))
|
|
shape.strokeBorder(p.separator, lineWidth: 1 / scale)
|
|
}
|
|
.shadow(color: .black.opacity(p.dark ? 0.5 : 0.12), radius: 12, y: 4)
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
// Thin progress line along the bottom of the card.
|
|
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)
|
|
}
|
|
.padding(.horizontal, 14)
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.bottom, 6)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { ui.openNowPlaying() }
|
|
.gesture(DragGesture(minimumDistance: 20).onEnded { v in if v.translation.height < -30 { ui.openNowPlaying() } })
|
|
.actionsMenu(ui.menu.track(t))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Synced lyrics follow the song (tap a line to jump there); plain ones just scroll.
|
|
private struct LyricsPanel: View {
|
|
let track: Track
|
|
@Environment(\.palette) private var p
|
|
@State private var lyrics: Lyrics?
|
|
@State private var loading = true
|
|
@State private var at = -1
|
|
private var g: AppGraph { .shared }
|
|
|
|
var body: some View {
|
|
Group {
|
|
if loading {
|
|
ProgressView()
|
|
} else if let l = lyrics, !l.lines.isEmpty {
|
|
lines(l)
|
|
} else {
|
|
Text(lyrics?.instrumental == true ? "Instrumental" : "No lyrics found")
|
|
.font(.title3.weight(.semibold)).foregroundStyle(p.label2)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.task(id: track.ratingKey) {
|
|
loading = true
|
|
at = -1
|
|
lyrics = await LyricsSource.shared.lyrics(track)
|
|
loading = false
|
|
guard let l = lyrics, l.synced else { return }
|
|
while !Task.isCancelled {
|
|
// A little early, so the line lights up as it's sung rather than after.
|
|
let pos = g.playback.positionMs() + 250
|
|
let i = (l.lines.lastIndex { $0.ms <= pos }) ?? -1
|
|
if i != at { withAnimation(.easeOut(duration: 0.25)) { at = i } }
|
|
try? await Task.sleep(nanoseconds: 150_000_000)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func lines(_ l: Lyrics) -> some View {
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: l.synced ? 18 : 6) {
|
|
ForEach(Array(l.lines.enumerated()), id: \.offset) { i, line in
|
|
Text(line.text.isEmpty ? "\u{266A}" : line.text)
|
|
.font(l.synced ? .title2.weight(.bold) : .body)
|
|
.foregroundStyle(!l.synced || i == at ? p.label : p.label3)
|
|
.scaleEffect(i == at ? 1.03 : 1, anchor: .leading)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { if l.synced { g.playback.seekTo(line.ms) } }
|
|
.id(i)
|
|
}
|
|
}
|
|
.padding(.horizontal, 28)
|
|
.padding(.vertical, l.synced ? 120 : 16)
|
|
}
|
|
.scrollIndicators(.hidden)
|
|
.onChange(of: at) { _, i in
|
|
if i >= 0 { withAnimation(.easeInOut(duration: 0.4)) { proxy.scrollTo(i, anchor: .center) } }
|
|
}
|
|
}
|
|
}
|
|
}
|