NavidromeApp/iOS/Views/Lyrics/LyricsOverlayView.swift
Dallas Groot c0a073bf3f Live lyrics system — foundation + search + editor
Companion API (4 new endpoints):
- GET /lyrics/search — proxy to LRCLIB, returns matches with sync status
- GET /lyrics/fetch — exact match by artist/title/duration, auto-caches in SQLite
- GET /lyrics/get — checks embedded tags > .lrc sidecar > DB cache
- POST /lyrics/embed — writes LRC into audio file tags via mutagen + .lrc sidecar

iOS data layer:
- LyricsModels.swift: LyricLine, LyricWord, LyricsData, LRCParser (full LRC parser + serializer)
- LyricsCache: local disk cache keyed by artist-title, memory tier
- CompanionAPIService: 4 new methods (fetchForPath, fetchFromLRCLIB, search, embed)

iOS manager:
- LyricsManager: source pipeline (embedded > lrc > cache > LRCLIB), binary search line/word tracking
- Hooked into AudioPlayer periodic time observer (0.5s sync) and pushWidgetState (song change)
- Word-level progress tracking for karaoke gradient fill

iOS views:
- LyricsOverlayView: karaoke word-by-word highlighting, FlowLayout, ScrollViewReader auto-scroll
- LyricsSearchSheet: LRCLIB search with results, duration mismatch warnings, preview + import
- LyricsEditorView: tap-to-sync timing, per-line ±0.1s adjust, global offset, save to device or embed

Still needs wiring: lyrics toggle button in NowPlayingView, blur panel overlay, mini player ticker
2026-04-12 20:56:48 -07:00

261 lines
10 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
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 {
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
)
}
} 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 {
// Open search sheet
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 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")
}