歌词相关
- 歌词数据模型:
// 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 = 0
func 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 = lyricCurrentWordIndex
cell.lineView.wordPlayedTime = wordPlayedTime
cell.lineView.setNeedsDisplay()
}
}
}
}
}
- 歌词行视图:
// LyricLineView.swift
class LyricLineView: UIView {
var data: LyricLine?
var accurate: Bool = false
var lineSelected = false
override 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] = lyricSelectedTextColor
wordStringNSString.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 + value
if 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?.lrc
if 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
方法更新歌词显示
-
同步计算:
- 将播放时间转换为毫秒
- 遍历歌词行,找到当前时间对应的行
- 如果行号变化,滚动到新位置
-
显示更新:
- 使用动画滚动到当前歌词行
- 保持当前行在屏幕中央
- 高亮显示当前行
关键点:
- 使用毫秒级的时间计算,保证同步精度
- 倒序遍历歌词行,提高查找效率
- 使用动画滚动,提供流畅的视觉效果
- 保持当前行居中显示,提升用户体验