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