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
275 lines
11 KiB
Swift
275 lines
11 KiB
Swift
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.0–1.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)
|
||
}
|
||
}
|