| 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 }[] = [] |
| |
| 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 |
| } |
|
|
| |
| 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 |
|
|
| |
| const lrcLines = useMemo(() => mergeLyrics(lyric, tlyric), [lyric, tlyric]) |
| const lrcAmllLines = useMemo(() => toAMLLLines(lrcLines), [lrcLines]) |
|
|
| |
| 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> |
| ) |
| } |
|
|