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
This commit is contained in:
Dallas Groot 2026-04-12 20:56:48 -07:00
parent 3824f42ab5
commit c0a073bf3f
7 changed files with 1545 additions and 0 deletions

View file

@ -815,6 +815,12 @@ class AudioPlayer: NSObject, ObservableObject {
WidgetBridge.shared.updatePosition(currentTime: time.seconds, isPlaying: self.isPlaying)
#endif
}
#if os(iOS)
// Sync lyrics line tracking every ~0.5s (every 5th tick of 0.1s interval)
if Int(time.seconds * 10) % 5 == 0 {
LyricsManager.shared.syncToTime(time.seconds)
}
#endif
}
// Periodic Now Playing info sync (keeps Dynamic Island/Lock Screen in sync)
@ -1782,6 +1788,16 @@ class AudioPlayer: NSObject, ObservableObject {
/// Called on song change, play/pause, and artwork load.
private func pushWidgetState() {
guard let song = currentSong else { return }
// Notify LyricsManager of the current song (deduplicates internally by song ID)
LyricsManager.shared.songChanged(
songId: song.id,
artist: song.artist ?? "Unknown",
title: song.title,
path: song.path,
duration: duration
)
let upcoming = Array(queue.dropFirst(queueIndex + 1).prefix(3))
.map { (title: $0.title, artist: $0.artist ?? "Unknown") }

View file

@ -400,6 +400,71 @@ actor CompanionAPIService {
return profiles
}
// MARK: - Lyrics
/// Fetch lyrics for a song by its file path. Checks embedded tags, .lrc sidecar, and DB cache.
func fetchLyricsForPath(relativePath: String) async throws -> LyricsResponse {
let base = try baseURL()
guard let encoded = relativePath.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "\(base)/lyrics/get?relative_path=\(encoded)") else {
throw CompanionError.invalidURL
}
let (data, response) = try await session.data(from: url)
try validateResponse(response)
return try JSONDecoder().decode(LyricsResponse.self, from: data)
}
/// Fetch lyrics from LRCLIB via companion API (exact match by artist/title/duration).
func fetchLyricsFromLRCLIB(artist: String, title: String, duration: Double) async throws -> LyricsResponse {
let base = try baseURL()
var components = URLComponents(url: base.appendingPathComponent("lyrics/fetch"), resolvingAgainstBaseURL: false)!
components.queryItems = [
URLQueryItem(name: "artist", value: artist),
URLQueryItem(name: "title", value: title),
URLQueryItem(name: "duration", value: String(duration))
]
guard let url = components.url else { throw CompanionError.invalidURL }
let (data, response) = try await session.data(from: url)
try validateResponse(response)
return try JSONDecoder().decode(LyricsResponse.self, from: data)
}
/// Search LRCLIB for lyrics matching a query.
func searchLyrics(query: String) async throws -> [LRCLIBResult] {
let base = try baseURL()
var components = URLComponents(url: base.appendingPathComponent("lyrics/search"), resolvingAgainstBaseURL: false)!
components.queryItems = [URLQueryItem(name: "q", value: query)]
guard let url = components.url else { throw CompanionError.invalidURL }
let (data, response) = try await session.data(from: url)
try validateResponse(response)
return try JSONDecoder().decode([LRCLIBResult].self, from: data)
}
/// Embed LRC lyrics into an audio file via Companion API.
func embedLyrics(relativePath: String, lrcContent: String) async throws {
let base = try baseURL()
let url = base.appendingPathComponent("lyrics/embed")
var request = URLRequest(url: url)
request.httpMethod = "POST"
let boundary = UUID().uuidString
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
var body = Data()
func field(_ name: String, _ value: String) {
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!)
body.append("\(value)\r\n".data(using: .utf8)!)
}
field("relative_path", relativePath)
field("lrc_content", lrcContent)
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
let (_, response) = try await session.data(for: request)
try validateResponse(response)
}
// MARK: - Visualizer Frames (GET /visualizer/frames?relative_path=...)
/// Fetch pre-computed Mitsuha visualizer frames from the server.

View file

