NavidromeApp/iOS/Views/Lyrics/LyricsManager.swift

280 lines
12 KiB
Swift
Raw Normal View History

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 {
let service = LyricsService.shared
// 1. On-device: read embedded lyrics from downloaded file
if let songId = currentSongId {
if let response = await service.readEmbedded(songId: songId) {
if let synced = response.syncedLyrics, !synced.isEmpty {
let lines = LRCParser.parse(synced)
let data = LyricsData(lines: lines, 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: embedded in local file (\(lines.count) lines)", category: "Lyrics")
return
} else if let plain = response.plainLyrics, !plain.isEmpty {
await MainActor.run { self.importPlain(plain, source: "embedded"); self.isLoading = false }
DebugLogger.shared.log("Lyrics: plain text embedded in local file", category: "Lyrics")
return
}
}
}
// 2. LRCLIB direct from iOS (no companion needed)
do {
let response = try await service.fetch(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: LRCLIB direct (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: LRCLIB direct (plain text)", category: "Lyrics")
return
}
} catch {
DebugLogger.shared.log("Lyrics: LRCLIB direct failed: \(error.localizedDescription)", category: "Lyrics")
}
// 3. Companion API fallback (reads server-side embedded tags + .lrc sidecars)
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 ?? "companion", hasSynced: true)
await MainActor.run { self.currentLyrics = data; self.isLoading = false }
LyricsCache.shared.store(data, artist: artist, title: title)
DebugLogger.shared.log("Lyrics: companion fallback (\(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 ?? "companion"); self.isLoading = false }
return
}
} catch {
DebugLogger.shared.log("Lyrics: companion fallback 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)
}
}