- MTAudioProcessingTap with lock-free ring buffer + pre-allocated vDSP FFT - Tap installs on readyToPlay (fixes 'no audio track' on live streams) - Shared tap serves FFT + Shazam simultaneously (no more tap conflict) - Ring buffer reset on removeTap (prevents stale FFT frames) - Simulation runs as placeholder until stream connects - All 6 paths verified: start, station change, goLive, seekBack, bg/fg, radio→music Bug fixes: - playerItem tracking in radioGoLive/radioSeekBack (was orphaned) - Combine sinks: .receive(on: .main) on all 4 notification handlers - ShazamRecognizer stopAll: audio session now actually restores after mic fallback - processTapBuffer/convertAndMatch methods restored (were dropped in refactor) Lyrics: - Bottom toolbar on overlay: Search, Timing (±0.1s/±0.5s inline), Edit - LyricsManager.globalOffset applied to syncToTime + wordProgress - openLyricsEditor notification wired to NowPlayingView UI: - Server selection button hidden when Dynamic Island active - Debug: Capture Audio Tap (5s WAV) with share sheet in Visualizer Settings"
379 lines
15 KiB
Swift
379 lines
15 KiB
Swift
import SwiftUI
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// LyricsOverlayView.swift
|
|
// Full-screen lyrics overlay for Now Playing. Replaces the cover art
|
|
// area when active. Visualizer stays at top, blurs where text begins.
|
|
//
|
|
// Performance:
|
|
// - TimelineView(.animation) only active when visible
|
|
// - Binary search for line/word lookup (O(log n))
|
|
// - No @Published for per-frame word updates
|
|
// - Canvas for karaoke gradient fill (zero SwiftUI diff)
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
struct LyricsOverlayView: View {
|
|
@ObservedObject var lyricsManager = LyricsManager.shared
|
|
@ObservedObject var audioPlayer = AudioPlayer.shared
|
|
@State private var showTimingAdjust = false
|
|
|
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
|
|
|
var body: some View {
|
|
if let lyrics = lyricsManager.currentLyrics, !lyrics.lines.isEmpty {
|
|
ZStack(alignment: .bottom) {
|
|
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !audioPlayer.isPlaying)) { timeline in
|
|
let now = audioPlayer.currentTime
|
|
let progress = lyricsManager.wordProgress(at: now)
|
|
|
|
lyricsContent(
|
|
lyrics: lyrics,
|
|
currentTime: now,
|
|
lineIndex: progress.lineIndex,
|
|
wordIndex: progress.wordIndex,
|
|
wordFraction: progress.wordFraction
|
|
)
|
|
}
|
|
|
|
// Bottom toolbar — search, timing, edit
|
|
lyricsToolbar(hasSynced: lyrics.hasSynced)
|
|
}
|
|
} else if lyricsManager.isLoading {
|
|
VStack {
|
|
Spacer()
|
|
ProgressView()
|
|
.tint(.white.opacity(0.5))
|
|
Text("Searching for lyrics...")
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(.white.opacity(0.4))
|
|
.padding(.top, 8)
|
|
Spacer()
|
|
}
|
|
} else {
|
|
VStack {
|
|
Spacer()
|
|
Image(systemName: "music.note")
|
|
.font(.system(size: 32, weight: .light))
|
|
.foregroundStyle(.white.opacity(0.15))
|
|
Text("No lyrics available")
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundStyle(.white.opacity(0.3))
|
|
.padding(.top, 8)
|
|
|
|
Button {
|
|
NotificationCenter.default.post(name: .openLyricsSearch, object: nil)
|
|
} label: {
|
|
Text("Search Lyrics")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(accentPink)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.background(
|
|
Capsule().fill(accentPink.opacity(0.15))
|
|
)
|
|
}
|
|
.padding(.top, 12)
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Lyrics Toolbar
|
|
|
|
@ViewBuilder
|
|
private func lyricsToolbar(hasSynced: Bool) -> some View {
|
|
VStack(spacing: 0) {
|
|
// Timing adjustment (expandable)
|
|
if showTimingAdjust && hasSynced {
|
|
HStack(spacing: 12) {
|
|
Button { lyricsManager.globalOffset -= 0.5 } label: {
|
|
Text("-0.5s")
|
|
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(Color.white.opacity(0.1))
|
|
.cornerRadius(8)
|
|
}
|
|
|
|
Button { lyricsManager.globalOffset -= 0.1 } label: {
|
|
Text("-0.1s")
|
|
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
|
.foregroundColor(.white.opacity(0.7))
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 6)
|
|
.background(Color.white.opacity(0.06))
|
|
.cornerRadius(8)
|
|
}
|
|
|
|
Text(String(format: "%+.1fs", lyricsManager.globalOffset))
|
|
.font(.system(size: 15, weight: .bold, design: .monospaced))
|
|
.foregroundColor(lyricsManager.globalOffset == 0 ? .white.opacity(0.5) : accentPink)
|
|
.frame(width: 60)
|
|
.onTapGesture { lyricsManager.globalOffset = 0 }
|
|
|
|
Button { lyricsManager.globalOffset += 0.1 } label: {
|
|
Text("+0.1s")
|
|
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
|
.foregroundColor(.white.opacity(0.7))
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 6)
|
|
.background(Color.white.opacity(0.06))
|
|
.cornerRadius(8)
|
|
}
|
|
|
|
Button { lyricsManager.globalOffset += 0.5 } label: {
|
|
Text("+0.5s")
|
|
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(Color.white.opacity(0.1))
|
|
.cornerRadius(8)
|
|
}
|
|
}
|
|
.padding(.vertical, 10)
|
|
.padding(.horizontal, 16)
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
}
|
|
|
|
// Main toolbar buttons
|
|
HStack(spacing: 0) {
|
|
// Re-search lyrics
|
|
Button {
|
|
NotificationCenter.default.post(name: .openLyricsSearch, object: nil)
|
|
} label: {
|
|
VStack(spacing: 3) {
|
|
Image(systemName: "magnifyingglass")
|
|
.font(.system(size: 16))
|
|
Text("Search")
|
|
.font(.system(size: 10, weight: .medium))
|
|
}
|
|
.foregroundColor(.white.opacity(0.6))
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
// Timing offset toggle (only for synced lyrics)
|
|
if hasSynced {
|
|
Button {
|
|
withAnimation(.spring(response: 0.3)) { showTimingAdjust.toggle() }
|
|
} label: {
|
|
VStack(spacing: 3) {
|
|
Image(systemName: "timer")
|
|
.font(.system(size: 16))
|
|
Text("Timing")
|
|
.font(.system(size: 10, weight: .medium))
|
|
}
|
|
.foregroundColor(showTimingAdjust ? accentPink : .white.opacity(0.6))
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
|
|
// Open full editor
|
|
Button {
|
|
NotificationCenter.default.post(name: .openLyricsEditor, object: nil)
|
|
} label: {
|
|
VStack(spacing: 3) {
|
|
Image(systemName: "pencil.line")
|
|
.font(.system(size: 16))
|
|
Text("Edit")
|
|
.font(.system(size: 10, weight: .medium))
|
|
}
|
|
.foregroundColor(.white.opacity(0.6))
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
.background(.ultraThinMaterial.opacity(0.8))
|
|
}
|
|
}
|
|
|
|
// MARK: - Lyrics Content
|
|
|
|
@ViewBuilder
|
|
private func lyricsContent(
|
|
lyrics: LyricsData,
|
|
currentTime: Double,
|
|
lineIndex: Int,
|
|
wordIndex: Int,
|
|
wordFraction: Double
|
|
) -> some View {
|
|
ScrollViewReader { proxy in
|
|
ScrollView(showsIndicators: false) {
|
|
LazyVStack(alignment: .leading, spacing: 16) {
|
|
// Top padding so first line isn't crammed at top
|
|
Spacer().frame(height: 40).id("top")
|
|
|
|
ForEach(lyrics.lines) { line in
|
|
let isCurrent = line.id == lineIndex
|
|
let isPast = line.id < lineIndex
|
|
let distance = line.id - lineIndex
|
|
|
|
if lyrics.hasSynced && isCurrent && line.words != nil {
|
|
// Karaoke mode: word-by-word highlighting
|
|
karaokeLineView(
|
|
line: line,
|
|
wordIndex: wordIndex,
|
|
wordFraction: wordFraction,
|
|
currentTime: currentTime
|
|
)
|
|
.id(line.id)
|
|
} else {
|
|
// Standard line view
|
|
Text(line.text)
|
|
.font(.system(size: isCurrent ? 26 : 21, weight: .bold))
|
|
.foregroundStyle(
|
|
isCurrent ? .white :
|
|
isPast ? .white.opacity(0.25) :
|
|
distance <= 2 ? .white.opacity(0.35) :
|
|
.white.opacity(0.18)
|
|
)
|
|
.lineLimit(3)
|
|
.id(line.id)
|
|
.onTapGesture {
|
|
if lyrics.hasSynced {
|
|
audioPlayer.seek(to: line.startTime)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bottom padding so last line can scroll to center
|
|
Spacer().frame(height: 200)
|
|
}
|
|
.padding(.horizontal, 28)
|
|
}
|
|
.mask(
|
|
// Fade top and bottom edges — NOT black, just alpha gradient
|
|
VStack(spacing: 0) {
|
|
LinearGradient(colors: [.clear, .white], startPoint: .top, endPoint: .bottom)
|
|
.frame(height: 40)
|
|
Color.white
|
|
LinearGradient(colors: [.white, .clear], startPoint: .top, endPoint: .bottom)
|
|
.frame(height: 60)
|
|
}
|
|
)
|
|
.onChange(of: lineIndex) { _, newIndex in
|
|
withAnimation(.spring(response: 0.5, dampingFraction: 0.85)) {
|
|
proxy.scrollTo(newIndex, anchor: .center)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Karaoke Line (word-by-word highlighting)
|
|
|
|
@ViewBuilder
|
|
private func karaokeLineView(
|
|
line: LyricLine,
|
|
wordIndex: Int,
|
|
wordFraction: Double,
|
|
currentTime: Double
|
|
) -> some View {
|
|
let words = line.words ?? []
|
|
|
|
// Use a flow layout of individual word views
|
|
FlowLayout(spacing: 5) {
|
|
ForEach(words) { word in
|
|
let isSung = word.id < wordIndex
|
|
let isSinging = word.id == wordIndex
|
|
let fraction = isSinging ? wordFraction : (isSung ? 1.0 : 0.0)
|
|
|
|
KaraokeWordView(
|
|
text: word.text,
|
|
fillFraction: fraction,
|
|
isSung: isSung || isSinging
|
|
)
|
|
}
|
|
}
|
|
.onTapGesture {
|
|
audioPlayer.seek(to: line.startTime)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Karaoke Word View (gradient fill per word)
|
|
|
|
struct KaraokeWordView: View {
|
|
let text: String
|
|
let fillFraction: Double
|
|
let isSung: Bool
|
|
|
|
var body: some View {
|
|
// Overlay technique: full white text masked by a gradient that slides
|
|
Text(text)
|
|
.font(.system(size: 26, weight: .bold))
|
|
.foregroundStyle(isSung && fillFraction >= 1.0 ? .white : .white.opacity(0.3))
|
|
.overlay {
|
|
if fillFraction > 0 && fillFraction < 1.0 {
|
|
GeometryReader { geo in
|
|
Rectangle()
|
|
.fill(.white)
|
|
.frame(width: geo.size.width * fillFraction)
|
|
}
|
|
.mask(
|
|
Text(text)
|
|
.font(.system(size: 26, weight: .bold))
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Flow Layout (horizontal word wrap)
|
|
|
|
/// Simple flow layout that wraps words to the next line when they exceed width.
|
|
struct FlowLayout: Layout {
|
|
var spacing: CGFloat = 5
|
|
|
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
|
let result = computeLayout(proposal: proposal, subviews: subviews)
|
|
return result.size
|
|
}
|
|
|
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
|
let result = computeLayout(proposal: proposal, subviews: subviews)
|
|
for (index, offset) in result.offsets.enumerated() {
|
|
subviews[index].place(at: CGPoint(x: bounds.minX + offset.x, y: bounds.minY + offset.y),
|
|
proposal: .unspecified)
|
|
}
|
|
}
|
|
|
|
private struct LayoutResult {
|
|
var offsets: [CGPoint]
|
|
var size: CGSize
|
|
}
|
|
|
|
private func computeLayout(proposal: ProposedViewSize, subviews: Subviews) -> LayoutResult {
|
|
let maxWidth = proposal.width ?? .infinity
|
|
var offsets: [CGPoint] = []
|
|
var x: CGFloat = 0
|
|
var y: CGFloat = 0
|
|
var rowHeight: CGFloat = 0
|
|
var totalWidth: CGFloat = 0
|
|
|
|
for subview in subviews {
|
|
let size = subview.sizeThatFits(.unspecified)
|
|
if x + size.width > maxWidth && x > 0 {
|
|
x = 0
|
|
y += rowHeight + spacing * 0.5
|
|
rowHeight = 0
|
|
}
|
|
offsets.append(CGPoint(x: x, y: y))
|
|
rowHeight = max(rowHeight, size.height)
|
|
x += size.width + spacing
|
|
totalWidth = max(totalWidth, x)
|
|
}
|
|
|
|
return LayoutResult(offsets: offsets, size: CGSize(width: totalWidth, height: y + rowHeight))
|
|
}
|
|
}
|
|
|
|
// MARK: - Notification
|
|
|
|
extension Notification.Name {
|
|
static let openLyricsSearch = Notification.Name("openLyricsSearch")
|
|
static let openLyricsEditor = Notification.Name("openLyricsEditor")
|
|
}
|