Files
stemify/ios/Stemify/UI/Screens/NowPlayingScreen.swift
T
2026-11-10 12:36:52 -08:00

1270 lines
58 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ----------------------------------------------------------------------------
/// Full screen, Now Playing owns the window's color scheme. Docked beside the library it only
/// sets its own pane's, since preferredColorScheme would repaint the library side too.
private struct NpScheme: ViewModifier {
var dark: Bool
var docked: Bool
func body(content: Content) -> some View {
if docked { content.environment(\.colorScheme, dark ? .dark : .light) }
else { content.preferredColorScheme(dark ? .dark : .light) }
}
}
struct NowPlayingScreen: View {
/// Shown beside the library on an unfolded iPhone Duo rather than as a full-screen cover:
/// no grabber, no drag to close, and its color scheme stays inside the pane.
var docked = false
@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?
/// How far the screen is being dragged down to close it.
@State private var drop: CGFloat = 0
/// Set once the phone has unfolded, so it's an iPhone Duo: its screens are shorter for their
/// width than a regular iPhone's, so the controls pack tighter and the art gets the room.
@AppStorage("duoSeen") private var duoSeen = false
private var tight: Bool { docked || duoSeen }
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: tight ? 520 : 560)
}
.frame(maxWidth: .infinity)
}
} else {
Text("Nothing Playing").font(.title3.weight(.semibold)).foregroundStyle(c.label2)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.padding(.top, docked ? 4 : tight ? 14 : 20)
.padding(.bottom, tight ? 4 : 8)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(c.bg.ignoresSafeArea())
.overlay(alignment: .top) {
// Stands in for the sheet's grabber: tap or drag down to close.
if !docked { Capsule().fill(c.label3).frame(width: 36, height: 5)
.frame(width: 120, height: 20).contentShape(Rectangle())
.onTapGesture { dismiss() }
.gesture(screenDrag) }
}
.offset(y: drop)
// On the art and the mixer: drag down anywhere to close, swipe up anywhere for the
// queue. The queue and lyrics scroll, so there it's the song header or the grabber.
.simultaneousGesture(screenDrag, including: panel == .Art || panel == .Mixer ? .all : .subviews)
.environment(\.palette, c)
.modifier(NpScheme(dark: c.dark, docked: docked))
.tint(c.label)
.animation(.easeInOut(duration: 0.3), value: tint)
.presentationBackground(.clear)
.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, inset: tight ? 18 : 28).contentShape(Rectangle())
case .Mixer: VStack(spacing: 0) { if header { MiniHeader(track: track) }; MixerPanel() }
case .Queue: VStack(spacing: 0) { if header { MiniHeader(track: track).contentShape(Rectangle()).gesture(screenDrag) }; QueuePanel(close: { show(.Queue) }) }
case .Lyrics: VStack(spacing: 0) { if header { MiniHeader(track: track).contentShape(Rectangle()).gesture(screenDrag) }; 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, tight ? 8 : 14)
.transition(.opacity.combined(with: .move(edge: .top)))
}
ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track))
.padding(.top, tight ? (wide ? 14 : 18) : 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, hold: pm.nextAlbum)
Spacer()
SideToggle(symbol: pm.repeatMode == .One ? "repeat.1" : "repeat", on: pm.repeatMode != .Off, action: pm.cycleRepeat)
}
.frame(height: tight ? 76 : 84)
.padding(.top, tight ? 4 : 14)
HStack {
let hidden = NowPlayingButton.hidden(g.settings.hiddenNowPlaying)
if !hidden.contains(.Mixer) { PanelToggle(symbol: "slider.vertical.3", on: docked ? ui.deck : panel == .Mixer) { show(.Mixer) } }
if !hidden.contains(.Speed) { SpeedMenu() }
if !hidden.contains(.Route) { Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity) } else { Spacer() }
if !hidden.contains(.AirPlay) { AirPlayButton(tint: UIColor(c.label2), active: UIColor(c.accent)).frame(width: 40, height: 40) }
if !hidden.contains(.Karaoke) { PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() } }
}
.frame(height: 48)
// Swipe up on the art (or here) for the queue. Docked, the queue is already beside it.
if !docked { Button { show(.Queue) } label: {
VStack(spacing: 0) {
Image(systemName: panel == .Queue ? "chevron.compact.down" : "chevron.compact.up").font(.system(size: 22, weight: .medium))
if panel != .Queue && !tight { Text("Playing Next").font(.caption2.weight(.medium)) }
}
.foregroundStyle(c.label3).frame(maxWidth: .infinity, minHeight: tight ? 24 : 30).contentShape(Rectangle())
}
.buttonStyle(PressFade())
.gesture(swipe(up: panel != .Queue)) }
}
.padding(.horizontal, tight ? 20 : 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) {
// Docked, the stems and the queue have the other side of the hinge to themselves.
if docked && (to == .Mixer || to == .Queue) { withAnimation(.snappy) { ui.showDeck() }; return }
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = panel == to ? .Art : to }
}
/// Swipe up for the queue, down to go back to the art.
private func swipe(up: Bool) -> some Gesture {
DragGesture(minimumDistance: 20).onEnded { v in
let dy = v.translation.height
guard up ? dy < -50 : dy > 50, abs(dy) > abs(v.translation.width) else { return }
if up && !g.settings.swipeUpQueue { return }
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = up ? .Queue : .Art }
}
}
private var screenDrag: some Gesture {
DragGesture(minimumDistance: 20)
.onChanged { v in
let dy = v.translation.height
drop = !docked && dy > 0 && dy > abs(v.translation.width) ? dy : 0
}
.onEnded { v in
let dy = v.translation.height
let vertical = abs(dy) > abs(v.translation.width)
if !docked && vertical && (dy > 120 || v.predictedEndTranslation.height > 300) { dismiss(); return }
withAnimation(.spring(duration: 0.3, bounce: 0.1)) { drop = 0 }
// Never from the stems: a slightly upward fader drag or scroll there kept opening the queue.
if vertical && dy < -50 && panel != .Mixer && g.settings.swipeUpQueue {
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = .Queue }
}
}
}
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
var hold: (() -> Void)?
@Environment(\.palette) private var p
var body: some View {
HoldButton(action: action, hold: hold) {
Image(systemName: symbol).font(.system(size: size)).foregroundStyle(p.label).frame(width: 72, height: 72).contentShape(Rectangle())
}
}
}
/// A PressFade button that runs [hold] on a long press instead of the tap (hold Next to skip the album).
struct HoldButton<Label: View>: View {
let action: () -> Void
var hold: (() -> Void)?
@ViewBuilder let label: () -> Label
@State private var held = false
@State private var fired = 0
var body: some View {
Button { if held { held = false } else { action() } } label: { label() }
.buttonStyle(PressFade())
.simultaneousGesture(LongPressGesture(minimumDuration: 0.5)
.onChanged { _ in held = false }
.onEnded { _ in
guard let hold else { return }
held = true; fired += 1; hold()
})
.sensoryFeedback(.impact(weight: .medium), trigger: fired)
}
}
/// 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 inset: CGFloat = 28
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, inset).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
// Bars are discrete and the clock shows seconds, so a few redraws a second look the same
// as 120. A drag redraws through [scrub] and the volume through [master].
TimelineView(.animation(minimumInterval: 0.25, 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: 12) {
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 {
/// The iPhone Duo deck: each stem's name sits over its fader so the fader gets the row's full
/// width, with a bigger thumb for fine moves.
var roomy = false
@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 stem rows instead of leaving it all at the bottom.
let count = CGFloat(max(names.count, 1))
let rowH = roomy ? min(80, max(Self.roomyRow, (geo.size.height - Self.roomyChrome) / count))
: min(62, max(46, (geo.size.height - 64) / count))
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) }
}
PresetChip(text: "New Preset", icon: "plus", selected: false) {
ui.overlays.prompt("Save Preset", "Saves this mix for every song.", "Save", placeholder: "Preset name") { name in
let n = name.trimmingCharacters(in: .whitespaces)
guard !n.isEmpty else { return }
CustomPreset.add(n, pm.mix)
ui.overlays.hud("Preset saved", "slider.horizontal.3")
}
}
let s = g.settings
ForEach(MixPresets.bar(s)) { preset in
if let target = preset.build(names) {
let on = MixPresets.normalized(target) == current
let locked = s.lockedPreset == preset.id
// Tapping the mix that's on goes back to the full song.
PresetChip(text: preset.label, icon: locked ? "lock.fill" : nil, selected: on) { pm.applyMix(on ? [:] : target) }
.contextMenu {
if locked {
Button("Unlock", systemImage: "lock.open") { s.lockedPreset = "" }
} else {
Button("Lock for All Songs", systemImage: "lock") { s.lockedPreset = preset.id; pm.applyMix(target) }
}
Button("Edit Stems", systemImage: "slider.horizontal.3") { ui.showInSettings(.StemEditor) }
if preset.custom {
Button("Delete Preset", systemImage: "trash", role: .destructive) { CustomPreset.remove(preset.label) }
}
}
}
}
}
.padding(.vertical, 8)
}
}
// The meters' fall is timed, so 30 fps looks the same and costs a quarter of 120.
TimelineView(.animation(minimumInterval: 1 / 30, 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, roomy: roomy)
}
}
}
Spacer(minLength: roomy ? 8 : 16)
}
.padding(.horizontal, 24)
}
.scrollIndicators(.hidden)
// Holds still when everything fits, so a drag down closes Now Playing instead.
.scrollBounceBehavior(.basedOnSize)
}
}
}
extension MixerPanel {
static let roomyRow: CGFloat = 66
/// Preset chips plus the bottom gap.
static let roomyChrome: CGFloat = 56
/// Height that shows the presets and every stem without scrolling, roomy rows.
static func roomyHeight(_ stems: Int) -> CGFloat { roomyChrome + CGFloat(stems) * roomyRow }
}
/// [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
var roomy = false
@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, stacked: roomy) {
VStack(spacing: 0) {
FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled, thumb: roomy ? 26 : 24) {
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, big: roomy) { pm.toggleMute(name) }
ToggleBox(text: "S", on: setting.solo, onColor: Color(hex: 0xFFCC00), big: roomy) { 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).
struct FaderRow<Fader: View, Trailing: View>: View {
let dot: Color?
let label: String
let percent: Int
let dim: Bool
var height: CGFloat = 46
/// Name and level on a line of their own over the fader, so the fader runs the full width.
var stacked = false
@ViewBuilder let fader: Fader
@ViewBuilder let trailing: Trailing
@Environment(\.palette) private var p
var body: some View {
if stacked {
// Bottom-aligned with a gap under, so each name sits clear of the meter above it.
VStack(alignment: .leading, spacing: 2) {
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)
Text("\(percent)%").font(.caption.monospacedDigit()).foregroundStyle(p.label2)
Spacer(minLength: 0)
}
.frame(height: 18)
HStack(spacing: 8) { fader; trailing }
}
.padding(.bottom, 4)
.frame(height: height, alignment: .bottom)
} else {
row
}
}
private var row: 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)
}
}
struct ToggleBox: View {
let text: String
let on: Bool
let onColor: Color
var big = false
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: big ? 34 : 30, height: big ? 28 : 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
}
private var bar: CGFloat { thumb >= 26 ? 5 : 4 }
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: bar).padding(.horizontal, thumb / 2)
Capsule().fill(fill).frame(width: x + thumb / 2, height: bar).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: max(36, thumb + 8))
.opacity(enabled ? 1 : 0.4)
.sensoryFeedback(.selection, trigger: caught) { _, now in now }
}
}
// ---- deck -----------------------------------------------------------------------------------
/// Unfolded iPhone Duo: the stems over the queue, on the other side of the hinge from Now Playing.
struct NowPlayingDeck: View {
@Environment(\.palette) private var base
@State private var tint: Color?
private var g: AppGraph { .shared }
var body: some View {
let c = tint.map { base.tinted($0) } ?? base
let track = g.playback.current?.track
GeometryReader { geo in
if track != nil {
VStack(spacing: 0) {
Text("Stems").font(.headline).foregroundStyle(c.label)
.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 24)
// As much as the stems need to show without scrolling, leaving the queue a few rows.
MixerPanel(roomy: true).frame(height: min(MixerPanel.roomyHeight(max(g.playback.stemNames.count, 4)), geo.size.height * 0.72))
Rectangle().fill(c.label3.opacity(0.35)).frame(height: 0.5).padding(.horizontal, 24).padding(.bottom, 8)
QueuePanel()
}
} else {
Text("Nothing Playing").font(.title3.weight(.semibold)).foregroundStyle(c.label2)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.padding(.top, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(c.bg.ignoresSafeArea())
.environment(\.palette, c)
.modifier(NpScheme(dark: c.dark, docked: true))
.tint(c.label)
.animation(.easeInOut(duration: 0.3), value: tint)
.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)
}
}
}
// ---- queue ----------------------------------------------------------------------------------
private struct QueuePanel: View {
var close: () -> Void = {}
@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)
.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance: 20).onEnded { v in if v.translation.height > 50 { close() } })
// Drag the handles to reorder; tap to jump, long press 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)
}
.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)
.contextMenu {
Button { pm.move(first + i, first) } label: { Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") }
Button(role: .destructive) { pm.removeAt(first + i) } label: { Label("Remove", systemImage: "trash") }
}
}
.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 Play Next or Add to 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)
.environment(\.editMode, .constant(.active))
}
}
}
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.5, 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 {
HoldButton(action: pm.next, hold: pm.nextAlbum) {
Image(systemName: "forward.fill").font(.system(size: 20)).foregroundStyle(color).frame(width: 44, height: 44).contentShape(Rectangle())
}
}
}
}
/// 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) } }
}
}
}
}