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
261 lines
10 KiB
Swift
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")
|
|
}
|