Files
stemify/ios/Stemify/UI/Screens/NowPlayingScreen.swift
T

1032 lines
45 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 ----------------------------------------------------------------------------
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 {
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)
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
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 {
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) } }
}
}
}
}