NavidromeApp/iOS/Views/Lyrics/LyricsManager.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

275 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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