NavidromeApp/iOS/Views/Lyrics/LyricsManager.swift

290 lines
12 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
/// 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.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)
}
// 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)
}
}