@ -0,0 +1,367 @@
import SwiftUI
//
// LyricsEditorView.swift
// Timing editor for syncing lyrics to audio. Two modes:
// 1. Tap-to-Sync: play the song, tap a button as each line begins
// 2. Fine-tune: ±0.1s nudge buttons per line, global offset
//
// Save options: local cache or embed into file via Companion API.
//
struct LyricsEditorView: View {
@Environment(\.dismiss) var dismiss
@ObservedObject var audioPlayer = AudioPlayer.shared
@State private var lines: [EditableLine]
@State private var isTapSyncMode = false
@State private var tapSyncIndex = 0
@State private var showSaveOptions = false
@State private var isSaving = false
@State private var saveMessage: String?
@State private var globalOffset: Double = 0
@State private var showOffsetSheet = false
private let songPath: String?
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
struct EditableLine: Identifiable {
let id: Int
var startTime: Double? // nil = not yet timed
var text: String
}
init(lyrics: LyricsData, songPath: String?) {
self.songPath = songPath
if lyrics.hasSynced {
_lines = State(initialValue: lyrics.lines.map {
EditableLine(id: $0.id, startTime: $0.startTime, text: $0.text)
})
} else {
_lines = State(initialValue: lyrics.lines.enumerated().map {
EditableLine(id: $0.offset, startTime: nil, text: $0.element.text)
})
}
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Playback bar
playbackBar
// Lines list
List {
ForEach($lines) { $line in
lineRow(line: $line)
}
}
.listStyle(.plain)
// Bottom action bar
bottomBar
}
.navigationTitle("Sync Lyrics")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { showSaveOptions = true }
.fontWeight(.semibold)
.foregroundStyle(accentPink)
}
}
.confirmationDialog("Save Lyrics", isPresented: $showSaveOptions) {
Button("Save to Device") { saveToDevice() }
if songPath != nil && CompanionSettings.shared.isEnabled {
Button("Embed in Audio File") { embedInFile() }
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Where should the synced lyrics be saved?")
}
.sheet(isPresented: $showOffsetSheet) {
offsetSheet
}
}
}
// MARK: - Playback Bar
private var playbackBar: some View {
HStack(spacing: 12) {
Button {
if audioPlayer.isPlaying {
audioPlayer.pause()
} else {
audioPlayer.resume()
}
} label: {
Image(systemName: audioPlayer.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 32))
.foregroundStyle(accentPink)
}
// Time display
Text(formatTime(audioPlayer.currentTime))
.font(.system(size: 14).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 50, alignment: .trailing)
// Progress bar
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2)
.fill(Color(.systemGray5))
RoundedRectangle(cornerRadius: 2)
.fill(accentPink)
.frame(width: geo.size.width * (audioPlayer.duration > 0 ? audioPlayer.currentTime / audioPlayer.duration : 0))
}
}
.frame(height: 4)
Text(formatTime(audioPlayer.duration))
.font(.system(size: 14).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 50, alignment: .leading)
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(Color(.systemGray6).opacity(0.5))
}
// MARK: - Line Row
private func lineRow(line: Binding<EditableLine>) -> some View {
let isActive = isTapSyncMode && line.wrappedValue.id == tapSyncIndex
return HStack(spacing: 10) {
// Timestamp
if let time = line.wrappedValue.startTime {
Text(formatTimePrecise(time))
.font(.system(size: 13, weight: .semibold).monospacedDigit())
.foregroundStyle(accentPink)
.frame(width: 55, alignment: .leading)
} else {
Text("—:——")
.font(.system(size: 13, weight: .semibold).monospacedDigit())
.foregroundStyle(.gray.opacity(0.4))
.frame(width: 55, alignment: .leading)
}
// Text
Text(line.wrappedValue.text)
.font(.system(size: 15))
.foregroundStyle(isActive ? .white : .white.opacity(0.8))
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
// ± adjust buttons (only if timed)
if line.wrappedValue.startTime != nil {
HStack(spacing: 2) {
Button {
line.wrappedValue.startTime = max((line.wrappedValue.startTime ?? 0) - 0.1, 0)
} label: {
Image(systemName: "minus")
.font(.system(size: 12, weight: .medium))
.frame(width: 28, height: 28)
.background(Color(.systemGray5).cornerRadius(6))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
Button {
line.wrappedValue.startTime = (line.wrappedValue.startTime ?? 0) + 0.1
} label: {
Image(systemName: "plus")
.font(.system(size: 12, weight: .medium))
.frame(width: 28, height: 28)
.background(Color(.systemGray5).cornerRadius(6))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
}
.padding(.vertical, 4)
.listRowBackground(isActive ? accentPink.opacity(0.1) : Color.clear)
}
// MARK: - Bottom Bar
private var bottomBar: some View {
HStack(spacing: 12) {
// Offset All
Button {
showOffsetSheet = true
} label: {
Text("Offset All ±")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(.systemGray5))
)
}
// Tap to Sync
Button {
if isTapSyncMode {
// Stamp current line
if tapSyncIndex < lines.count {
lines[tapSyncIndex].startTime = audioPlayer.currentTime
tapSyncIndex += 1
if tapSyncIndex >= lines.count {
isTapSyncMode = false
}
}
} else {
// Enter tap-sync mode
isTapSyncMode = true
tapSyncIndex = lines.firstIndex(where: { $0.startTime == nil }) ?? 0
if !audioPlayer.isPlaying {
audioPlayer.resume()
}
}
} label: {
Text(isTapSyncMode ? "Tap ▸ Line \(tapSyncIndex + 1)" : "Tap to Sync ▸")
.font(.system(size: 15, weight: .bold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(accentPink)
)
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Color(.systemGray6).opacity(0.5))
}
// MARK: - Offset Sheet
private var offsetSheet: some View {
NavigationStack {
VStack(spacing: 24) {
Text("Shift all timestamps by:")
.font(.system(size: 17, weight: .medium))
Text(String(format: "%+.1fs", globalOffset))
.font(.system(size: 48, weight: .bold).monospacedDigit())
.foregroundStyle(accentPink)
HStack(spacing: 20) {
Button { globalOffset -= 1.0 } label: { offsetBtn("-1.0s") }
Button { globalOffset -= 0.1 } label: { offsetBtn("-0.1s") }
Button { globalOffset += 0.1 } label: { offsetBtn("+0.1s") }
Button { globalOffset += 1.0 } label: { offsetBtn("+1.0s") }
}
Button("Apply") {
applyGlobalOffset()
showOffsetSheet = false
}
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(accentPink)
.padding(.top, 8)
}
.padding(24)
.navigationTitle("Global Offset")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { showOffsetSheet = false }
}
}
}
.presentationDetents([.medium])
}
private func offsetBtn(_ label: String) -> some View {
Text(label)
.font(.system(size: 14, weight: .semibold).monospacedDigit())
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(.systemGray5))
)
.foregroundStyle(.white)
}
// MARK: - Actions
private func applyGlobalOffset() {
for i in lines.indices {
if let t = lines[i].startTime {
lines[i].startTime = max(t + globalOffset, 0)
}
}
globalOffset = 0
}
private func buildLyricLines() -> [LyricLine] {
var result: [LyricLine] = []
let sorted = lines.filter { $0.startTime != nil }.sorted { ($0.startTime ?? 0) < ($1.startTime ?? 0) }
for (i, line) in sorted.enumerated() {
let endTime = i + 1 < sorted.count ? sorted[i + 1].startTime : nil
result.append(LyricLine(
id: i,
startTime: line.startTime ?? 0,
endTime: endTime,
text: line.text,
words: nil
))
}
return result
}
private func saveToDevice() {
let builtLines = buildLyricLines()
LyricsManager.shared.updateTimedLyrics(builtLines)
dismiss()
}
private func embedInFile() {
guard let path = songPath else { return }
isSaving = true
let builtLines = buildLyricLines()
let lrc = LRCParser.toLRC(builtLines)
Task {
do {
let api = CompanionAPIService.shared
try await api.embedLyrics(relativePath: path, lrcContent: lrc)
LyricsManager.shared.updateTimedLyrics(builtLines)
await MainActor.run {
isSaving = false
dismiss()
}
} catch {
await MainActor.run {
isSaving = false
saveMessage = "Embed failed: \(error.localizedDescription)"
}
}
}
}
// MARK: - Formatting
private func formatTime(_ t: Double) -> String {
let total = Int(max(t, 0))
return String(format: "%d:%02d", total / 60, total % 60)
}
private func formatTimePrecise(_ t: Double) -> String {
let m = Int(t) / 60
let s = t - Double(m * 60)
return String(format: "%d:%04.1f", m, s)
}
}

