432 lines
14 KiB
TypeScript
432 lines
14 KiB
TypeScript
import { computed, readonly, ref } from 'vue'
|
||
import { cleanTTMLTranslations, parseQRCLyric, parseSmartLrc } from '~/utils/lyric/lyricParser'
|
||
import { parseTTML, parseYrc } from '@applemusic-like-lyrics/lyric'
|
||
import { useAudioPlayer } from './useAudioPlayer'
|
||
import { useMusicSources } from './useMusicSources'
|
||
import { useLyricSettings } from './useLyricSettings'
|
||
import { useLocale } from '~/utils/locale'
|
||
|
||
export interface ParsedLyricLine {
|
||
time: number
|
||
content: string
|
||
words?: Array<{
|
||
time: number
|
||
duration: number
|
||
content: string
|
||
}>
|
||
}
|
||
|
||
type ParsedLyricWord = NonNullable<ParsedLyricLine['words']>[number]
|
||
|
||
export interface LyricData {
|
||
lrc: string
|
||
trans?: string
|
||
yrc?: string
|
||
ttml?: string
|
||
/** YRC 对齐的翻译(ytlrc),时间戳与逐字主歌词行边界一致 */
|
||
ytrans?: string
|
||
}
|
||
|
||
// ─── 内部解析工具 ────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 将 @applemusic-like-lyrics/lyric 库返回的 lines 数组统一转换为
|
||
* ParsedLyricLine[],兼容 TTML / YRC / SmartLrc 三种来源。
|
||
*/
|
||
const convertLibraryLines = (lines: any[]): ParsedLyricLine[] => {
|
||
return lines
|
||
.map((line): ParsedLyricLine | null => {
|
||
const words: ParsedLyricWord[] | undefined = Array.isArray(line?.words)
|
||
? line.words
|
||
.map((word: any): ParsedLyricWord | null => {
|
||
const startTime = typeof word?.startTime === 'number' ? word.startTime : 0
|
||
const endTime =
|
||
typeof word?.endTime === 'number'
|
||
? word.endTime
|
||
: startTime + (typeof word?.duration === 'number' ? word.duration : 0)
|
||
const content =
|
||
typeof word?.word === 'string' ? word.word : String(word?.content ?? '')
|
||
if (!content) return null
|
||
return { time: startTime, duration: Math.max(endTime - startTime, 0), content }
|
||
})
|
||
.filter((w: ParsedLyricWord | null): w is ParsedLyricWord => w !== null)
|
||
: undefined
|
||
|
||
const content =
|
||
words?.map((w) => w.content).join('') ||
|
||
(typeof line?.content === 'string' ? line.content : '')
|
||
|
||
const time = typeof line?.startTime === 'number' ? line.startTime : (line?.time ?? 0)
|
||
|
||
if (!content && (!words || words.length === 0)) return null
|
||
return { time, content, words: words && words.length > 0 ? words : undefined }
|
||
})
|
||
.filter((l): l is ParsedLyricLine => l !== null)
|
||
.sort((a, b) => a.time - b.time)
|
||
}
|
||
|
||
const hasWordByWord = (lines: ParsedLyricLine[]): boolean =>
|
||
lines.some((l) => (l.words?.length ?? 0) > 1)
|
||
|
||
/**
|
||
* 尝试将单段文本解析为歌词行。
|
||
* 优先级:TTML > QRC > YRC(JSON) > SmartLrc
|
||
*/
|
||
const parseFlexibleLyrics = (text: string): { lines: ParsedLyricLine[]; wordByWord: boolean } => {
|
||
const raw = text?.trim()
|
||
if (!raw) return { lines: [], wordByWord: false }
|
||
|
||
// TTML(XML 开头)
|
||
if (raw.startsWith('<')) {
|
||
// 先尝试 TTML
|
||
try {
|
||
const parsed = parseTTML(cleanTTMLTranslations(raw))
|
||
const ttmlLines = convertLibraryLines((parsed as any)?.lines ?? [])
|
||
if (ttmlLines.length > 0) {
|
||
return { lines: ttmlLines, wordByWord: hasWordByWord(ttmlLines) }
|
||
}
|
||
} catch {
|
||
// 解析失败继续尝试 QRC
|
||
}
|
||
|
||
// QRC(QQ 音乐 XML 格式)
|
||
if (raw.includes('LyricContent="') || raw.includes('<lyric') || raw.includes('<LrcContent')) {
|
||
try {
|
||
const qrcLines = convertLibraryLines(parseQRCLyric(raw))
|
||
if (qrcLines.length > 0) {
|
||
return { lines: qrcLines, wordByWord: hasWordByWord(qrcLines) }
|
||
}
|
||
} catch {
|
||
// 继续回退
|
||
}
|
||
}
|
||
}
|
||
|
||
// YRC(网易云 JSON 逐字格式)
|
||
if (raw.startsWith('{') || raw.trim().split('\n')[0]?.trimStart().startsWith('{')) {
|
||
try {
|
||
const lines = parseYrc(raw)
|
||
if (lines && lines.length > 0) {
|
||
const converted = convertLibraryLines(lines)
|
||
if (converted.length > 0) {
|
||
return { lines: converted, wordByWord: hasWordByWord(converted) }
|
||
}
|
||
}
|
||
} catch {
|
||
// 回退到 SmartLrc
|
||
}
|
||
}
|
||
|
||
// SmartLrc(通用 LRC / 逐字扩展 LRC)
|
||
const { lines: smartLines, format } = parseSmartLrc(raw)
|
||
if (smartLines.length > 0) {
|
||
const converted = convertLibraryLines(smartLines)
|
||
return {
|
||
lines: converted,
|
||
wordByWord: hasWordByWord(converted) || format !== 'line'
|
||
}
|
||
}
|
||
|
||
return { lines: [], wordByWord: false }
|
||
}
|
||
|
||
/**
|
||
* 按优先级顺序尝试多个候选文本,返回第一个成功解析的结果。
|
||
* 外部调用时按 [ttml, yrc, lrc] 顺序传入。
|
||
*/
|
||
const parseBestLyrics = (
|
||
candidates: Array<string | undefined | null>
|
||
): { lines: ParsedLyricLine[]; wordByWord: boolean } => {
|
||
for (const candidate of candidates) {
|
||
if (!candidate?.trim()) continue
|
||
const parsed = parseFlexibleLyrics(candidate)
|
||
if (parsed.lines.length > 0) return parsed
|
||
}
|
||
return { lines: [], wordByWord: false }
|
||
}
|
||
|
||
// ─── Composable ────────────────────────────────────────────────
|
||
|
||
export const useLyrics = () => {
|
||
const audioPlayer = useAudioPlayer()
|
||
const { composableErrors } = useLocale()
|
||
const lyricMessages = computed(() => composableErrors.value.lyrics)
|
||
|
||
const currentLyrics = ref<ParsedLyricLine[]>([])
|
||
const translationLyrics = ref<ParsedLyricLine[]>([])
|
||
const wordByWordLyrics = ref<ParsedLyricLine[]>([])
|
||
const currentLyricIndex = ref(0)
|
||
const currentTime = ref(0)
|
||
const isLoading = ref(false)
|
||
const error = ref<string | null>(null)
|
||
|
||
const showTranslation = ref(false)
|
||
const showWordByWord = ref(false)
|
||
|
||
// 竞态令牌
|
||
let currentToken = 0
|
||
|
||
// ─── 通知 HarmonyOS ────────────────────────────────────────────
|
||
|
||
const notifyHarmonyOSLyricsUpdate = (lyrics: string) => {
|
||
if (typeof window === 'undefined') return
|
||
if (!window.HarmonyOS?.postMessage) {
|
||
console.warn('[useLyrics] 鸿蒙侧消息接口不可用')
|
||
return
|
||
}
|
||
try {
|
||
window.HarmonyOS.postMessage(
|
||
JSON.stringify({ method: 'updateLyrics', parameters: { lyrics } })
|
||
)
|
||
} catch (e) {
|
||
console.error('[useLyrics] 通知鸿蒙侧失败:', e)
|
||
}
|
||
}
|
||
|
||
// ─── 歌词获取 ────────────────────────────────────────────────
|
||
|
||
const applyLyricData = (lyricData?: LyricData | null): boolean => {
|
||
if (!lyricData) return false
|
||
|
||
// 主歌词:按优先级 ttml > yrc > lrc 解析
|
||
const parsedMain = parseBestLyrics([lyricData.ttml, lyricData.yrc, lyricData.lrc])
|
||
if (parsedMain.lines.length === 0) return false
|
||
|
||
currentLyrics.value = parsedMain.lines
|
||
wordByWordLyrics.value = parsedMain.wordByWord ? parsedMain.lines : []
|
||
showWordByWord.value = parsedMain.wordByWord
|
||
|
||
translationLyrics.value = []
|
||
showTranslation.value = false
|
||
|
||
// 翻译歌词(ytlrc 时间戳与逐字主歌词对齐,优先于 tlyric)
|
||
const transSource = lyricData.ytrans || lyricData.trans
|
||
if (transSource) {
|
||
const parsedTrans = parseBestLyrics([transSource])
|
||
if (parsedTrans.lines.length > 0) {
|
||
translationLyrics.value = parsedTrans.lines
|
||
showTranslation.value = true
|
||
}
|
||
}
|
||
|
||
error.value = null
|
||
return true
|
||
}
|
||
|
||
const fetchLyrics = async (
|
||
platform: string,
|
||
musicId: string,
|
||
meta?: { title?: string; artist?: string; album?: string }
|
||
): Promise<void> => {
|
||
if (!platform || !musicId) {
|
||
console.error('[useLyrics] fetchLyrics 参数错误:', { platform, musicId })
|
||
error.value = lyricMessages.value.missingParams
|
||
_resetState()
|
||
return
|
||
}
|
||
|
||
const token = ++currentToken
|
||
isLoading.value = true
|
||
error.value = null
|
||
_resetState()
|
||
let hasRenderedProgress = false
|
||
|
||
try {
|
||
const { getLyrics } = useMusicSources()
|
||
const currentSong = audioPlayer.getCurrentSong().value as any
|
||
const result = await getLyrics(platform as 'netease' | 'tencent', musicId, {
|
||
title: meta?.title ?? currentSong?.title ?? '',
|
||
artist: meta?.artist ?? currentSong?.artist ?? '',
|
||
album: meta?.album ?? currentSong?.album ?? '',
|
||
duration: currentSong?.duration,
|
||
priority: useLyricSettings().lyricPriority.value,
|
||
onProgress: ({ data }) => {
|
||
if (token !== currentToken) return
|
||
const applied = applyLyricData(data)
|
||
if (!applied) return
|
||
|
||
hasRenderedProgress = true
|
||
isLoading.value = false
|
||
notifyHarmonyOSLyricsUpdate(getFormattedLyricsForHarmonyOS())
|
||
}
|
||
})
|
||
|
||
if (token !== currentToken) return
|
||
|
||
if (!result.success || !result.data) {
|
||
throw new Error(result.error || lyricMessages.value.notFound)
|
||
}
|
||
|
||
const lyricData = result.data
|
||
|
||
const applied = applyLyricData(lyricData)
|
||
if (!applied && !hasRenderedProgress) {
|
||
throw new Error(lyricMessages.value.notFound)
|
||
}
|
||
|
||
const harmonyLyrics = getFormattedLyricsForHarmonyOS()
|
||
notifyHarmonyOSLyricsUpdate(harmonyLyrics)
|
||
|
||
error.value = null
|
||
} catch (e: any) {
|
||
if (token !== currentToken) return
|
||
if (hasRenderedProgress) {
|
||
error.value = null
|
||
return
|
||
}
|
||
console.error('[useLyrics] 获取歌词失败:', e?.message ?? e)
|
||
error.value = e?.message ?? lyricMessages.value.fetchFailed
|
||
_resetState()
|
||
notifyHarmonyOSLyricsUpdate('')
|
||
} finally {
|
||
if (token === currentToken) {
|
||
isLoading.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── 时间同步 ────────────────────────────────────────────────
|
||
|
||
const updateCurrentLyricIndex = (time: number): void => {
|
||
currentTime.value = time
|
||
const lyricList = currentLyrics.value
|
||
if (lyricList.length === 0) return
|
||
|
||
// 二分查找当前时间所在行
|
||
let lo = 0
|
||
let hi = lyricList.length - 1
|
||
while (lo < hi) {
|
||
const mid = (lo + hi + 1) >> 1
|
||
if (lyricList[mid].time <= time) lo = mid
|
||
else hi = mid - 1
|
||
}
|
||
|
||
if (currentLyricIndex.value !== lo) {
|
||
currentLyricIndex.value = lo
|
||
}
|
||
}
|
||
|
||
// ─── 计算属性 ────────────────────────────────────────────────
|
||
|
||
const currentLyricContent = computed(() => {
|
||
const lyricList = currentLyrics.value
|
||
if (lyricList.length === 0 || currentLyricIndex.value >= lyricList.length) return ''
|
||
return lyricList[currentLyricIndex.value].content
|
||
})
|
||
|
||
const currentTranslationContent = computed(() => {
|
||
if (!showTranslation.value || translationLyrics.value.length === 0) return ''
|
||
const refTime = currentLyrics.value[currentLyricIndex.value]?.time
|
||
if (refTime == null) return ''
|
||
|
||
const list = translationLyrics.value
|
||
let lo = 0
|
||
let hi = list.length - 1
|
||
while (lo < hi) {
|
||
const mid = (lo + hi + 1) >> 1
|
||
if (list[mid].time <= refTime) lo = mid
|
||
else hi = mid - 1
|
||
}
|
||
return list[lo]?.time <= refTime ? list[lo].content : ''
|
||
})
|
||
|
||
// ─── 格式化工具 ────────────────────────────────────────────────
|
||
|
||
const _formatTime = (ms: number): string => {
|
||
const minutes = Math.floor(ms / 60000)
|
||
const seconds = Math.floor((ms % 60000) / 1000)
|
||
const centiseconds = Math.floor((ms % 1000) / 10)
|
||
return `[${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(centiseconds).padStart(2, '0')}]`
|
||
}
|
||
|
||
const getFormattedLrcText = (): string => {
|
||
if (currentLyrics.value.length === 0) return `${_formatTime(0)}${lyricMessages.value.unavailable}`
|
||
return currentLyrics.value
|
||
.map((line) => `${_formatTime(line.time)}${line.content}`)
|
||
.join('\r\n')
|
||
}
|
||
|
||
const getFormattedLyricsForHarmonyOS = (): string => {
|
||
if (currentLyrics.value.length === 0) return `${_formatTime(0)}${lyricMessages.value.unavailable}`
|
||
|
||
const MAX_BYTES = 40960
|
||
const encoder = new TextEncoder()
|
||
const lines = currentLyrics.value
|
||
const parts: string[] = []
|
||
let byteCount = 0
|
||
|
||
for (const line of lines) {
|
||
const lineText = `${_formatTime(line.time)}${line.content}\r\n`
|
||
const lineBytes = encoder.encode(lineText).length
|
||
// 留 100 字节缓冲
|
||
if (byteCount + lineBytes > MAX_BYTES - 100) break
|
||
parts.push(lineText)
|
||
byteCount += lineBytes
|
||
}
|
||
|
||
return parts.join('').trimEnd()
|
||
}
|
||
|
||
// ─── 清理 ────────────────────────────────────────────────────
|
||
|
||
const _resetState = () => {
|
||
currentLyrics.value = []
|
||
translationLyrics.value = []
|
||
wordByWordLyrics.value = []
|
||
currentLyricIndex.value = 0
|
||
showTranslation.value = false
|
||
showWordByWord.value = false
|
||
}
|
||
|
||
const clearLyrics = (): void => {
|
||
currentToken++
|
||
_resetState()
|
||
currentTime.value = 0
|
||
error.value = null
|
||
}
|
||
|
||
const seekToLyricLine = (index: number): number => {
|
||
if (index >= 0 && index < currentLyrics.value.length) {
|
||
return currentLyrics.value[index].time / 1000
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// 保留向后兼容的解析方法(部分组件可能直接调用)
|
||
const parseLrcLyrics = (lrcText: string): ParsedLyricLine[] => {
|
||
if (!lrcText) return []
|
||
return parseFlexibleLyrics(lrcText).lines
|
||
}
|
||
|
||
const parseYrcLyrics = (yrcText: string): ParsedLyricLine[] => {
|
||
if (!yrcText) return []
|
||
return parseFlexibleLyrics(yrcText).lines
|
||
}
|
||
|
||
return {
|
||
currentLyrics: readonly(currentLyrics),
|
||
translationLyrics: readonly(translationLyrics),
|
||
wordByWordLyrics: readonly(wordByWordLyrics),
|
||
currentLyricIndex: readonly(currentLyricIndex),
|
||
currentTime: readonly(currentTime),
|
||
isLoading: readonly(isLoading),
|
||
error: readonly(error),
|
||
|
||
showTranslation,
|
||
showWordByWord,
|
||
|
||
currentLyricContent,
|
||
currentTranslationContent,
|
||
|
||
fetchLyrics,
|
||
updateCurrentLyricIndex,
|
||
getFormattedLrcText,
|
||
getFormattedLyricsForHarmonyOS,
|
||
notifyHarmonyOSLyricsUpdate,
|
||
clearLyrics,
|
||
seekToLyricLine,
|
||
parseLrcLyrics,
|
||
parseYrcLyrics
|
||
}
|
||
}
|