NavidromeApp/iOS/Views/Lyrics/LyricsOverlayView.swift
Dallas Groot c6451b440b Audio Tap FFT:
- 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"
2026-04-14 19:45:05 -07:00

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")
}