import Foundation /// Parses LRC (synced lyrics) format into timestamped lines. /// LRC format: `[mm:ss.xx] Lyric line text` struct LyricsParser { /// A single timestamped lyric line. struct LyricLine: Identifiable, Equatable { let id = UUID() let timestamp: TimeInterval let text: String /// Format timestamp as mm:ss. var formattedTime: String { let minutes = Int(timestamp) / 60 let seconds = Int(timestamp) % 60 return String(format: "%d:%02d", minutes, seconds) } } /// Parse synced LRC lyrics into an array of timestamped lines, sorted by time. static func parseSynced(_ lrc: String) -> [LyricLine] { var lines: [LyricLine] = [] for rawLine in lrc.components(separatedBy: .newlines) { let trimmed = rawLine.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { continue } // Match [mm:ss.xx] or [mm:ss:xx] patterns // Can have multiple timestamps per line: [00:12.34][00:45.67] text let pattern = #"\[(\d{1,3}):(\d{2})[\.:,](\d{2,3})\]"# guard let regex = try? NSRegularExpression(pattern: pattern) else { continue } let matches = regex.matches(in: trimmed, range: NSRange(trimmed.startIndex..., in: trimmed)) guard !matches.isEmpty else { continue } // Extract the text after all timestamps let lastMatch = matches.last! let textStartIndex = trimmed.index(trimmed.startIndex, offsetBy: lastMatch.range.upperBound) let text = String(trimmed[textStartIndex...]).trimmingCharacters(in: .whitespaces) // Skip empty lyric lines (instrumental breaks, etc.) — keep them as blank markers // Some LRC files use empty lines to indicate pauses // Create a line for each timestamp for match in matches { guard let minRange = Range(match.range(at: 1), in: trimmed), let secRange = Range(match.range(at: 2), in: trimmed), let msRange = Range(match.range(at: 3), in: trimmed), let minutes = Double(trimmed[minRange]), let seconds = Double(trimmed[secRange]), let rawMs = Double(trimmed[msRange]) else { continue } // Handle both 2-digit (centiseconds) and 3-digit (milliseconds) fractional parts let fractional: Double let msString = String(trimmed[msRange]) if msString.count <= 2 { fractional = rawMs / 100.0 } else { fractional = rawMs / 1000.0 } let timestamp = minutes * 60.0 + seconds + fractional lines.append(LyricLine(timestamp: timestamp, text: text)) } } return lines.sorted { $0.timestamp < $1.timestamp } } /// Parse plain (unsynced) lyrics into lines with no timestamps. static func parsePlain(_ text: String) -> [LyricLine] { text.components(separatedBy: .newlines) .enumerated() .map { index, line in LyricLine(timestamp: TimeInterval(index), text: line) } } /// Find the index of the current lyric line for a given playback time. static func currentLineIndex(in lines: [LyricLine], at time: TimeInterval) -> Int? { guard !lines.isEmpty else { return nil } // Binary search for the last line whose timestamp <= current time var low = 0 var high = lines.count - 1 var result = -1 while low <= high { let mid = (low + high) / 2 if lines[mid].timestamp <= time { result = mid low = mid + 1 } else { high = mid - 1 } } return result >= 0 ? result : nil } }