Live lyrics system — foundation + search + editor
Companion API (4 new endpoints):
- GET /lyrics/search — proxy to LRCLIB, returns matches with sync status
- GET /lyrics/fetch — exact match by artist/title/duration, auto-caches in SQLite
- GET /lyrics/get — checks embedded tags > .lrc sidecar > DB cache
- POST /lyrics/embed — writes LRC into audio file tags via mutagen + .lrc sidecar
iOS data layer:
- LyricsModels.swift: LyricLine, LyricWord, LyricsData, LRCParser (full LRC parser + serializer)
- LyricsCache: local disk cache keyed by artist-title, memory tier
- CompanionAPIService: 4 new methods (fetchForPath, fetchFromLRCLIB, search, embed)
iOS manager:
- LyricsManager: source pipeline (embedded > lrc > cache > LRCLIB), binary search line/word tracking
- Hooked into AudioPlayer periodic time observer (0.5s sync) and pushWidgetState (song change)
- Word-level progress tracking for karaoke gradient fill
iOS views:
- LyricsOverlayView: karaoke word-by-word highlighting, FlowLayout, ScrollViewReader auto-scroll
- LyricsSearchSheet: LRCLIB search with results, duration mismatch warnings, preview + import
- LyricsEditorView: tap-to-sync timing, per-line ±0.1s adjust, global offset, save to device or embed
Still needs wiring: lyrics toggle button in NowPlayingView, blur panel overlay, mini player ticker
2026-04-12 20:56:48 -07:00
|
|
|
|
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 {
|
2026-04-14 00:27:10 -07:00
|
|
|
|
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) {
|
Live lyrics system — foundation + search + editor
Companion API (4 new endpoints):
- GET /lyrics/search — proxy to LRCLIB, returns matches with sync status
- GET /lyrics/fetch — exact match by artist/title/duration, auto-caches in SQLite
- GET /lyrics/get — checks embedded tags > .lrc sidecar > DB cache
- POST /lyrics/embed — writes LRC into audio file tags via mutagen + .lrc sidecar
iOS data layer:
- LyricsModels.swift: LyricLine, LyricWord, LyricsData, LRCParser (full LRC parser + serializer)
- LyricsCache: local disk cache keyed by artist-title, memory tier
- CompanionAPIService: 4 new methods (fetchForPath, fetchFromLRCLIB, search, embed)
iOS manager:
- LyricsManager: source pipeline (embedded > lrc > cache > LRCLIB), binary search line/word tracking
- Hooked into AudioPlayer periodic time observer (0.5s sync) and pushWidgetState (song change)
- Word-level progress tracking for karaoke gradient fill
iOS views:
- LyricsOverlayView: karaoke word-by-word highlighting, FlowLayout, ScrollViewReader auto-scroll
- LyricsSearchSheet: LRCLIB search with results, duration mismatch warnings, preview + import
- LyricsEditorView: tap-to-sync timing, per-line ±0.1s adjust, global offset, save to device or embed
Still needs wiring: lyrics toggle button in NowPlayingView, blur panel overlay, mini player ticker
2026-04-12 20:56:48 -07:00
|
|
|
|
if let synced = response.syncedLyrics, !synced.isEmpty {
|
|
|
|
|
|
let lines = LRCParser.parse(synced)
|
2026-04-14 00:27:10 -07:00
|
|
|
|
let data = LyricsData(lines: lines, source: "embedded", hasSynced: true)
|
|
|
|
|
|
await MainActor.run { self.currentLyrics = data; self.isLoading = false }
|
Live lyrics system — foundation + search + editor
Companion API (4 new endpoints):
- GET /lyrics/search — proxy to LRCLIB, returns matches with sync status
- GET /lyrics/fetch — exact match by artist/title/duration, auto-caches in SQLite
- GET /lyrics/get — checks embedded tags > .lrc sidecar > DB cache
- POST /lyrics/embed — writes LRC into audio file tags via mutagen + .lrc sidecar
iOS data layer:
- LyricsModels.swift: LyricLine, LyricWord, LyricsData, LRCParser (full LRC parser + serializer)
- LyricsCache: local disk cache keyed by artist-title, memory tier
- CompanionAPIService: 4 new methods (fetchForPath, fetchFromLRCLIB, search, embed)
iOS manager:
- LyricsManager: source pipeline (embedded > lrc > cache > LRCLIB), binary search line/word tracking
- Hooked into AudioPlayer periodic time observer (0.5s sync) and pushWidgetState (song change)
- Word-level progress tracking for karaoke gradient fill
iOS views:
- LyricsOverlayView: karaoke word-by-word highlighting, FlowLayout, ScrollViewReader auto-scroll
- LyricsSearchSheet: LRCLIB search with results, duration mismatch warnings, preview + import
- LyricsEditorView: tap-to-sync timing, per-line ±0.1s adjust, global offset, save to device or embed
Still needs wiring: lyrics toggle button in NowPlayingView, blur panel overlay, mini player ticker
2026-04-12 20:56:48 -07:00
|
|
|
|
LyricsCache.shared.store(data, artist: artist, title: title)
|
2026-04-14 00:27:10 -07:00
|
|
|
|
DebugLogger.shared.log("Lyrics: embedded in local file (\(lines.count) lines)", category: "Lyrics")
|
Live lyrics system — foundation + search + editor
Companion API (4 new endpoints):
- GET /lyrics/search — proxy to LRCLIB, returns matches with sync status
- GET /lyrics/fetch — exact match by artist/title/duration, auto-caches in SQLite
- GET /lyrics/get — checks embedded tags > .lrc sidecar > DB cache
- POST /lyrics/embed — writes LRC into audio file tags via mutagen + .lrc sidecar
iOS data layer:
- LyricsModels.swift: LyricLine, LyricWord, LyricsData, LRCParser (full LRC parser + serializer)
- LyricsCache: local disk cache keyed by artist-title, memory tier
- CompanionAPIService: 4 new methods (fetchForPath, fetchFromLRCLIB, search, embed)
iOS manager:
- LyricsManager: source pipeline (embedded > lrc > cache > LRCLIB), binary search line/word tracking
- Hooked into AudioPlayer periodic time observer (0.5s sync) and pushWidgetState (song change)
- Word-level progress tracking for karaoke gradient fill
iOS views:
- LyricsOverlayView: karaoke word-by-word highlighting, FlowLayout, ScrollViewReader auto-scroll
- LyricsSearchSheet: LRCLIB search with results, duration mismatch warnings, preview + import
- LyricsEditorView: tap-to-sync timing, per-line ±0.1s adjust, global offset, save to device or embed
Still needs wiring: lyrics toggle button in NowPlayingView, blur panel overlay, mini player ticker
2026-04-12 20:56:48 -07:00
|
|
|
|
return
|
|
|
|
|
|
} else if let plain = response.plainLyrics, !plain.isEmpty {
|
2026-04-14 00:27:10 -07:00
|
|
|
|
await MainActor.run { self.importPlain(plain, source: "embedded"); self.isLoading = false }
|
|
|
|
|
|
DebugLogger.shared.log("Lyrics: plain text embedded in local file", category: "Lyrics")
|
Live lyrics system — foundation + search + editor
Companion API (4 new endpoints):
- GET /lyrics/search — proxy to LRCLIB, returns matches with sync status
- GET /lyrics/fetch — exact match by artist/title/duration, auto-caches in SQLite
- GET /lyrics/get — checks embedded tags > .lrc sidecar > DB cache
- POST /lyrics/embed — writes LRC into audio file tags via mutagen + .lrc sidecar
iOS data layer:
- LyricsModels.swift: LyricLine, LyricWord, LyricsData, LRCParser (full LRC parser + serializer)
- LyricsCache: local disk cache keyed by artist-title, memory tier
- CompanionAPIService: 4 new methods (fetchForPath, fetchFromLRCLIB, search, embed)
iOS manager:
- LyricsManager: source pipeline (embedded > lrc > cache > LRCLIB), binary search line/word tracking
- Hooked into AudioPlayer periodic time observer (0.5s sync) and pushWidgetState (song change)
- Word-level progress tracking for karaoke gradient fill
iOS views:
- LyricsOverlayView: karaoke word-by-word highlighting, FlowLayout, ScrollViewReader auto-scroll
- LyricsSearchSheet: LRCLIB search with results, duration mismatch warnings, preview + import
- LyricsEditorView: tap-to-sync timing, per-line ±0.1s adjust, global offset, save to device or embed
Still needs wiring: lyrics toggle button in NowPlayingView, blur panel overlay, mini player ticker
2026-04-12 20:56:48 -07:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 00:27:10 -07:00
|
|
|
|
// 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 {
|
Live lyrics system — foundation + search + editor
Companion API (4 new endpoints):
- GET /lyrics/search — proxy to LRCLIB, returns matches with sync status
- GET /lyrics/fetch — exact match by artist/title/duration, auto-caches in SQLite
- GET /lyrics/get — checks embedded tags > .lrc sidecar > DB cache
- POST /lyrics/embed — writes LRC into audio file tags via mutagen + .lrc sidecar
iOS data layer:
- LyricsModels.swift: LyricLine, LyricWord, LyricsData, LRCParser (full LRC parser + serializer)
- LyricsCache: local disk cache keyed by artist-title, memory tier
- CompanionAPIService: 4 new methods (fetchForPath, fetchFromLRCLIB, search, embed)
iOS manager:
- LyricsManager: source pipeline (embedded > lrc > cache > LRCLIB), binary search line/word tracking
- Hooked into AudioPlayer periodic time observer (0.5s sync) and pushWidgetState (song change)
- Word-level progress tracking for karaoke gradient fill
iOS views:
- LyricsOverlayView: karaoke word-by-word highlighting, FlowLayout, ScrollViewReader auto-scroll
- LyricsSearchSheet: LRCLIB search with results, duration mismatch warnings, preview + import
- LyricsEditorView: tap-to-sync timing, per-line ±0.1s adjust, global offset, save to device or embed
Still needs wiring: lyrics toggle button in NowPlayingView, blur panel overlay, mini player ticker
2026-04-12 20:56:48 -07:00
|
|
|
|
do {
|
|
|
|
|
|
let api = CompanionAPIService.shared
|
2026-04-14 00:27:10 -07:00
|
|
|
|
let response = try await api.fetchLyricsForPath(relativePath: path)
|
Live lyrics system — foundation + search + editor
Companion API (4 new endpoints):
- GET /lyrics/search — proxy to LRCLIB, returns matches with sync status
- GET /lyrics/fetch — exact match by artist/title/duration, auto-caches in SQLite
- GET /lyrics/get — checks embedded tags > .lrc sidecar > DB cache
- POST /lyrics/embed — writes LRC into audio file tags via mutagen + .lrc sidecar
iOS data layer:
- LyricsModels.swift: LyricLine, LyricWord, LyricsData, LRCParser (full LRC parser + serializer)
- LyricsCache: local disk cache keyed by artist-title, memory tier
- CompanionAPIService: 4 new methods (fetchForPath, fetchFromLRCLIB, search, embed)
iOS manager:
- LyricsManager: source pipeline (embedded > lrc > cache > LRCLIB), binary search line/word tracking
- Hooked into AudioPlayer periodic time observer (0.5s sync) and pushWidgetState (song change)
- Word-level progress tracking for karaoke gradient fill
iOS views:
- LyricsOverlayView: karaoke word-by-word highlighting, FlowLayout, ScrollViewReader auto-scroll
- LyricsSearchSheet: LRCLIB search with results, duration mismatch warnings, preview + import
- LyricsEditorView: tap-to-sync timing, per-line ±0.1s adjust, global offset, save to device or embed
Still needs wiring: lyrics toggle button in NowPlayingView, blur panel overlay, mini player ticker
2026-04-12 20:56:48 -07:00
|
|
|
|
if let synced = response.syncedLyrics, !synced.isEmpty {
|
|
|
|
|
|
let lines = LRCParser.parse(synced)
|
2026-04-14 00:27:10 -07:00
|
|
|
|
let data = LyricsData(lines: lines, source: response.source ?? "companion", hasSynced: true)
|
|
|
|
|
|
await MainActor.run { self.currentLyrics = data; self.isLoading = false }
|
Live lyrics system — foundation + search + editor
Companion API (4 new endpoints):
- GET /lyrics/search — proxy to LRCLIB, returns matches with sync status
- GET /lyrics/fetch — exact match by artist/title/duration, auto-caches in SQLite
- GET /lyrics/get — checks embedded tags > .lrc sidecar > DB cache
- POST /lyrics/embed — writes LRC into audio file tags via mutagen + .lrc sidecar
iOS data layer:
- LyricsModels.swift: LyricLine, LyricWord, LyricsData, LRCParser (full LRC parser + serializer)
- LyricsCache: local disk cache keyed by artist-title, memory tier
- CompanionAPIService: 4 new methods (fetchForPath, fetchFromLRCLIB, search, embed)
iOS manager:
- LyricsManager: source pipeline (embedded > lrc > cache > LRCLIB), binary search line/word tracking
- Hooked into AudioPlayer periodic time observer (0.5s sync) and pushWidgetState (song change)
- Word-level progress tracking for karaoke gradient fill
iOS views:
- LyricsOverlayView: karaoke word-by-word highlighting, FlowLayout, ScrollViewReader auto-scroll
- LyricsSearchSheet: LRCLIB search with results, duration mismatch warnings, preview + import
- LyricsEditorView: tap-to-sync timing, per-line ±0.1s adjust, global offset, save to device or embed
Still needs wiring: lyrics toggle button in NowPlayingView, blur panel overlay, mini player ticker
2026-04-12 20:56:48 -07:00
|
|
|
|
LyricsCache.shared.store(data, artist: artist, title: title)
|
2026-04-14 00:27:10 -07:00
|
|
|
|
DebugLogger.shared.log("Lyrics: companion fallback (\(response.source ?? "?"), \(lines.count) lines)", category: "Lyrics")
|
Live lyrics system — foundation + search + editor
Companion API (4 new endpoints):
- GET /lyrics/search — proxy to LRCLIB, returns matches with sync status
- GET /lyrics/fetch — exact match by artist/title/duration, auto-caches in SQLite
- GET /lyrics/get — checks embedded tags > .lrc sidecar > DB cache
- POST /lyrics/embed — writes LRC into audio file tags via mutagen + .lrc sidecar
iOS data layer:
- LyricsModels.swift: LyricLine, LyricWord, LyricsData, LRCParser (full LRC parser + serializer)
- LyricsCache: local disk cache keyed by artist-title, memory tier
- CompanionAPIService: 4 new methods (fetchForPath, fetchFromLRCLIB, search, embed)
iOS manager:
- LyricsManager: source pipeline (embedded > lrc > cache > LRCLIB), binary search line/word tracking
- Hooked into AudioPlayer periodic time observer (0.5s sync) and pushWidgetState (song change)
- Word-level progress tracking for karaoke gradient fill
iOS views:
- LyricsOverlayView: karaoke word-by-word highlighting, FlowLayout, ScrollViewReader auto-scroll
- LyricsSearchSheet: LRCLIB search with results, duration mismatch warnings, preview + import
- LyricsEditorView: tap-to-sync timing, per-line ±0.1s adjust, global offset, save to device or embed
Still needs wiring: lyrics toggle button in NowPlayingView, blur panel overlay, mini player ticker
2026-04-12 20:56:48 -07:00
|
|
|
|
return
|
|
|
|
|
|
} else if let plain = response.plainLyrics, !plain.isEmpty {
|
2026-04-14 00:27:10 -07:00
|
|
|
|
await MainActor.run { self.importPlain(plain, source: response.source ?? "companion"); self.isLoading = false }
|
Live lyrics system — foundation + search + editor
Companion API (4 new endpoints):
- GET /lyrics/search — proxy to LRCLIB, returns matches with sync status
- GET /lyrics/fetch — exact match by artist/title/duration, auto-caches in SQLite
- GET /lyrics/get — checks embedded tags > .lrc sidecar > DB cache
- POST /lyrics/embed — writes LRC into audio file tags via mutagen + .lrc sidecar
iOS data layer:
- LyricsModels.swift: LyricLine, LyricWord, LyricsData, LRCParser (full LRC parser + serializer)
- LyricsCache: local disk cache keyed by artist-title, memory tier
- CompanionAPIService: 4 new methods (fetchForPath, fetchFromLRCLIB, search, embed)
iOS manager:
- LyricsManager: source pipeline (embedded > lrc > cache > LRCLIB), binary search line/word tracking
- Hooked into AudioPlayer periodic time observer (0.5s sync) and pushWidgetState (song change)
- Word-level progress tracking for karaoke gradient fill
iOS views:
- LyricsOverlayView: karaoke word-by-word highlighting, FlowLayout, ScrollViewReader auto-scroll
- LyricsSearchSheet: LRCLIB search with results, duration mismatch warnings, preview + import
- LyricsEditorView: tap-to-sync timing, per-line ±0.1s adjust, global offset, save to device or embed
Still needs wiring: lyrics toggle button in NowPlayingView, blur panel overlay, mini player ticker
2026-04-12 20:56:48 -07:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
2026-04-14 00:27:10 -07:00
|
|
|
|
DebugLogger.shared.log("Lyrics: companion fallback failed: \(error.localizedDescription)", category: "Lyrics")
|
Live lyrics system — foundation + search + editor
Companion API (4 new endpoints):
- GET /lyrics/search — proxy to LRCLIB, returns matches with sync status
- GET /lyrics/fetch — exact match by artist/title/duration, auto-caches in SQLite
- GET /lyrics/get — checks embedded tags > .lrc sidecar > DB cache
- POST /lyrics/embed — writes LRC into audio file tags via mutagen + .lrc sidecar
iOS data layer:
- LyricsModels.swift: LyricLine, LyricWord, LyricsData, LRCParser (full LRC parser + serializer)
- LyricsCache: local disk cache keyed by artist-title, memory tier
- CompanionAPIService: 4 new methods (fetchForPath, fetchFromLRCLIB, search, embed)
iOS manager:
- LyricsManager: source pipeline (embedded > lrc > cache > LRCLIB), binary search line/word tracking
- Hooked into AudioPlayer periodic time observer (0.5s sync) and pushWidgetState (song change)
- Word-level progress tracking for karaoke gradient fill
iOS views:
- LyricsOverlayView: karaoke word-by-word highlighting, FlowLayout, ScrollViewReader auto-scroll
- LyricsSearchSheet: LRCLIB search with results, duration mismatch warnings, preview + import
- LyricsEditorView: tap-to-sync timing, per-line ±0.1s adjust, global offset, save to device or embed
Still needs wiring: lyrics toggle button in NowPlayingView, blur panel overlay, mini player ticker
2026-04-12 20:56:48 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|