ghp / packages /client /src /components /Player /LyricDisplay.tsx
QSLY's picture
deploy: build Hugging Face Space from source
00a912e
Raw
History Blame Contribute Delete
5.67 kB
import { usePlayerStore } from '@/stores/playerStore'
import { useSettingsStore } from '@/stores/settingsStore'
import { getLyricOffsetKey } from '@/lib/lyricOffset'
import type { LyricLine as AMLLLyricLine } from '@applemusic-like-lyrics/core'
import '@applemusic-like-lyrics/core/style.css'
import { LyricPlayer } from '@applemusic-like-lyrics/react'
import { useMemo } from 'react'
const FULL_SIZE_STYLE = { width: '100%', height: '100%' } as const
interface LyricLine {
time: number
text: string
translation?: string
}
function parseLRC(lrc: string): { time: number; text: string }[] {
const lines: { time: number; text: string }[] = []
// Supports [mm:ss], [mm:ss.x], [mm:ss.xx], [mm:ss.xxx]
const regex = /\[(\d{2}):(\d{2})(?:\.(\d{1,3}))?\](.*)/g
let match
while ((match = regex.exec(lrc)) !== null) {
const minutes = parseInt(match[1], 10)
const seconds = parseInt(match[2], 10)
const ms = match[3] ? parseInt(match[3].padEnd(3, '0'), 10) : 0
const time = minutes * 60 + seconds + ms / 1000
const text = match[4].trim()
if (text) {
lines.push({ time, text })
}
}
return lines.sort((a, b) => a.time - b.time)
}
function mergeLyrics(original: string, translated: string): LyricLine[] {
const origLines = parseLRC(original)
if (origLines.length === 0) return []
const result: LyricLine[] = origLines.map((l) => ({ ...l }))
if (!translated) return result
const transLines = parseLRC(translated)
if (transLines.length === 0) return result
const transMap = new Map<number, string>()
for (const tl of transLines) {
transMap.set(Math.round(tl.time * 10) / 10, tl.text)
}
for (const line of result) {
const key = Math.round(line.time * 10) / 10
const exact = transMap.get(key)
if (exact) {
line.translation = exact
continue
}
for (let offset = 1; offset <= 5; offset++) {
const near =
transMap.get(Math.round((line.time + offset * 0.1) * 10) / 10) ??
transMap.get(Math.round((line.time - offset * 0.1) * 10) / 10)
if (near) {
line.translation = near
break
}
}
}
return result
}
/** 将自有 LRC 解析结果转为 AMLL LyricLine 格式 */
function toAMLLLines(lines: LyricLine[]): AMLLLyricLine[] {
return lines.map((line, i, arr) => {
const startMs = Math.round(line.time * 1000)
const endMs = Math.round((arr[i + 1]?.time ?? line.time + 5) * 1000)
return {
words: [
{
word: line.text,
startTime: startMs,
endTime: endMs,
romanWord: '',
obscene: false,
},
],
translatedLyric: line.translation ?? '',
romanLyric: '',
startTime: startMs,
endTime: endMs,
isBG: false,
isDuet: false,
}
})
}
export function LyricDisplay() {
const lyric = usePlayerStore((s) => s.lyric)
const tlyric = usePlayerStore((s) => s.tlyric)
const lyricLoading = usePlayerStore((s) => s.lyricLoading)
const ttmlLines = usePlayerStore((s) => s.ttmlLines)
const currentTime = usePlayerStore((s) => s.currentTime)
const isPlaying = usePlayerStore((s) => s.isPlaying)
const currentTrack = usePlayerStore((s) => s.currentTrack)
const lyricOffsetPreview = usePlayerStore((s) => s.lyricOffsetPreview)
const alignAnchor = useSettingsStore((s) => s.lyricAlignAnchor)
const alignPosition = useSettingsStore((s) => s.lyricAlignPosition)
const enableSpring = useSettingsStore((s) => s.lyricEnableSpring)
const enableBlur = useSettingsStore((s) => s.lyricEnableBlur)
const enableScale = useSettingsStore((s) => s.lyricEnableScale)
const fontWeight = useSettingsStore((s) => s.lyricFontWeight)
const fontSize = useSettingsStore((s) => s.lyricFontSize)
const translationFontSize = useSettingsStore((s) => s.lyricTranslationFontSize)
const romanFontSize = useSettingsStore((s) => s.lyricRomanFontSize)
const lyricOffsets = useSettingsStore((s) => s.lyricOffsets)
const lyricOffsetKey = getLyricOffsetKey(currentTrack)
const savedLyricOffsetMs = lyricOffsets[lyricOffsetKey ?? ''] ?? 0
const isCalibrating = lyricOffsetPreview?.key === lyricOffsetKey
const lyricOffsetMs = isCalibrating ? lyricOffsetPreview.offsetMs : savedLyricOffsetMs
// LRC 解析(仅在没有 TTML 时使用)
const lrcLines = useMemo(() => mergeLyrics(lyric, tlyric), [lyric, tlyric])
const lrcAmllLines = useMemo(() => toAMLLLines(lrcLines), [lrcLines])
// TTML 优先,LRC 回退
const amllLines = ttmlLines ?? lrcAmllLines
const hasLyrics = ttmlLines ? ttmlLines.length > 0 : lrcLines.length > 0
if (!hasLyrics) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-xl text-white/50">{lyricLoading ? '歌词加载中...' : '暂无歌词'}</p>
</div>
)
}
return (
<div
className="amll-container h-full w-full"
style={
{
fontWeight,
'--amll-lp-font-size': `clamp(16px, calc(min(5vh, 7vw) * ${fontSize / 100}), 80px)`,
'--amll-translated-font-size': `${translationFontSize / 100}em`,
'--amll-roman-font-size': `${romanFontSize / 100}em`,
} as React.CSSProperties
}
>
<LyricPlayer
lyricLines={amllLines}
currentTime={Math.max(0, Math.round(currentTime * 1000 - lyricOffsetMs))}
playing={isPlaying}
isSeeking={isCalibrating}
alignAnchor={alignAnchor}
alignPosition={alignPosition}
enableSpring={enableSpring && !isCalibrating}
enableBlur={enableBlur}
enableScale={enableScale}
style={FULL_SIZE_STYLE}
/>
</div>
)
}