290 lines
12 KiB
Swift
290 lines
12 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
|
||
|
||
/// Global timing offset in seconds. Positive = lyrics appear later, negative = earlier.
|
||
/// Applied in syncToTime and wordProgress — instant feedback without editing timestamps.
|
||
@Published var globalOffset: Double = 0
|
||
|
||
// 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
|
||
globalOffset = 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 }
|
||
|
||
// Apply global offset: positive = lyrics later, so we look up an earlier time
|
||
let adjusted = time - globalOffset
|
||
|
||
let newIndex = findLineIndex(at: adjusted, 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: adjusted, 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)
|
||
}
|
||
|
||
// Apply global offset
|
||
let adjusted = time - globalOffset
|
||
|
||
let lineIdx = findLineIndex(at: adjusted, in: lyrics.lines)
|
||
let line = lyrics.lines[lineIdx]
|
||
|
||
guard let words = line.words, !words.isEmpty else {
|
||
return (lineIdx, 0, 0)
|
||
}
|
||
|
||
let wordIdx = findWordIndex(at: adjusted, 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((adjusted - word.startTime) / wordDuration, 0), 1)
|
||
} else {
|
||
fraction = adjusted >= 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)
|
||
}
|
||
}
|