fix lyric show and music trans logic

This commit is contained in:
2026-09-19 12:40:26 +08:00
parent ed37d0d570
commit 1fe55a2c84
11 changed files with 707 additions and 171 deletions
@@ -8,6 +8,7 @@ import com.zhushenwudi.lovelivemusicplayer.domain.Lyric
import com.zhushenwudi.lovelivemusicplayer.domain.Menu
import com.zhushenwudi.lovelivemusicplayer.domain.Music
import com.zhushenwudi.lovelivemusicplayer.util.PathResolver
import com.zhushenwudi.lovelivemusicplayer.util.joinMediaRelativePath
private val json = Json { ignoreUnknownKeys = true }
@@ -131,12 +132,14 @@ class LibraryRepository(private val db: LlmpDatabase) {
}
/**
* 重新检查本地文件并更新 `Music.existFile` / `Album.existFile`。
* 重新检查本地文件并更新 `Music.existFile` / `Album.existFile`,同时修复
* 旧版快传写入的专辑单文件名封面路径。
* 存储根路径或 SD 路径变更后、无需全量重导入时使用。
*/
fun rescanExistFiles(paths: PathResolver): Int {
var changed = 0
val albumHasFile = mutableMapOf<String, Boolean>()
val albumCoverPaths = mutableMapOf<String, String>()
getAllMusic().forEach { music ->
val updated = paths.refreshExistFile(music)
if (updated.existFile != music.existFile) {
@@ -144,6 +147,9 @@ class LibraryRepository(private val db: LlmpDatabase) {
changed++
}
val albumId = updated.albumId ?: return@forEach
joinMediaRelativePath(updated.baseUrl, updated.coverPath)?.let { coverPath ->
albumCoverPaths.putIfAbsent(albumId, coverPath)
}
if (updated.existFile == true) {
albumHasFile[albumId] = true
} else {
@@ -155,8 +161,22 @@ class LibraryRepository(private val db: LlmpDatabase) {
getAllAlbums().forEach { album ->
val id = album.albumId ?: return@forEach
val shouldExist = albumHasFile[id] == true
if (album.existFile != shouldExist) {
upsertAlbum(album.copy(existFile = shouldExist))
val storedCoverPath = album.coverPath?.trim().orEmpty()
val repairedCoverPath = if (
storedCoverPath.isEmpty() ||
(!storedCoverPath.contains('/') && !storedCoverPath.contains('\\'))
) {
albumCoverPaths[id] ?: album.coverPath
} else {
album.coverPath
}
if (album.existFile != shouldExist || album.coverPath != repairedCoverPath) {
upsertAlbum(
album.copy(
coverPath = repairedCoverPath,
existFile = shouldExist,
),
)
changed++
}
}
@@ -6,8 +6,8 @@ data class LyricLine(
)
object LrcParser {
private val lineRegex =
Regex("""\[(\d{1,2}):(\d{1,2})(?:[.:](\d{1,3}))?](.*)""")
private val timeTagRegex =
Regex("""\[(\d{1,2}):(\d{1,2})(?:[.:](\d{1,3}))?]""")
fun parse(lrc: String?): List<LyricLine> {
if (lrc.isNullOrBlank()) return emptyList()
@@ -15,23 +15,48 @@ object LrcParser {
for (raw in lrc.lineSequence()) {
val trimmed = raw.trim()
if (trimmed.isEmpty()) continue
val match = lineRegex.find(trimmed) ?: continue
val min = match.groupValues[1].toLongOrNull() ?: continue
val sec = match.groupValues[2].toLongOrNull() ?: continue
val frac = match.groupValues[3]
val millis = when {
frac.isEmpty() -> 0L
frac.length == 1 -> frac.toLong() * 100
frac.length == 2 -> frac.toLong() * 10
else -> frac.take(3).padEnd(3, '0').toLong()
val matches = timeTagRegex.findAll(trimmed).toList()
if (matches.isEmpty()) continue
val text = trimmed.substring(matches.last().range.last + 1).trim()
for (match in matches) {
val min = match.groupValues[1].toLongOrNull() ?: continue
val sec = match.groupValues[2].toLongOrNull() ?: continue
val frac = match.groupValues[3]
val millis = when {
frac.isEmpty() -> 0L
frac.length == 1 -> frac.toLong() * 100
frac.length == 2 -> frac.toLong() * 10
else -> frac.take(3).padEnd(3, '0').toLong()
}
lines += LyricLine(timeMs = min * 60_000 + sec * 1_000 + millis, text = text)
}
val text = match.groupValues[4].trim()
if (text.isEmpty()) continue
lines += LyricLine(timeMs = min * 60_000 + sec * 1_000 + millis, text = text)
}
return lines.sortedBy { it.timeMs }
}
/**
* 按 LRC 时间标签把扩展歌词对齐到原文;缺失标签保留空行,避免后续歌词整体错位。
* 同一时间存在多行时,按各自在源文件中的出现顺序一一配对。
*/
fun alignByTime(
main: List<LyricLine>,
extension: List<LyricLine>,
): List<LyricLine> {
val extensionByTime = extension.groupBy { it.timeMs }
val usedAtTime = mutableMapOf<Long, Int>()
return main.map { mainLine ->
val occurrence = usedAtTime[mainLine.timeMs] ?: 0
usedAtTime[mainLine.timeMs] = occurrence + 1
LyricLine(
timeMs = mainLine.timeMs,
text = extensionByTime[mainLine.timeMs]
?.getOrNull(occurrence)
?.text
.orEmpty(),
)
}
}
fun currentIndex(lines: List<LyricLine>, positionMs: Long): Int {
if (lines.isEmpty()) return -1
var idx = -1
@@ -154,9 +154,11 @@ class LyricService(
translation = lyric.roma
}
}
val mainLines = LrcParser.parse(main?.takeIf { it.isNotBlank() })
val translationLines = LrcParser.parse(translation?.takeIf { it.isNotBlank() })
return LoadedLyric(
main = LrcParser.parse(main?.takeIf { it.isNotBlank() }),
translation = LrcParser.parse(translation?.takeIf { it.isNotBlank() }),
main = mainLines,
translation = LrcParser.alignByTime(mainLines, translationLines),
raw = lyric,
)
}
@@ -168,6 +170,13 @@ data class LoadedLyric(
val raw: Lyric? = null,
)
/** 始终按原文时间标签取译文,避免调用方重新退化为下标配对。 */
fun LoadedLyric.translationAt(mainIndex: Int): LyricLine? {
val mainLine = main.getOrNull(mainIndex) ?: return null
return translation.getOrNull(mainIndex)?.takeIf { it.timeMs == mainLine.timeMs }
?: translation.firstOrNull { it.timeMs == mainLine.timeMs }
}
data class LyricFetchResult(
val loaded: LoadedLyric,
val ok: Boolean,
@@ -26,6 +26,7 @@ import com.zhushenwudi.lovelivemusicplayer.protocol.ProtocolCmd
import com.zhushenwudi.lovelivemusicplayer.protocol.TransferChannel
import com.zhushenwudi.lovelivemusicplayer.util.AudioPathUtils
import com.zhushenwudi.lovelivemusicplayer.util.PathResolver
import com.zhushenwudi.lovelivemusicplayer.util.joinMediaRelativePath
data class TransferUiState(
val host: String = "",
@@ -36,6 +37,7 @@ data class TransferUiState(
val current: String = "",
val artist: String = "",
val coverUrl: String = "",
val coverKey: String = "",
val progress: Int = 0,
val total: Int = 0,
val error: String? = null,
@@ -174,6 +176,7 @@ class MusicTransferService(
current = item.musicName,
artist = item.artist,
coverUrl = coverHttpUrl(item),
coverKey = coverKey(item),
status = I18n.t("downloading_song", item.musicName),
)
}
@@ -248,7 +251,10 @@ class MusicTransferService(
}
private fun coverHttpUrl(item: DownloadMusic): String =
fileHttpUrl(item.baseUrl, item.coverPath)
if (item.coverPath.isBlank()) "" else fileHttpUrl(item.baseUrl, item.coverPath)
private fun coverKey(item: DownloadMusic): String =
joinMediaRelativePath(item.baseUrl, item.coverPath).orEmpty()
private fun importOne(item: DownloadMusic) {
library.upsertAlbum(
@@ -256,7 +262,8 @@ class MusicTransferService(
albumId = item.albumUId,
albumName = item.albumName,
date = item.date,
coverPath = item.coverPath,
// Album 没有 baseUrl 字段,必须保存可独立解析的完整相对路径。
coverPath = coverKey(item).ifBlank { null },
category = item.category,
group = item.group,
existFile = true,
@@ -8,6 +8,19 @@ import com.zhushenwudi.lovelivemusicplayer.platform.AppLog
import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem
import com.zhushenwudi.lovelivemusicplayer.settings.AppSettings
/**
* 将歌曲的目录和文件相对路径合并成专辑可独立使用的完整相对路径。
* 已经是完整路径时不重复拼接,兼容云端导入和旧数据库数据。
*/
internal fun joinMediaRelativePath(baseUrl: String?, path: String?): String? {
val child = path?.trim()?.trim('/', '\\').orEmpty()
if (child.isEmpty()) return null
val base = baseUrl?.trim()?.trim('/', '\\').orEmpty()
if (base.isEmpty()) return child
if (child == base || child.startsWith("$base/")) return child
return "$base/$child"
}
class PathResolver(
private val fs: FileSystem,
private val settings: AppSettings,
@@ -56,9 +69,8 @@ class PathResolver(
}
fun absoluteCoverPath(music: Music): String? {
val base = music.baseUrl ?: return null
val path = music.coverPath ?: return null
return storageRoot() + base.trimStart('/') + path.trimStart('/')
val relative = joinMediaRelativePath(music.baseUrl, music.coverPath) ?: return null
return storageRoot() + relative
}
fun absoluteAlbumCoverPath(album: Album): String? {
@@ -93,7 +105,8 @@ class PathResolver(
}
}
if (settings.enableHttp) {
return joinHttpUrl(music.baseUrl.orEmpty(), music.coverPath.orEmpty())
val relative = joinMediaRelativePath(music.baseUrl, music.coverPath) ?: return null
return joinHttpUrl(relative)
}
return null
}