View file

@ -0,0 +1,275 @@
import Foundation
import Combine
//
// LyricsManager.swift
// Orchestrates lyrics loading, line/word tracking during playback,
// and the source priority pipeline (embedded > lrc > cache > LRCLIB).
//
// Performance: line tracking uses binary search (O(log n)).
// Word tracking only runs when the lyrics view is visible.
// No @Published on per-frame properties views use TimelineView.
//
final class LyricsManager: ObservableObject {
static let shared = LyricsManager()
// MARK: - Published State (low-frequency updates)
/// The loaded lyrics for the current song. Nil = no lyrics available.
@Published var currentLyrics: LyricsData?
/// Whether lyrics are currently being fetched.
@Published var isLoading = false
/// The current line index updated ~every 0.5s from the sync timer.
@Published var currentLineIndex: Int = 0
/// Whether the lyrics overlay is visible in Now Playing.
@Published var isLyricsVisible = false
// MARK: - Non-Published State (polled per-frame by views)
/// Current word index within the active line. Updated per-frame when visible.
/// NOT @Published views read this from TimelineView/Canvas to avoid SwiftUI thrash.
var currentWordIndex: Int = 0
/// The current playback time snapshot for word-level tracking.
var lastSyncedTime: Double = 0
// MARK: - Private
private var currentSongId: String?
private var currentArtist: String?
private var currentTitle: String?
private init() {}
// MARK: - Song Changed
/// Call when a new song starts playing. Kicks off the lyrics fetch pipeline.
func songChanged(songId: String, artist: String, title: String, path: String?, duration: Double) {
// Skip if same song
guard songId != currentSongId else { return }
currentSongId = songId
currentArtist = artist
currentTitle = title
currentLineIndex = 0
currentWordIndex = 0
lastSyncedTime = 0
// Check local cache first (instant, no network)
if let cached = LyricsCache.shared.get(artist: artist, title: title) {
currentLyrics = cached
DebugLogger.shared.log("Lyrics: loaded from cache (\(cached.lines.count) lines)", category: "Lyrics")
return
}
// Fetch async from companion API
isLoading = true
Task {
await fetchLyrics(artist: artist, title: title, path: path, duration: duration)
}
}
/// Clear lyrics when playback stops.
func clear() {
currentSongId = nil
currentArtist = nil
currentTitle = nil
currentLyrics = nil
currentLineIndex = 0
currentWordIndex = 0
lastSyncedTime = 0
isLyricsVisible = false
}
// MARK: - Time Sync (called from AudioPlayer's sync timer)
/// Update the current line based on playback time. Call every ~0.5s.
/// Uses binary search for O(log n) lookup.
func syncToTime(_ time: Double) {
lastSyncedTime = time
guard let lyrics = currentLyrics, !lyrics.lines.isEmpty else { return }
let newIndex = findLineIndex(at: time, in: lyrics.lines)
if newIndex != currentLineIndex {
currentLineIndex = newIndex
}
// Update word index if we have word-level data
if isLyricsVisible,
newIndex < lyrics.lines.count,
let words = lyrics.lines[newIndex].words {
currentWordIndex = findWordIndex(at: time, in: words)
}
}
/// Fine-grained word tracking call per-frame from TimelineView when visible.
/// Returns the word progress (0.01.0) within the current word for gradient fill.
func wordProgress(at time: Double) -> (lineIndex: Int, wordIndex: Int, wordFraction: Double) {
guard let lyrics = currentLyrics, !lyrics.lines.isEmpty else {
return (0, 0, 0)
}
let lineIdx = findLineIndex(at: time, in: lyrics.lines)
let line = lyrics.lines[lineIdx]
guard let words = line.words, !words.isEmpty else {
return (lineIdx, 0, 0)
}
let wordIdx = findWordIndex(at: time, in: words)
let word = words[min(wordIdx, words.count - 1)]
let wordDuration = word.endTime - word.startTime
let fraction: Double
if wordDuration > 0 {
fraction = min(max((time - word.startTime) / wordDuration, 0), 1)
} else {
fraction = time >= word.startTime ? 1 : 0
}
return (lineIdx, wordIdx, fraction)
}
/// The text of the current line, for the mini player ticker.
var currentLineText: String? {
guard let lyrics = currentLyrics, !lyrics.lines.isEmpty,
currentLineIndex < lyrics.lines.count else { return nil }
return lyrics.lines[currentLineIndex].text
}
// MARK: - Manual Lyrics Import
/// Import lyrics from LRC text (pasted or from search result).
func importLRC(_ lrc: String, source: String = "lrclib") {
let lines = LRCParser.parse(lrc)
let data = LyricsData(lines: lines, source: source, hasSynced: true)
currentLyrics = data
if let artist = currentArtist, let title = currentTitle {
LyricsCache.shared.store(data, artist: artist, title: title)
}
}
/// Import plain (unsynced) lyrics.
func importPlain(_ text: String, source: String = "manual") {
let lines = text.components(separatedBy: .newlines)
.enumerated()
.filter { !$0.element.trimmingCharacters(in: .whitespaces).isEmpty }
.map { LyricLine(id: $0.offset, startTime: 0, endTime: nil, text: $0.element, words: nil) }
let data = LyricsData(lines: lines, source: source, hasSynced: false)
currentLyrics = data
if let artist = currentArtist, let title = currentTitle {
LyricsCache.shared.store(data, artist: artist, title: title)
}
}
/// Update lyrics after timing editor saves (replaces current with new timestamps).
func updateTimedLyrics(_ lines: [LyricLine]) {
let data = LyricsData(lines: lines, source: "manual", hasSynced: true)
currentLyrics = data
if let artist = currentArtist, let title = currentTitle {
LyricsCache.shared.store(data, artist: artist, title: title)
}
}
// MARK: - Private: Fetch Pipeline
private func fetchLyrics(artist: String, title: String, path: String?, duration: Double) async {
// 1. Try Companion API /lyrics/get (checks embedded + .lrc + cache)
if CompanionSettings.shared.isEnabled, let path = path {
do {
let api = CompanionAPIService.shared
let response = try await api.fetchLyricsForPath(relativePath: path)
if let synced = response.syncedLyrics, !synced.isEmpty {
let lines = LRCParser.parse(synced)
let data = LyricsData(lines: lines, source: response.source ?? "embedded", hasSynced: true)
await MainActor.run {
self.currentLyrics = data
self.isLoading = false
}
LyricsCache.shared.store(data, artist: artist, title: title)
DebugLogger.shared.log("Lyrics: found via companion (\(response.source ?? "?"), \(lines.count) lines)", category: "Lyrics")
return
} else if let plain = response.plainLyrics, !plain.isEmpty {
await MainActor.run {
self.importPlain(plain, source: response.source ?? "embedded")
self.isLoading = false
}
DebugLogger.shared.log("Lyrics: plain text via companion (\(response.source ?? "?"))", category: "Lyrics")
return
}
} catch {
DebugLogger.shared.log("Lyrics: companion fetch failed: \(error.localizedDescription)", category: "Lyrics")
}
}
// 2. Try LRCLIB via Companion API
if CompanionSettings.shared.isEnabled {
do {
let api = CompanionAPIService.shared
let response = try await api.fetchLyricsFromLRCLIB(artist: artist, title: title, duration: duration)
if let synced = response.syncedLyrics, !synced.isEmpty {
let lines = LRCParser.parse(synced)
let data = LyricsData(lines: lines, source: "lrclib", hasSynced: true)
await MainActor.run {
self.currentLyrics = data
self.isLoading = false
}
LyricsCache.shared.store(data, artist: artist, title: title)
DebugLogger.shared.log("Lyrics: found on LRCLIB (synced, \(lines.count) lines)", category: "Lyrics")
return
} else if let plain = response.plainLyrics, !plain.isEmpty {
await MainActor.run {
self.importPlain(plain, source: "lrclib")
self.isLoading = false
}
DebugLogger.shared.log("Lyrics: found on LRCLIB (plain text)", category: "Lyrics")
return
}
} catch {
DebugLogger.shared.log("Lyrics: LRCLIB fetch failed: \(error.localizedDescription)", category: "Lyrics")
}
}
await MainActor.run {
self.currentLyrics = nil
self.isLoading = false
}
DebugLogger.shared.log("Lyrics: none found for \(artist)\(title)", category: "Lyrics")
}
// MARK: - Binary Search
/// Find the active line index at a given time. O(log n).
private func findLineIndex(at time: Double, in lines: [LyricLine]) -> Int {
var lo = 0, hi = lines.count - 1
while lo <= hi {
let mid = (lo + hi) / 2
if lines[mid].startTime <= time {
lo = mid + 1
} else {
hi = mid - 1
}
}
return max(hi, 0)
}
/// Find the active word index at a given time. O(log n).
private func findWordIndex(at time: Double, in words: [LyricWord]) -> Int {
var lo = 0, hi = words.count - 1
while lo <= hi {
let mid = (lo + hi) / 2
if words[mid].startTime <= time {
lo = mid + 1
} else {
hi = mid - 1
}
}
return max(hi, 0)
}
}

