NavidromeApp/iOS/Views/Lyrics/LyricsManager.swift
Dallas Groot cea4e3868e Seek bar glitch — The time observer was reporting from the outgoing player (near 100%) for the first half, then jumping to the incoming player (near 0%) at 50%. Now it reports from the incoming player as soon as crossfade begins. The user hears the new song fading in — the seek bar matches.
Metadata/art/colors delayed — currentSong, artwork, widget colors, and lyrics only updated after finalizeCrossfade. Now a songHandoff callback fires at the crossfade midpoint (50%) — updates everything mid-fade so the UI transitions with the audio. Scrobble of the outgoing song also fires here (before currentSong changes).
Visualizer stale data — Old song’s vis frames stayed in the buffer during the first half of crossfade. Now visualizerHandoff zeros offlineVisBuffer + _audioLevels before loading the new song’s data. Clean slate → simulation fills the gap → real vis takes over.
The needsNextTrack callback (post-finalize) now only handles player swap, observer re-registration, and queue persistence — no redundant metadata/scrobble work.
2026-04-14 00:27:10 -07:00

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