歌词相关
- 歌词数据模型:
// Lyric.swift
class Lyric: BaseModel {/// 是否是精确到字的歌词var isAccurate:Bool = false/// 所有的歌词var datum:Array<LyricLine>!
}// LyricLine.swift
class LyricLine: BaseModel {/// 整行歌词var data:String!/// 开始时间(毫秒)var startTime:Int!/// 每个字(KSC格式)var words:Array<String>!/// 每个字的持续时间(KSC格式)var wordDurations:Array<Int>!/// 结束时间var endTime:Int = 0
}
- 歌词解析:
// LRCLyricParser.swift - LRC格式解析
static func parse(_ data:String) -> Lyric {let result = Lyric()result.isAccurate = false // LRC格式不精确到字// 按行分割let strings = data.components(separatedBy: "\n")for line in strings {if line.starts(with: "[0") {// 解析时间戳和歌词内容// 例如:[00:00.300]爱的代价let lyricLine = LyricLine()// 解析时间戳lyricLine.startTime = DateUtil.parseToInt(commands[0])// 解析歌词内容lyricLine.data = commands[1]result.datum.append(lyricLine)}}return result
}// KSCLyricParser.swift - KSC格式解析
static func parse(_ data:String) -> Lyric {let result = Lyric()result.isAccurate = true // KSC格式精确到字// 解析每行歌词// 例如:karaoke.add('00:27.487', '00:32.068', '一时失志不免怨叹', '347,373,1077,320,344,386,638,1096')// 包含每个字的持续时间
}
- 歌词显示视图:
// LyricListView.swift
class LyricListView: BaseRelativeLayout {var data: Lyric?var tableView: UITableView!var datum: [Any] = []/// 当前显示的歌词行号var lyricLineNumber: Int = 0/// 歌词上下填充的占位行数var lyricPlaceholderSize = 0func setProgress(_ progress: Float) {// 1. 计算当前应该显示哪一行let newLineNumber = LyricUtil.getLineNumber(data!, progress) + lyricPlaceholderSize// 2. 如果行号变化,滚动到新位置if newLineNumber != lyricLineNumber {scrollPosition(newLineNumber)lyricLineNumber = newLineNumber}// 3. 如果是精确到字的歌词,更新当前字的位置if data!.isAccurate {if let object = datum[lyricLineNumber] as? LyricLine {// 计算当前是第几个字let lyricCurrentWordIndex = LyricUtil.getWordIndex(object, progress)// 计算当前字已经播放的时间let wordPlayedTime = LyricUtil.getWordPlayedTime(object, progress)// 更新显示if let cell = getCell(lyricLineNumber) {cell.lineView.lyricCurrentWordIndex = lyricCurrentWordIndexcell.lineView.wordPlayedTime = wordPlayedTimecell.lineView.setNeedsDisplay()}}}}
}
- 歌词行视图:
// LyricLineView.swift
class LyricLineView: UIView {var data: LyricLine?var accurate: Bool = falsevar lineSelected = falseoverride func draw(_ rect: CGRect) {if let data = self.data {if accurate {// 精确到字的歌词绘制// 1. 绘制整行歌词(灰色)wordStringNSString.draw(at: point, withAttributes: attributes)if lineSelected {// 2. 计算高亮部分的宽度let lineLyricPlayedWidth = calculatePlayedWidth()// 3. 绘制高亮部分(红色)let selectedRect = CGRect(x: point.x, y: point.y, width: lineLyricPlayedWidth, height: size.height)context.clip(to: selectedRect)attributes[.foregroundColor] = lyricSelectedTextColorwordStringNSString.draw(at: point, withAttributes: attributes)}} else {// 普通歌词绘制if lineSelected {attributes[.foregroundColor] = lyricSelectedTextColor}wordStringNSString.draw(at: point, withAttributes: attributes)}}}
}
- 时间计算工具:
// LyricUtil.swift
class LyricUtil {/// 计算当前时间对应的歌词行static func getLineNumber(_ lyric: Lyric, _ progress: Float) -> Int {let progress = progress * 1000 // 转为毫秒// 倒序遍历找到第一个开始时间小于等于当前时间的行for (index, value) in lyric.datum.enumerated().reversed() {if progress >= Float(value.startTime) {return index}}return 0}/// 计算当前时间对应的字(KSC格式)static func getWordIndex(_ line: LyricLine, _ progress: Float) -> Int {let newTime = Int(progress * 1000)var startTime = line.startTime!// 累加每个字的持续时间,找到当前字for (index, value) in line.wordDurations!.enumerated() {startTime = startTime + valueif newTime < startTime {return index}}return -1}
}
- 播放器集成:
// MusicPlayerManager.swift
class MusicPlayerManager {func prepareLyric() {// 1. 检查是否有歌词if data!.parsedLyric != nil {onLyricReady()} else if SuperStringUtil.isNotBlank(data!.lyric) {// 2. 解析本地歌词parseLyric()} else {// 3. 从网络获取歌词let urlString = data?.lrcif let url = URL(string: urlString ?? "") {// 下载并解析歌词}}}// 播放进度更新时调用func updateProgress(_ progress: Float) {// 更新歌词显示lyricView?.setProgress(progress)}
}
这个实现的主要特点:
-
支持多种格式:
- LRC:简单的时间戳+歌词格式
- KSC:支持精确到字的歌词显示
-
精确的时间控制:
- 毫秒级的时间计算
- 支持精确到字的歌词显示
- 平滑的滚动效果
-
良好的用户体验:
- 歌词居中显示
- 支持拖拽交互
- 显示拖拽位置的时间
- 点击可以跳转到对应位置
-
性能优化:
- 使用占位行实现居中效果
- 按需更新显示
- 避免不必要的重绘
歌词同步机制:
- 时间同步机制:
// LyricListView.swift
func setProgress(_ progress: Float) {if datum.count > 0 {// 1. 根据当前播放时间,计算应该显示哪一行歌词let newLineNumber = LyricUtil.getLineNumber(data!, progress) + lyricPlaceholderSize//所以为什么不二分// 2. 如果行号发生变化,滚动到新位置if newLineNumber != lyricLineNumber {scrollPosition(newLineNumber)lyricLineNumber = newLineNumber}}
}
- 时间计算:
// LyricUtil.swift
static func getLineNumber(_ lyric: Lyric, _ progress: Float) -> Int {// 将播放时间转换为毫秒let progress = progress * 1000// 倒序遍历歌词行,找到第一个开始时间小于等于当前时间的行for (index, value) in lyric.datum.enumerated().reversed() {if progress >= Float(value.startTime) {return index}}return 0
}
- 滚动实现:
// LyricListView.swift
func scrollPosition(_ lineNumber: Int) {let indexPaht = IndexPath(item: lineNumber, section: 0)if tableView.visibleCells.count > 0 {// 使用动画滚动到当前行,并保持居中tableView.selectRow(at: indexPaht, animated: true, scrollPosition: .middle)}
}
- 播放器集成:
// MusicPlayerManager.swift
class MusicPlayerManager {// 播放进度更新时调用func updateProgress(_ progress: Float) {// 更新歌词显示lyricView?.setProgress(progress)}
}
同步流程:
-
准备阶段:
- 解析歌词文件,获取每行歌词的开始时间
- 将歌词数据存储在
parsedLyric
中
-
播放阶段:
- 播放器实时提供播放进度(秒)
- 调用
setProgress
方法更新歌词显示
-
同步计算:
- 将播放时间转换为毫秒
- 遍历歌词行,找到当前时间对应的行
- 如果行号变化,滚动到新位置
-
显示更新:
- 使用动画滚动到当前歌词行
- 保持当前行在屏幕中央
- 高亮显示当前行
关键点:
- 使用毫秒级的时间计算,保证同步精度
- 倒序遍历歌词行,提高查找效率
- 使用动画滚动,提供流畅的视觉效果
- 保持当前行居中显示,提升用户体验