View file

@ -0,0 +1,284 @@
import Foundation
//
// LyricsModels.swift
// Data types for synced lyrics, LRC parsing, and local disk cache.
//
// MARK: - Data Models
struct LyricLine: Codable, Identifiable, Equatable {
let id: Int
let startTime: Double // seconds
var endTime: Double? // nil = until next line
let text: String
var words: [LyricWord]? // nil = line-level only (no karaoke)
}
struct LyricWord: Codable, Identifiable, Equatable {
let id: Int
let text: String
let startTime: Double
let endTime: Double
}
struct LyricsData: Codable {
let lines: [LyricLine]
let source: String // "embedded", "lrc_file", "lrclib", "manual"
let hasSynced: Bool
static let empty = LyricsData(lines: [], source: "", hasSynced: false)
}
// MARK: - LRCLIB Search Result
struct LRCLIBResult: Codable, Identifiable {
let id: Int?
let trackName: String
let artistName: String
let albumName: String?
let duration: Double
let hasSynced: Bool
let hasPlain: Bool
let syncedLyrics: String?
let plainLyrics: String?
}
// MARK: - Companion API Lyrics Response
struct LyricsResponse: Codable {
let syncedLyrics: String?
let plainLyrics: String?
let source: String?
let cached: Bool?
let found: Bool?
}
// MARK: - LRC Parser
/// Parses standard LRC format into structured LyricLine/LyricWord arrays.
/// Supports both line-level `[mm:ss.xx]text` and enhanced word-level
/// `[mm:ss.xx]<mm:ss.xx>word<mm:ss.xx>word` formats.
enum LRCParser {
/// Parse LRC text into an array of LyricLines with computed endTimes.
static func parse(_ lrc: String) -> [LyricLine] {
let rawLines = lrc.components(separatedBy: .newlines)
var results: [LyricLine] = []
var idx = 0
for raw in rawLines {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { continue }
// Skip metadata lines like [ar:Artist] [ti:Title]
if trimmed.hasPrefix("[") && !trimmed.contains(":") { continue }
if let metaEnd = trimmed.firstIndex(of: "]"),
!trimmed[trimmed.index(after: trimmed.startIndex)...metaEnd].contains(where: { $0.isNumber }) {
continue
}
// Extract all timestamps from the line (multi-timestamp lines)
let timestamps = extractTimestamps(from: trimmed)
guard !timestamps.isEmpty else { continue }
// Get the text after the last timestamp bracket
let text = extractText(from: trimmed)
guard !text.isEmpty else { continue }
// Parse word-level timestamps if present
let words = parseWords(from: text, lineStartTime: timestamps[0])
let cleanText = words != nil
? words!.map(\.text).joined(separator: " ")
: text
for ts in timestamps {
results.append(LyricLine(
id: idx,
startTime: ts,
endTime: nil,
text: cleanText,
words: words
))
idx += 1
}
}
// Sort by time and compute endTimes
results.sort { $0.startTime < $1.startTime }
for i in 0..<results.count {
results[i] = LyricLine(
id: i,
startTime: results[i].startTime,
endTime: i + 1 < results.count ? results[i + 1].startTime : nil,
text: results[i].text,
words: results[i].words
)
}
return results
}
/// Convert structured LyricLines back to LRC format string.
static func toLRC(_ lines: [LyricLine]) -> String {
lines.map { line in
let m = Int(line.startTime) / 60
let s = line.startTime - Double(m * 60)
return String(format: "[%02d:%05.2f]%@", m, s, line.text)
}.joined(separator: "\n")
}
// MARK: - Private Helpers
private static func extractTimestamps(from line: String) -> [Double] {
let pattern = #"\[(\d{1,2}):(\d{2})[.:]*(\d{0,3})\]"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
let nsRange = NSRange(line.startIndex..<line.endIndex, in: line)
let matches = regex.matches(in: line, range: nsRange)
return matches.compactMap { match in
guard match.numberOfRanges >= 3,
let minRange = Range(match.range(at: 1), in: line),
let secRange = Range(match.range(at: 2), in: line) else { return nil }
let minutes = Double(line[minRange]) ?? 0
let seconds = Double(line[secRange]) ?? 0
var millis: Double = 0
if match.numberOfRanges >= 4,
let msRange = Range(match.range(at: 3), in: line) {
let msStr = String(line[msRange])
if !msStr.isEmpty {
let padded = msStr.padding(toLength: 3, withPad: "0", startingAt: 0)
millis = (Double(padded) ?? 0) / 1000.0
}
}
return minutes * 60 + seconds + millis
}
}
private static func extractText(from line: String) -> String {
// Remove all [mm:ss.xx] timestamps
let pattern = #"\[\d{1,2}:\d{2}[.:]*\d{0,3}\]"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return line }
let nsRange = NSRange(line.startIndex..<line.endIndex, in: line)
return regex.stringByReplacingMatches(in: line, range: nsRange, withTemplate: "")
.trimmingCharacters(in: .whitespaces)
}
private static func parseWords(from text: String, lineStartTime: Double) -> [LyricWord]? {
// Enhanced LRC: <mm:ss.xx>word<mm:ss.xx>word
let pattern = #"<(\d{1,2}):(\d{2})[.:]*(\d{0,3})>"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let nsRange = NSRange(text.startIndex..<text.endIndex, in: text)
let matches = regex.matches(in: text, range: nsRange)
guard !matches.isEmpty else { return nil }
var words: [LyricWord] = []
let splits = regex.matches(in: text, range: nsRange)
for (i, match) in splits.enumerated() {
guard let minRange = Range(match.range(at: 1), in: text),
let secRange = Range(match.range(at: 2), in: text) else { continue }
let minutes = Double(text[minRange]) ?? 0
let seconds = Double(text[secRange]) ?? 0
var millis: Double = 0
if match.numberOfRanges >= 4,
let msRange = Range(match.range(at: 3), in: text) {
let msStr = String(text[msRange])
if !msStr.isEmpty {
millis = (Double(msStr.padding(toLength: 3, withPad: "0", startingAt: 0)) ?? 0) / 1000.0
}
}
let startTime = minutes * 60 + seconds + millis
// Extract word text between this timestamp and the next
let matchEnd = Range(match.range, in: text)!.upperBound
let nextStart: String.Index
if i + 1 < splits.count {
nextStart = Range(splits[i + 1].range, in: text)!.lowerBound
} else {
nextStart = text.endIndex
}
let wordText = String(text[matchEnd..<nextStart]).trimmingCharacters(in: .whitespaces)
guard !wordText.isEmpty else { continue }
let endTime: Double
if i + 1 < splits.count {
// Next word's start time
guard let nMin = Range(splits[i + 1].range(at: 1), in: text),
let nSec = Range(splits[i + 1].range(at: 2), in: text) else { continue }
let nm = Double(text[nMin]) ?? 0
let ns = Double(text[nSec]) ?? 0
endTime = nm * 60 + ns
} else {
endTime = startTime + 1.0 // estimate
}
words.append(LyricWord(id: i, text: wordText, startTime: startTime, endTime: endTime))
}
return words.isEmpty ? nil : words
}
}
// MARK: - Lyrics Cache
/// Persistent local cache for fetched lyrics. Stores in Documents/lyrics/ as JSON files.
/// Keyed by a normalized "artist-title" hash so the same lyrics load instantly offline.
class LyricsCache {
static let shared = LyricsCache()
private let cacheDir: URL
private var memoryCache: [String: LyricsData] = [:]
private init() {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
cacheDir = docs.appendingPathComponent("lyrics", isDirectory: true)
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
}
private func cacheKey(artist: String, title: String) -> String {
let normalized = "\(artist.lowercased())_\(title.lowercased())"
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: " ", with: "_")
return normalized
}
private func cacheURL(for key: String) -> URL {
cacheDir.appendingPathComponent("\(key).json")
}
func get(artist: String, title: String) -> LyricsData? {
let key = cacheKey(artist: artist, title: title)
if let cached = memoryCache[key] { return cached }
let url = cacheURL(for: key)
guard let data = try? Data(contentsOf: url),
let lyrics = try? JSONDecoder().decode(LyricsData.self, from: data) else { return nil }
memoryCache[key] = lyrics
return lyrics
}
func store(_ lyrics: LyricsData, artist: String, title: String) {
let key = cacheKey(artist: artist, title: title)
memoryCache[key] = lyrics
if let data = try? JSONEncoder().encode(lyrics) {
try? data.write(to: cacheURL(for: key), options: .atomic)
}
}
func remove(artist: String, title: String) {
let key = cacheKey(artist: artist, title: title)
memoryCache.removeValue(forKey: key)
try? FileManager.default.removeItem(at: cacheURL(for: key))
}
func clearAll() {
memoryCache.removeAll()
if let files = try? FileManager.default.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) {
for file in files { try? FileManager.default.removeItem(at: file) }
}
}
}

