1076 lines
47 KiB
Swift
1076 lines
47 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
|
||
// Wide when there's no room to stack art over controls: landscape, or iPhone Duo's
|
||
// outer display. Tall layouts (including the unfolded Duo) cap the controls' width.
|
||
GeometryReader { geo in
|
||
let wide = geo.size.width > geo.size.height * 0.9
|
||
if let track {
|
||
if wide {
|
||
HStack(spacing: 0) {
|
||
panelView(track, header: false)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.transition(.opacity.combined(with: .scale(scale: 0.96)))
|
||
controls(track, wide: true)
|
||
.frame(width: min(geo.size.width * 0.48, 440))
|
||
.frame(maxHeight: .infinity)
|
||
}
|
||
} else {
|
||
VStack(spacing: 0) {
|
||
panelView(track, header: true)
|
||
.frame(maxHeight: .infinity)
|
||
.transition(.opacity.combined(with: .scale(scale: 0.96)))
|
||
controls(track, wide: false).frame(maxWidth: 560)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
} else {
|
||
Text("Nothing Playing").font(.title3.weight(.semibold)).foregroundStyle(c.label2)
|
||
.frame(maxWidth: .infinity, 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)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func panelView(_ track: Track, header: Bool) -> some View {
|
||
switch panel {
|
||
case .Art: ArtPanel(thumb: track.thumb, playing: g.playback.playWhenReady)
|
||
case .Mixer: VStack(spacing: 0) { if header { MiniHeader(track: track) }; MixerPanel() }
|
||
case .Queue: VStack(spacing: 0) { if header { MiniHeader(track: track) }; QueuePanel() }
|
||
case .Lyrics: VStack(spacing: 0) { if header { MiniHeader(track: track) }; LyricsPanel(track: track) }
|
||
}
|
||
}
|
||
|
||
private func controls(_ track: Track, wide: Bool) -> some View {
|
||
let pm = g.playback
|
||
let c = tint.map { base.tinted($0) } ?? base
|
||
return VStack(spacing: 0) {
|
||
if wide || 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, wide ? 24 : panel == .Art ? 34 : 30)
|
||
HStack {
|
||
SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
|
||
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()
|
||
SideToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat)
|
||
}
|
||
.frame(height: 84)
|
||
.padding(.top, 14)
|
||
HStack {
|
||
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
|
||
SpeedMenu()
|
||
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
|
||
AirPlayButton(tint: UIColor(c.label2), active: UIColor(c.accent)).frame(width: 40, height: 40)
|
||
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 down 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())
|
||
}
|
||
}
|
||
|
||
/// Shuffle and repeat, either side of the transport. A dot underneath when on.
|
||
private struct SideToggle: View {
|
||
let symbol: String
|
||
let on: Bool
|
||
let action: () -> Void
|
||
@Environment(\.palette) private var p
|
||
var body: some View {
|
||
Button(action: action) {
|
||
VStack(spacing: 4) {
|
||
Image(systemName: symbol).font(.system(size: 19, weight: .semibold)).contentTransition(.symbolEffect(.replace))
|
||
Circle().frame(width: 4, height: 4).opacity(on ? 1 : 0)
|
||
}
|
||
.foregroundStyle(on ? p.label : p.label3)
|
||
.frame(width: 40, height: 56)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(PressFade())
|
||
.sensoryFeedback(.selection, trigger: symbol + "\(on)")
|
||
}
|
||
}
|
||
|
||
/// Playback speed picker; shows the current speed.
|
||
private struct SpeedMenu: View {
|
||
private var g: AppGraph { .shared }
|
||
@Environment(\.palette) private var p
|
||
var body: some View {
|
||
let pm = g.playback
|
||
Menu {
|
||
Picker("Speed", selection: Binding(get: { pm.speed }, set: { pm.setSpeed($0) })) {
|
||
ForEach(PlaybackManager.speeds, id: \.self) { Text(Self.label($0)).tag($0) }
|
||
}
|
||
} label: {
|
||
Text(Self.label(pm.speed)).font(.system(size: 14, weight: .semibold).monospacedDigit())
|
||
.foregroundStyle(pm.speed == 1 ? p.label2 : p.bg)
|
||
.frame(minWidth: 40, minHeight: 40)
|
||
.padding(.horizontal, 2)
|
||
.background(pm.speed == 1 ? .clear : p.label, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||
}
|
||
.menuOrder(.fixed)
|
||
}
|
||
static func label(_ v: Double) -> String {
|
||
(v == v.rounded() ? String(Int(v)) : String(format: "%g", v)) + "×"
|
||
}
|
||
}
|
||
|
||
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
|
||
GeometryReader { geo in
|
||
// Spread spare height across the rows (stems + master) instead of leaving it all at the bottom.
|
||
let rowH = min(62, max(46, (geo.size.height - 80) / CGFloat(names.count + 1)))
|
||
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, height: rowH)
|
||
}
|
||
}
|
||
}
|
||
p.separator.frame(height: 0.5).padding(.vertical, 6)
|
||
FaderRow(dot: nil, label: "Master", percent: Int(pm.master * 100), dim: false, height: rowH) {
|
||
FaderSlider(value: pm.master, fill: p.label, onChange: pm.setMaster)
|
||
} trailing: { EmptyView() }
|
||
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
|
||
var height: CGFloat = 46
|
||
@Environment(\.palette) private var p
|
||
private var pm: PlaybackManager { AppGraph.shared.playback }
|
||
|
||
var body: some View {
|
||
let color = Color(hex: Stems.color(name))
|
||
FaderRow(dot: setting.muted ? p.label3 : color, label: Stems.label(name), percent: Int(stemTaper(setting.level) * 100), dim: setting.muted, height: height) {
|
||
VStack(spacing: 0) {
|
||
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)
|
||
}
|
||
} trailing: {
|
||
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) }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One mixer line: name, fader, level and buttons side by side, so all six stems and the
|
||
/// master fit without scrolling (the faders grab drags, so the list can't scroll well).
|
||
private struct FaderRow<Fader: View, Trailing: View>: View {
|
||
let dot: Color?
|
||
let label: String
|
||
let percent: Int
|
||
let dim: Bool
|
||
var height: CGFloat = 46
|
||
@ViewBuilder let fader: Fader
|
||
@ViewBuilder let trailing: Trailing
|
||
@Environment(\.palette) private var p
|
||
var body: some View {
|
||
HStack(spacing: 8) {
|
||
HStack(spacing: 6) {
|
||
if let dot { Circle().fill(dot).frame(width: 8, height: 8) }
|
||
Text(label).font(.subheadline.weight(.semibold)).foregroundStyle(dim ? p.label2 : p.label)
|
||
.lineLimit(1).minimumScaleFactor(0.8)
|
||
}
|
||
.frame(width: 70, alignment: .leading)
|
||
fader
|
||
Text("\(percent)%").font(.caption.monospacedDigit()).foregroundStyle(p.label2)
|
||
.frame(width: 36, alignment: .trailing)
|
||
trailing
|
||
}
|
||
.frame(height: height)
|
||
}
|
||
}
|
||
|
||
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 {
|
||
if let t = g.playback.current?.track {
|
||
if #available(iOS 26.1, *) { GlassMiniPlayer(track: t) } else { card(t) }
|
||
}
|
||
}
|
||
|
||
/// Before iOS 26: a card floating over each tab.
|
||
@ViewBuilder private func card(_ t: Track) -> some View {
|
||
let pm = g.playback
|
||
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)
|
||
MiniTransport(color: p.label, next: true)
|
||
}
|
||
.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))
|
||
}
|
||
}
|
||
|
||
/// iOS 26: lives in the tab bar's Liquid Glass accessory, like Music. Shrinks to title and play when the bar collapses.
|
||
@available(iOS 26.1, *)
|
||
private struct GlassMiniPlayer: View {
|
||
let track: Track
|
||
@Environment(Ui.self) private var ui
|
||
@Environment(\.tabViewBottomAccessoryPlacement) private var placement
|
||
private var inline: Bool { placement == .inline }
|
||
|
||
var body: some View {
|
||
HStack(spacing: 10) {
|
||
Artwork(thumb: track.thumb, size: inline ? 28 : 34, corner: 6)
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Text(track.title).font(.subheadline.weight(.semibold)).lineLimit(1)
|
||
if !inline { Text(track.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
|
||
}
|
||
Spacer(minLength: 4)
|
||
MiniTransport(color: .primary, next: !inline)
|
||
}
|
||
.padding(.leading, 10).padding(.trailing, 6)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture { ui.openNowPlaying() }
|
||
.actionsMenu(ui.menu.track(track))
|
||
}
|
||
}
|
||
|
||
/// Play/pause (spinner while buffering) and next, for the mini player.
|
||
private struct MiniTransport: View {
|
||
let color: Color
|
||
let next: Bool
|
||
private var g: AppGraph { .shared }
|
||
|
||
var body: some View {
|
||
let pm = g.playback
|
||
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(color).frame(width: 44, height: 44).contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(PressFade())
|
||
if next {
|
||
Button(action: pm.next) {
|
||
Image(systemName: "forward.fill").font(.system(size: 20)).foregroundStyle(color).frame(width: 44, height: 44).contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(PressFade())
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 {
|
||
VStack(spacing: 0) {
|
||
// Soft leaves the singer in quietly as a guide.
|
||
HStack(spacing: 12) {
|
||
Text("Vocals").font(.subheadline.weight(.semibold)).foregroundStyle(p.label2)
|
||
Picker("Vocals", selection: Binding(get: { g.playback.karaokeVocals }, set: { g.playback.setKaraokeVocals($0) })) {
|
||
ForEach(KaraokeVocals.allCases, id: \.self) { Text(String(describing: $0)).tag($0) }
|
||
}
|
||
.pickerStyle(.segmented)
|
||
}
|
||
.padding(.horizontal, 28).padding(.top, 8)
|
||
if g.playback.karaokeVocals == .Soft {
|
||
let db = g.settings.karaokeSoftDb
|
||
HStack(spacing: 12) {
|
||
Text("Softness").font(.subheadline.weight(.semibold)).foregroundStyle(p.label2)
|
||
Slider(value: Binding(get: { Double(db) }, set: { g.playback.setKaraokeSoftDb(Int($0.rounded())) }), in: -30...(-3), step: 1)
|
||
Text("\(db) dB").font(.caption).monospacedDigit().foregroundStyle(p.label2).frame(width: 46, alignment: .trailing)
|
||
}
|
||
.padding(.horizontal, 28).padding(.top, 8)
|
||
}
|
||
content
|
||
}
|
||
.task(id: track.ratingKey) { await follow() }
|
||
}
|
||
|
||
@ViewBuilder private var content: 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)
|
||
}
|
||
|
||
private func follow() async {
|
||
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) } }
|
||
}
|
||
}
|
||
}
|
||
}
|