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
284 lines
11 KiB
Swift
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) }
|
|
}
|
|
}
|
|
}
|