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 : 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.. [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.. 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.. 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)) FaderRow(dot: setting.muted ? p.label3 : color, label: Stems.label(name), percent: Int(stemTaper(setting.level) * 100), dim: setting.muted) { 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: View { let dot: Color? let label: String let percent: Int let dim: Bool @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: 46) } } 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) } } } } } }