View file

@ -0,0 +1,261 @@
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")
}

View file

@ -0,0 +1,277 @@
import SwiftUI
//
// LyricsSearchSheet.swift
// Search LRCLIB for lyrics, preview results, and import into the app.
// Presented as a sheet from Now Playing or Track Editor.
//
struct LyricsSearchSheet: View {
@Environment(\.dismiss) var dismiss
@ObservedObject var audioPlayer = AudioPlayer.shared
@State private var query: String
@State private var results: [LRCLIBResult] = []
@State private var isSearching = false
@State private var selectedResult: LRCLIBResult?
@State private var showPreview = false
@State private var errorMessage: String?
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
/// Initialize with a pre-filled query (typically "artist title")
init(initialQuery: String = "") {
_query = State(initialValue: initialQuery)
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search bar
HStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.gray)
TextField("Search lyrics...", text: $query)
.textFieldStyle(.plain)
.autocorrectionDisabled()
.onSubmit { search() }
if !query.isEmpty {
Button {
query = ""
results = []
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.gray)
}
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(.systemGray6))
)
.padding(.horizontal, 16)
.padding(.top, 8)
if isSearching {
Spacer()
ProgressView("Searching...")
.foregroundStyle(.secondary)
Spacer()
} else if let error = errorMessage {
Spacer()
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 28))
.foregroundStyle(.orange)
Text(error)
.font(.system(size: 14))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(.horizontal, 40)
Spacer()
} else if results.isEmpty && !query.isEmpty {
Spacer()
Text("No results found")
.foregroundStyle(.secondary)
Spacer()
} else {
List(results) { result in
Button {
selectedResult = result
showPreview = true
} label: {
resultRow(result)
}
.listRowBackground(Color.clear)
}
.listStyle(.plain)
}
}
.navigationTitle("Search Lyrics")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
.sheet(isPresented: $showPreview) {
if let result = selectedResult {
LyricsPreviewSheet(result: result) {
// On import
importResult(result)
dismiss()
}
}
}
.onAppear {
if !query.isEmpty { search() }
}
}
}
// MARK: - Result Row
private func resultRow(_ result: LRCLIBResult) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(result.trackName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(1)
Spacer()
if result.hasSynced {
Text("Synced")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.green)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Capsule().fill(.green.opacity(0.15)))
} else if result.hasPlain {
Text("Plain")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.gray)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Capsule().fill(.gray.opacity(0.15)))
}
}
HStack(spacing: 6) {
Text(result.artistName)
.font(.system(size: 13))
.foregroundStyle(.secondary)
.lineLimit(1)
if let album = result.albumName, !album.isEmpty {
Text("·")
.foregroundStyle(.secondary.opacity(0.5))
Text(album)
.font(.system(size: 13))
.foregroundStyle(.secondary.opacity(0.7))
.lineLimit(1)
}
Text("·")
.foregroundStyle(.secondary.opacity(0.5))
Text(formatDuration(result.duration))
.font(.system(size: 13).monospacedDigit())
.foregroundStyle(.secondary)
}
// Duration mismatch warning
if audioPlayer.duration > 0 {
let diff = abs(result.duration - audioPlayer.duration)
if diff > 10 {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 10))
Text("Duration differs by \(formatDuration(diff))")
.font(.system(size: 11))
}
.foregroundStyle(.orange.opacity(0.8))
.padding(.top, 2)
}
}
}
.padding(.vertical, 4)
}
// MARK: - Actions
private func search() {
guard !query.trimmingCharacters(in: .whitespaces).isEmpty else { return }
isSearching = true
errorMessage = nil
Task {
do {
let api = CompanionAPIService.shared
let fetched = try await api.searchLyrics(query: query)
await MainActor.run {
results = fetched
isSearching = false
}
} catch {
await MainActor.run {
isSearching = false
errorMessage = "Search failed: \(error.localizedDescription)"
}
}
}
}
private func importResult(_ result: LRCLIBResult) {
if let synced = result.syncedLyrics {
LyricsManager.shared.importLRC(synced, source: "lrclib")
} else if let plain = result.plainLyrics {
LyricsManager.shared.importPlain(plain, source: "lrclib")
}
}
private func formatDuration(_ seconds: Double) -> String {
let total = Int(max(seconds, 0))
return String(format: "%d:%02d", total / 60, total % 60)
}
}
// MARK: - Preview Sheet
struct LyricsPreviewSheet: View {
let result: LRCLIBResult
let onImport: () -> Void
@Environment(\.dismiss) var dismiss
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
// Header
VStack(alignment: .leading, spacing: 4) {
Text(result.trackName)
.font(.system(size: 20, weight: .bold))
Text(result.artistName)
.font(.system(size: 16))
.foregroundStyle(.secondary)
if let album = result.albumName {
Text(album)
.font(.system(size: 14))
.foregroundStyle(.secondary.opacity(0.7))
}
}
.padding(.bottom, 12)
Divider()
// Lyrics text
let text = result.syncedLyrics ?? result.plainLyrics ?? "No lyrics"
Text(text)
.font(.system(size: 15))
.foregroundStyle(.white.opacity(0.8))
.lineSpacing(6)
.padding(.top, 8)
}
.padding(20)
}
.navigationTitle("Preview")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Use These Lyrics") {
onImport()
dismiss()
}
.foregroundStyle(accentPink)
.fontWeight(.semibold)
}
}
}
}
}