NavidromeApp/iOS/Views/Lyrics/LyricsModels.swift
Dallas Groot c0a073bf3f 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

284 lines
11 KiB
Swift

import Foundation
//
// LyricsModels.swift
// Data types for synced lyrics, LRC parsing, and local disk cache.
//
// MARK: - Data Models
struct LyricLine: Codable, Identifiable, Equatable {
let id: Int
let startTime: Double // seconds
var endTime: Double? // nil = until next line
let text: String
var words: [LyricWord]? // nil = line-level only (no karaoke)
}
struct LyricWord: Codable, Identifiable, Equatable {
let id: Int
let text: String
let startTime: Double
let endTime: Double
}
struct LyricsData: Codable {
let lines: [LyricLine]
let source: String // "embedded", "lrc_file", "lrclib", "manual"
let hasSynced: Bool
static let empty = LyricsData(lines: [], source: "", hasSynced: false)
}
// MARK: - LRCLIB Search Result
struct LRCLIBResult: Codable, Identifiable {
let id: Int?
let trackName: String
let artistName: String
let albumName: String?
let duration: Double
let hasSynced: Bool
let hasPlain: Bool
let syncedLyrics: String?
let plainLyrics: String?
}
// MARK: - Companion API Lyrics Response
struct LyricsResponse: Codable {
let syncedLyrics: String?
let plainLyrics: String?
let source: String?
let cached: Bool?
let found: Bool?
}
// MARK: - LRC Parser
/// Parses standard LRC format into structured LyricLine/LyricWord arrays.
/// Supports both line-level `[mm:ss.xx]text` and enhanced word-level
/// `[mm:ss.xx]<mm:ss.xx>word<mm:ss.xx>word` formats.
enum LRCParser {
/// Parse LRC text into an array of LyricLines with computed endTimes.
static func parse(_ lrc: String) -> [LyricLine] {
let rawLines = lrc.components(separatedBy: .newlines)
var results: [LyricLine] = []
var idx = 0
for raw in rawLines {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { continue }
// Skip metadata lines like [ar:Artist] [ti:Title]
if trimmed.hasPrefix("[") && !trimmed.contains(":") { continue }
if let metaEnd = trimmed.firstIndex(of: "]"),
!trimmed[trimmed.index(after: trimmed.startIndex)...metaEnd].contains(where: { $0.isNumber }) {
continue
}
// Extract all timestamps from the line (multi-timestamp lines)
let timestamps = extractTimestamps(from: trimmed)
guard !timestamps.isEmpty else { continue }
// Get the text after the last timestamp bracket
let text = extractText(from: trimmed)
guard !text.isEmpty else { continue }
// Parse word-level timestamps if present
let words = parseWords(from: text, lineStartTime: timestamps[0])
let cleanText = words != nil
? words!.map(\.text).joined(separator: " ")
: text
for ts in timestamps {
results.append(LyricLine(
id: idx,
startTime: ts,
endTime: nil,
text: cleanText,
words: words
))
idx += 1
}
}
// Sort by time and compute endTimes
results.sort { $0.startTime < $1.startTime }
for i in 0..<results.count {
results[i] = LyricLine(
id: i,
startTime: results[i].startTime,
endTime: i + 1 < results.count ? results[i + 1].startTime : nil,
text: results[i].text,
words: results[i].words
)
}
return results
}
/// Convert structured LyricLines back to LRC format string.
static func toLRC(_ lines: [LyricLine]) -> String {
lines.map { line in
let m = Int(line.startTime) / 60
let s = line.startTime - Double(m * 60)
return String(format: "[%02d:%05.2f]%@", m, s, line.text)
}.joined(separator: "\n")
}
// MARK: - Private Helpers
private static func extractTimestamps(from line: String) -> [Double] {
let pattern = #"\[(\d{1,2}):(\d{2})[.:]*(\d{0,3})\]"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
let nsRange = NSRange(line.startIndex..<line.endIndex, in: line)
let matches = regex.matches(in: line, range: nsRange)
return matches.compactMap { match in
guard match.numberOfRanges >= 3,
let minRange = Range(match.range(at: 1), in: line),
let secRange = Range(match.range(at: 2), in: line) else { return nil }
let minutes = Double(line[minRange]) ?? 0
let seconds = Double(line[secRange]) ?? 0
var millis: Double = 0
if match.numberOfRanges >= 4,
let msRange = Range(match.range(at: 3), in: line) {
let msStr = String(line[msRange])
if !msStr.isEmpty {
let padded = msStr.padding(toLength: 3, withPad: "0", startingAt: 0)
millis = (Double(padded) ?? 0) / 1000.0
}
}
return minutes * 60 + seconds + millis
}
}
private static func extractText(from line: String) -> String {
// Remove all [mm:ss.xx] timestamps
let pattern = #"\[\d{1,2}:\d{2}[.:]*\d{0,3}\]"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return line }
let nsRange = NSRange(line.startIndex..<line.endIndex, in: line)
return regex.stringByReplacingMatches(in: line, range: nsRange, withTemplate: "")
.trimmingCharacters(in: .whitespaces)
}
private static func parseWords(from text: String, lineStartTime: Double) -> [LyricWord]? {
// Enhanced LRC: <mm:ss.xx>word<mm:ss.xx>word
let pattern = #"<(\d{1,2}):(\d{2})[.:]*(\d{0,3})>"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let nsRange = NSRange(text.startIndex..<text.endIndex, in: text)
let matches = regex.matches(in: text, range: nsRange)
guard !matches.isEmpty else { return nil }
var words: [LyricWord] = []
let splits = regex.matches(in: text, range: nsRange)
for (i, match) in splits.enumerated() {
guard let minRange = Range(match.range(at: 1), in: text),
let secRange = Range(match.range(at: 2), in: text) else { continue }
let minutes = Double(text[minRange]) ?? 0
let seconds = Double(text[secRange]) ?? 0
var millis: Double = 0
if match.numberOfRanges >= 4,
let msRange = Range(match.range(at: 3), in: text) {
let msStr = String(text[msRange])
if !msStr.isEmpty {
millis = (Double(msStr.padding(toLength: 3, withPad: "0", startingAt: 0)) ?? 0) / 1000.0
}
}
let startTime = minutes * 60 + seconds + millis
// Extract word text between this timestamp and the next
let matchEnd = Range(match.range, in: text)!.upperBound
let nextStart: String.Index
if i + 1 < splits.count {
nextStart = Range(splits[i + 1].range, in: text)!.lowerBound
} else {
nextStart = text.endIndex
}
let wordText = String(text[matchEnd..<nextStart]).trimmingCharacters(in: .whitespaces)
guard !wordText.isEmpty else { continue }
let endTime: Double
if i + 1 < splits.count {
// Next word's start time
guard let nMin = Range(splits[i + 1].range(at: 1), in: text),
let nSec = Range(splits[i + 1].range(at: 2), in: text) else { continue }
let nm = Double(text[nMin]) ?? 0
let ns = Double(text[nSec]) ?? 0
endTime = nm * 60 + ns
} else {
endTime = startTime + 1.0 // estimate
}
words.append(LyricWord(id: i, text: wordText, startTime: startTime, endTime: endTime))
}
return words.isEmpty ? nil : words
}
}
// MARK: - Lyrics Cache
/// Persistent local cache for fetched lyrics. Stores in Documents/lyrics/ as JSON files.
/// Keyed by a normalized "artist-title" hash so the same lyrics load instantly offline.
class LyricsCache {
static let shared = LyricsCache()
private let cacheDir: URL
private var memoryCache: [String: LyricsData] = [:]
private init() {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
cacheDir = docs.appendingPathComponent("lyrics", isDirectory: true)
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
}
private func cacheKey(artist: String, title: String) -> String {
let normalized = "\(artist.lowercased())_\(title.lowercased())"
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: " ", with: "_")
return normalized
}
private func cacheURL(for key: String) -> URL {
cacheDir.appendingPathComponent("\(key).json")
}
func get(artist: String, title: String) -> LyricsData? {
let key = cacheKey(artist: artist, title: title)
if let cached = memoryCache[key] { return cached }
let url = cacheURL(for: key)
guard let data = try? Data(contentsOf: url),
let lyrics = try? JSONDecoder().decode(LyricsData.self, from: data) else { return nil }
memoryCache[key] = lyrics
return lyrics
}
func store(_ lyrics: LyricsData, artist: String, title: String) {
let key = cacheKey(artist: artist, title: title)
memoryCache[key] = lyrics
if let data = try? JSONEncoder().encode(lyrics) {
try? data.write(to: cacheURL(for: key), options: .atomic)
}
}
func remove(artist: String, title: String) {
let key = cacheKey(artist: artist, title: title)
memoryCache.removeValue(forKey: key)
try? FileManager.default.removeItem(at: cacheURL(for: key))
}
func clearAll() {
memoryCache.removeAll()
if let files = try? FileManager.default.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) {
for file in files { try? FileManager.default.removeItem(at: file) }
}
}
}