diff --git a/src/plugins/pluginManager.ts b/src/plugins/pluginManager.ts index f9d9f0f..281bec7 100644 --- a/src/plugins/pluginManager.ts +++ b/src/plugins/pluginManager.ts @@ -2,15 +2,13 @@ import type { PluginFullInfo, PluginModule, PluginSearchResult, UrlResponse } fr /** * QZMusic Web 插件管理器 - * 兼容 PC/Android 原版的 CommonJS 插件格式 - * - * PC 原版插件格式: - * module.exports = { - * pluginInfo: { info: { id, name, ... }, quality: [...] }, - * getUrl: (id, quality) => url, - * musicSearch: { search: (query, page, limit) => result }, - * getLyric: (id) => lyric - * } + * 兼容 PC/Android 原版的 CommonJS 插件格式: + * module.exports = { + * pluginInfo: { info: { id, name, ... }, quality: [...] }, + * musicSearch: { search: (query, page, limit) => result }, + * getUrl: (id, quality) => url, + * getLyric: (id) => lyric_string_or_object + * } */ class PluginManager { private plugins: Map = new Map(); @@ -33,33 +31,20 @@ class PluginManager { /** 从 JS 代码字符串加载插件(兼容 PC 版 CommonJS 格式) */ loadFromCode(code: string, source: 'built-in' | 'user' = 'user'): PluginModule | null { try { - // 将 CommonJS module.exports 转换为可执行的函数 - // 支持 module.exports = { ... } 和 exports.xxx = ... 语法 - const wrappedCode = ` - (function() { - var module = { exports: {} }; - var exports = module.exports; - ${code} - return module.exports; - })() - `; - const module = new Function(wrappedCode)() as PluginModule; - - if (!module) { + const wrapped = `(function(){var module={exports:{}};var exports=module.exports;${code}\n;return module.exports;})()`; + const mod = (new Function(wrapped))() as PluginModule; + if (!mod) { console.error('[PluginManager] 插件代码执行后返回空'); return null; } - - // 标记来源 - if (module.pluginInfo?.info) { - (module.pluginInfo.info as any).__source = source; + if (mod.pluginInfo?.info) { + (mod.pluginInfo.info as any).__source = source; } - if (module.info) { - (module.info as any).__source = source; + if (mod.info) { + (mod.info as any).__source = source; } - - this.registerModule(module); - return module; + this.registerModule(mod); + return mod; } catch (e) { console.error('[PluginManager] 加载插件代码失败:', e); return null; @@ -100,11 +85,24 @@ class PluginManager { return false; } - /** 获取当前激活插件ID */ + /** 获取当前激活插件 ID */ getActivePluginId(): string { return this.activePluginId; } + /** 获取指定插件的音质列表 */ + getQualityList(id: string): { id: string; name: string; ui: string }[] { + const mod = this.plugins.get(id); + if (mod?.pluginInfo?.quality && mod.pluginInfo.quality.length > 0) { + return mod.pluginInfo.quality; + } + return [ + { id: 'standard', name: '标准', ui: 'SQ' }, + { id: 'high', name: '高品', ui: 'HQ' }, + { id: 'hires', name: '无损', ui: 'HR' }, + ]; + } + /** 搜索(兼容 PC 版 musicSearch.search 接口) */ async search(query: string, page: number, limit: number): Promise { const plugin = this.getActivePlugin(); @@ -112,42 +110,87 @@ class PluginManager { return { list: [], total: 0, error: '当前插件不支持搜索' }; } try { - return await plugin.musicSearch.search(query, page, limit); + const result = await plugin.musicSearch.search(query, page, limit); + if (!result || !Array.isArray(result.list)) { + return { list: [], total: 0, error: '插件未返回正确的搜索结果格式' }; + } + return result; } catch (e) { console.error('[PluginManager] 搜索失败:', e); return { list: [], total: 0, error: (e as Error).message }; } } - /** 获取歌曲URL(兼容 PC 版 getUrl 接口) */ - async getSongUrl(id: string, quality: string = 'standard'): Promise { - const plugin = this.getActivePlugin(); - if (!plugin?.getUrl) { - return { success: false, error: '当前插件不支持获取URL' }; + /** 获取歌曲 URL(兼容 PC 版 getUrl 接口) + * priority: song.source 指定的插件 → 当前激活插件 → 所有插件轮流试 + */ + async getSongUrl(song: { id: string; source?: string; quality?: string }, preferQuality?: string): Promise { + const pluginIds: string[] = []; + if (song.source && this.plugins.has(song.source)) { + pluginIds.push(song.source); } - try { - const url = await plugin.getUrl(id, quality); - if (typeof url !== 'string' || !url.startsWith('http')) { - return { success: false, error: '无效的URL' }; + if (this.activePluginId && !pluginIds.includes(this.activePluginId)) { + pluginIds.push(this.activePluginId); + } + for (const pid of this.plugins.keys()) { + if (!pluginIds.includes(pid)) pluginIds.push(pid); + } + + const quality = preferQuality || song.quality || 'standard'; + + for (const pid of pluginIds) { + const plugin = this.plugins.get(pid); + if (!plugin?.getUrl) continue; + try { + const url = await plugin.getUrl(song.id, quality); + if (typeof url === 'string' && url.length > 0) { + if (/^(https?:)?\/\//i.test(url) || url.startsWith('data:') || url.startsWith('blob:')) { + return { success: true, url }; + } + // 一些插件可能返回相对路径,加 https: + if (url.startsWith('//')) { + return { success: true, url: 'https:' + url }; + } + } + } catch (e) { + console.warn(`[PluginManager] 插件 ${pid} getUrl 失败:`, e); } - return { success: true, url }; - } catch (e) { - return { success: false, error: (e as Error).message || '插件错误' }; } + return { success: false, error: '所有插件都无法获取这首歌曲的播放地址' }; } - /** 获取歌词(兼容 PC 版 getLyric 接口) */ - async getLyric(id: string): Promise { - const plugin = this.getActivePlugin(); - if (!plugin?.getLyric) { - return null; + /** 获取歌词(兼容 PC 版 getLyric 接口) + * 返回:{ format: 'lrc' | 'ttml' | 'qrc' | 'yrc' | 'json' | 'text' | null, raw: any } + */ + async getLyric(song: { id: string; source?: string }): Promise<{ format: string | null; raw: any }> { + const pluginIds: string[] = []; + if (song.source && this.plugins.has(song.source)) { + pluginIds.push(song.source); } - try { - return await plugin.getLyric(id); - } catch (e) { - console.error('[PluginManager] 获取歌词失败:', e); - return null; + if (this.activePluginId && !pluginIds.includes(this.activePluginId)) { + pluginIds.push(this.activePluginId); } + for (const pid of this.plugins.keys()) { + if (!pluginIds.includes(pid)) pluginIds.push(pid); + } + + for (const pid of pluginIds) { + const plugin = this.plugins.get(pid); + if (!plugin?.getLyric) continue; + try { + const raw = await plugin.getLyric(song.id); + if (raw == null) continue; + const format = detectLyricFormat(raw); + if (format) { + return { format, raw }; + } + // 就算识别不到格式,也返回原始内容 + return { format: format, raw }; + } catch (e) { + console.warn(`[PluginManager] 插件 ${pid} getLyric 失败:`, e); + } + } + return { format: null, raw: null }; } hasPlugins(): boolean { @@ -169,40 +212,39 @@ class PluginManager { } } - /** 保存用户插件到 localStorage */ + /** 保存用户插件 code 到 localStorage */ saveUserPlugin(code: string): void { - const saved: string[] = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]'); - saved.push(code); - localStorage.setItem('qz-user-plugins', JSON.stringify(saved)); + try { + const saved: string[] = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]'); + saved.push(code); + localStorage.setItem('qz-user-plugins', JSON.stringify(saved)); + } catch (e) { + console.error('[PluginManager] 保存用户插件失败:', e); + } } - /** 删除用户插件代码 */ + /** 删除用户插件(按 id 匹配) */ removeUserPlugin(id: string): void { - const saved: string[] = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]'); - // 尝试从代码中提取id来匹配 - const filtered = saved.filter(code => { - try { - const wrappedCode = ` - (function() { - var module = { exports: {} }; - var exports = module.exports; - ${code} - return module.exports; - })() - `; - const mod = new Function(wrappedCode)() as any; - const info = mod?.pluginInfo?.info || mod?.info; - return info?.id !== id; - } catch { - return true; // 解析失败的保留 - } - }); - localStorage.setItem('qz-user-plugins', JSON.stringify(filtered)); + try { + const saved: string[] = JSON.parse(localStorage.getItem('qz-user-plugins') || '[]'); + const filtered = saved.filter(code => { + try { + const wrapped = `(function(){var module={exports:{}};var exports=module.exports;${code}\n;return module.exports;})()`; + const mod = (new Function(wrapped))(); + const info = mod?.pluginInfo?.info || mod?.info; + return info?.id !== id; + } catch { + return true; + } + }); + localStorage.setItem('qz-user-plugins', JSON.stringify(filtered)); + } catch (e) { + console.error('[PluginManager] 删除用户插件失败:', e); + } } /** 提取插件信息(兼容 PC 版两种格式) */ private extractInfo(module: PluginModule): PluginFullInfo | null { - // PC 原版格式:pluginInfo.info if (module.pluginInfo?.info) { return { ...module.pluginInfo.info, @@ -210,7 +252,6 @@ class PluginManager { source: (module.pluginInfo.info as any).__source || 'built-in', }; } - // 简化格式:info if (module.info) { return { ...module.info, @@ -221,4 +262,35 @@ class PluginManager { } } +/** 自动识别歌词返回内容的格式 */ +function detectLyricFormat(raw: any): string | null { + if (typeof raw === 'string') { + const s = raw.trim(); + if (!s) return null; + // 开头是 / → TTML/QRC XML + if (/^<\s*(?:\?xml|tt|TT|lyric\b|Lyric\b|LyricData\b)/i.test(s)) return 'ttml'; + // 含有大量 <[00:00.00]> → QRC 风格 + if (/<\s*\d+[::]\d+/.test(s)) return 'qrc'; + // 含有大量 [00:00.00] 时间戳 → LRC + if (/\[\s*\d{1,2}[::]\d{1,2}(?:[.::]\d{1,3})?\s*\]/.test(s)) return 'lrc'; + // 看起来像 JSON + const first = s.charAt(0); + if (first === '{' || first === '[') return 'json'; + // 默认纯文本 + return 'text'; + } + if (typeof raw === 'object') { + // 判断是否是网易云 YRC 格式(有 yrc 字段或逐字信息) + if (raw.yrc || raw.lrclib || raw.klyric) return 'yrc'; + if (raw.lrc) return 'lrc'; + if (raw.ttml) return 'ttml'; + if (raw.qrc) return 'qrc'; + if (Array.isArray(raw)) return 'json'; + // 带 lines 数组 + if (Array.isArray(raw.lines) || Array.isArray(raw.lyric)) return 'json'; + return 'json'; + } + return null; +} + export const pluginManager = new PluginManager(); diff --git a/src/stores/player.ts b/src/stores/player.ts index 70b7815..69f428c 100644 --- a/src/stores/player.ts +++ b/src/stores/player.ts @@ -173,7 +173,6 @@ export const usePlayerStore = defineStore('player', () => { if (!song) return; initAudio(); - console.log(song); currentSong.value = song; const foundIndex = playlist.value.findIndex(s => s.id === song.id); if (foundIndex !== -1) { @@ -181,36 +180,48 @@ export const usePlayerStore = defineStore('player', () => { } updateMediaSession(song); + + if (audioContext && audioContext.state === 'suspended') { + try { await audioContext.resume(); } catch { /* ignore */ } + } + + // 1) URL:优先使用 song 自带有效 URL;否则走插件系统 + let playUrl = song.url; + const isValidUrl = typeof playUrl === 'string' && playUrl.length > 0 + && (/^https?:\/\//i.test(playUrl) || playUrl.startsWith('data:') || playUrl.startsWith('blob:')); + + if (!isValidUrl) { + try { + // 动态引入,避免 SSR/初始化阶段依赖问题 + const pluginManager = (await import('../plugins/index')).pluginManager; + const res = await pluginManager.getSongUrl(song); + if (res?.success && res.url) { + playUrl = res.url; + song.url = res.url; + } + } catch (e) { + console.warn('[Player] 插件获取 URL 失败:', e); + } + } + + // 2) 歌词:用插件系统并行获取(不阻塞播放) fetchLyrics(song); - // Resume audio context if suspended (for autoplay policy) - if (audioContext && audioContext.state === 'suspended') { - await audioContext.resume(); - } - - let playUrl = song.url; - if (song.type === 'Remote' && song.source) { - const quality = 'hires'; - playUrl = `http://localhost:5266/music?source=${song.source}&id=${song.id}&quality=${quality}`; - console.log('[Player] Using Proxy:', playUrl); - } - if (playUrl && audioElement) { - console.log('Playing:', song.name, 'AutoPlay:', autoPlay); try { audioElement.src = playUrl; audioElement.load(); if (autoPlay) { await audioElement.play(); } - song.url = playUrl; playErrorCount.value = 0; } catch (e) { - console.error("Play request failed:", e); + console.error('[Player] 播放失败:', e); if (autoPlay) handlePlayError(); } } else { - console.warn("Song has no URL"); + console.warn('[Player] 歌曲无可用 URL:', song.name); + MessagePlugin.warning('当前音源插件无法获取这首歌的播放地址').then(); if (autoPlay) handlePlayError(); } }; @@ -219,10 +230,19 @@ export const usePlayerStore = defineStore('player', () => { lyrics.value = { lines: [] }; if (!song || !song.id) return; try { - // TODO: Implement web-based lyric fetching - MessagePlugin.info("网页版暂不支持歌词获取").then(); + const pluginManager = (await import('../plugins/index')).pluginManager; + const lyricData = await pluginManager.getLyric(song); + if (lyricData && (lyricData.raw || lyricData.format)) { + const { parseAnyLyric } = await import('../utils/lyricUtil'); + const parsed = parseAnyLyric(lyricData); + if (Array.isArray(parsed) && parsed.length > 0) { + lyrics.value = { lines: parsed }; + return; + } + } + // 插件无歌词或解析为空 → 不弹提示打扰用户 } catch (e) { - console.error('Failed to fetch lyrics:', e); + console.error('[Player] 获取歌词失败:', e); } }; diff --git a/src/types/plugin.ts b/src/types/plugin.ts index 89a1ccb..baef956 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -23,6 +23,38 @@ export interface PluginFullInfo extends PluginInfo { quality?: PluginQuality[]; } +/** PC 原版插件搜索结果单条(常见字段) */ +export interface PluginSearchItem { + songmid?: string; + id?: string; + name?: string; + singer?: string; + artist?: string; + albumName?: string; + album?: string; + albumId?: string; + interval?: number; + duration?: number; + img?: string; + m_img?: string; + s_img?: string; + picUrl?: string; + source?: string; + types?: { [qualityId: string]: string | number }; + quality?: string[]; + [key: string]: any; +} + +/** 搜索结果 */ +export interface PluginSearchResult { + list: PluginSearchItem[]; + total?: number; + songCount?: number; + allPage?: number; + error?: string; + [key: string]: any; +} + /** PC 原版插件模块接口 */ export interface PluginModule { pluginInfo?: { @@ -34,16 +66,7 @@ export interface PluginModule { musicSearch?: { search: (query: string, page: number, limit: number) => Promise | PluginSearchResult; }; - getLyric?: (id: string) => Promise | string | object; -} - -/** 搜索结果 */ -export interface PluginSearchResult { - list: any[]; - total?: number; - songCount?: number; - allPage?: number; - error?: string; + getLyric?: (id: string) => Promise | string | object | ArrayBuffer; } /** URL 响应 */ diff --git a/src/utils/lyricUtil.ts b/src/utils/lyricUtil.ts index 980e496..0da0de9 100644 --- a/src/utils/lyricUtil.ts +++ b/src/utils/lyricUtil.ts @@ -1,57 +1,506 @@ -import {LyricLine, parseLrc, parseQrc, parseTTML, parseYrc} from "@applemusic-like-lyrics/lyric"; -const sanitizeLyricLines = (lines: LyricLine[]): LyricLine[] => { - const defaultLineDuration = 3000 - const toFiniteNumber = (v: any, fallback: number) => { - const n = typeof v === 'number' ? v : Number(v) - return Number.isFinite(n) ? n : fallback - } - const cleaned: LyricLine[] = [] - for (const rawLine of lines || []) { - const rawWords = Array.isArray((rawLine as any).words) ? (rawLine as any).words : [] - const fixedWords: any[] = [] - let prevEnd = -1 - for (const rawWord of rawWords) { - const rawStart = toFiniteNumber(rawWord?.startTime, Number.NaN) - const rawEnd = toFiniteNumber(rawWord?.endTime, Number.NaN) - if (!Number.isFinite(rawStart)) continue - let startTime = Math.max(0, rawStart) - if (startTime < prevEnd) startTime = prevEnd - let endTime = Number.isFinite(rawEnd) ? rawEnd : startTime + 1 - if (endTime <= startTime) endTime = startTime + 1 - prevEnd = endTime - fixedWords.push({ ...rawWord, startTime, endTime }) +/** + * 多格式歌词解析器 - 兼容 PC/Android 版 QZMusic 返回的各种歌词格式 + * + * 支持的格式: + * - LRC: [00:12.34]歌词内容(带逐字 [00:12.34]<00:00.50>...) + * - QRC: <00:00.00> 逐字 XML 风格 / 腾讯 Q 音乐格式 + * - TTML/XML:

歌词

+ * - YRC: 网易云逐字 JSON 格式 { yrc: { version:1, lyric:[{...,words:[...]}] } } + * - JSON: { lrc: { lyric: "..." }, tlyric: {...}, yrc: {...} } + * - SRT: 1\n00:00:01,000 --> 00:00:03,000\n文本\n + * - VTT: WEBVTT\n\n00:00:01.000 --> 00:00:03.000\n文本 + * - 纯文本: 普通歌词,没有时间戳 + * + * 最终统一输出 { lines: LyricLine[] },每个 LyricLine 有 startTime、endTime、text、words + */ + +export interface LyricWord { + startTime: number; // 毫秒 + endTime: number; // 毫秒 + text: string; +} + +export interface LyricLine { + startTime: number; // 毫秒 + endTime: number; // 毫秒 + text: string; + words?: LyricWord[]; // 逐字信息(可选) + raw?: any; // 原始数据(调试用) +} + +const DEFAULT_LINE_DURATION = 5000; // 默认每行 5 秒 + +/** + * 主入口:根据给定的 { format, raw } 自动解析,或传入未知内容自动识别 + */ +export function parseAnyLyric(input: { format?: string | null; raw: any } | any): LyricLine[] { + let format: string | null = input?.format; + let raw: any = input?.raw; + + // 兼容直接传入字符串 / 对象 + if (format == null && raw == null) { + if (typeof input === 'string' || (typeof input === 'object' && (input as any) !== null)) { + raw = input; + } else { + return []; } - if (fixedWords.length === 0) continue - - const firstWordStart = fixedWords[0].startTime - const lastWordEnd = fixedWords[fixedWords.length - 1].endTime - let startTime = toFiniteNumber((rawLine as any).startTime, firstWordStart) - startTime = Math.max(0, startTime) - let endTime = toFiniteNumber((rawLine as any).endTime, lastWordEnd) - if (!Number.isFinite(endTime) || endTime <= startTime) endTime = startTime + defaultLineDuration - if (endTime < lastWordEnd) endTime = lastWordEnd - - cleaned.push({ ...(rawLine as any), startTime, endTime, words: fixedWords }) } - cleaned.sort((a: any, b: any) => (a?.startTime ?? 0) - (b?.startTime ?? 0)) - return cleaned -} -interface LyricData { - ttml?: string, - yrc?: string, - lrc?: string, - qrc?: string -} -export function parseLyric(lyric: LyricData):LyricLine[] { - let parsed:LyricLine[] = [] - if (lyric.ttml != undefined) { - parsed = parseTTML(lyric.ttml).lines; - } else if (lyric.yrc != undefined) { - parsed = parseYrc(lyric.yrc); - } else if (lyric.lrc != undefined) { - parsed = parseLrc(lyric.lrc); - } else if (lyric.qrc != undefined) { - parsed = parseQrc(lyric.qrc) + + if (raw == null) return []; + + if (!format) { + format = detectFormat(raw); } - return sanitizeLyricLines(parsed); -} \ No newline at end of file + + switch (format) { + case 'lrc': return parseLrc(typeof raw === 'string' ? raw : extractStringField(raw, 'lrc')); + case 'qrc': return parseQrc(typeof raw === 'string' ? raw : extractStringField(raw, 'qrc')); + case 'ttml': return parseTTML(typeof raw === 'string' ? raw : extractStringField(raw, 'ttml')); + case 'yrc': return parseYrc(typeof raw === 'object' && raw != null ? raw : tryParseJSON(String(raw))); + case 'srt': return parseSrt(String(raw)); + case 'vtt': return parseVtt(String(raw)); + case 'json': return parseJSON(raw); + case 'text': return parseText(String(raw)); + default: { + // 最后兜底:试各种解析,取第一个返回非空的 + if (typeof raw === 'string') { + const lrc = parseLrc(raw); + if (lrc.length > 0) return lrc; + return parseText(raw); + } + return parseJSON(raw); + } + } +} + +// ========== 格式探测 ========== +function detectFormat(raw: any): string | null { + if (typeof raw === 'string') { + const s = raw.trim(); + if (!s) return null; + if (/^<\s*(?:\?xml|tt\b|TT\b|lyric\b|Lyric\b|LyricData\b)/i.test(s)) return 'ttml'; + if (/<\s*\d+[::]\d+/.test(s)) return 'qrc'; + if (/\[\s*\d{1,2}[::]\d{1,2}(?:[.::]\d{1,3})?\s*\]/.test(s)) return 'lrc'; + const first = s.charAt(0); + if (first === '{' || first === '[') return 'json'; + return 'text'; + } + if (typeof raw === 'object' && raw !== null) { + if (raw.yrc || raw.lrclib || raw.klyric) return 'yrc'; + if (raw.lrc) return 'lrc'; + if (raw.ttml) return 'ttml'; + if (raw.qrc) return 'qrc'; + return 'json'; + } + return null; +} + +function extractStringField(obj: any, field: string): string { + if (typeof obj[field] === 'string') return obj[field]; + if (typeof obj[field]?.lyric === 'string') return obj[field].lyric; + return ''; +} + +function tryParseJSON(s: string): any { + try { return JSON.parse(s); } catch { return null; } +} + +// ========== LRC 解析 ========== +export function parseLrc(text: string): LyricLine[] { + if (!text) return []; + const lines: LyricLine[] = []; + // 支持多个时间戳同一行: [00:12.00][00:25.00]歌词 + const lineRegex = /((?:\[\s*\d{1,2}[::]\d{1,2}(?:[.::]\d{1,3})?\s*\])+)(.*)/g; + const timeRegex = /\[\s*(\d{1,2})[::](\d{1,2})(?:[.::](\d{1,3}))?\s*\]/g; + + let match; + while ((match = lineRegex.exec(text)) !== null) { + const stamps = match[1]; + const content = (match[2] || '').trim(); + if (!content) continue; + + let m; + timeRegex.lastIndex = 0; + while ((m = timeRegex.exec(stamps)) !== null) { + const mm = parseInt(m[1], 10); + const ss = parseInt(m[2], 10); + const msRaw = m[3] || '0'; + const ms = parseInt(msRaw.padEnd(3, '0').substring(0, 3), 10); + const startTime = mm * 60000 + ss * 1000 + ms; + lines.push({ startTime, endTime: startTime + DEFAULT_LINE_DURATION, text: content }); + } + } + + // 检查是否有逐字信息(QQ音乐 lrc 逐字扩展: [00:12.00]<00:00.00>字<00:00.50>字...) + for (const line of lines) { + const words = extractInlineWords(line.text, line.startTime); + if (words.length > 1) { + line.words = words; + line.endTime = words[words.length - 1].endTime; + line.text = words.map(w => w.text).join(''); + } + } + + sortAndFixEndTime(lines); + return lines; +} + +// ========== 逐字(LRC 内嵌 word 或 QRC 风格) ========== +function extractInlineWords(text: string, _lineStart: number): LyricWord[] { + // 匹配 字 或 字 + const regex = /<\s*(\d{1,2})[::](\d{1,2})(?:[.::](\d{1,3}))?\s*>([^<]*)/g; + const words: LyricWord[] = []; + let m; + while ((m = regex.exec(text)) !== null) { + const mm = parseInt(m[1], 10); + const ss = parseInt(m[2], 10); + const msRaw = m[3] || '0'; + const ms = parseInt(msRaw.padEnd(3, '0').substring(0, 3), 10); + const start = mm * 60000 + ss * 1000 + ms; + words.push({ startTime: start, endTime: start + 500, text: (m[4] || '').trim() }); + } + if (words.length > 1) { + for (let i = 0; i < words.length - 1; i++) { + words[i].endTime = words[i + 1].startTime; + } + } + return words; +} + +// ========== QRC 解析(QQ音乐逐字歌词 XML/文本风格) ========== +export function parseQrc(text: string): LyricLine[] { + if (!text) return []; + const lines: LyricLine[] = []; + // QRC 常见格式: [ms,ms]0 字/字/字 ... 或逐行 字 + // 先按行尝试 + const rawLines = text.split(/\r?\n/).filter(l => l.trim().length > 0); + for (const rawLine of rawLines) { + // 格式1: [start,end]1 字1字2字3... + const bracketMatch = rawLine.match(/\[\s*(\d+)\s*,\s*(\d+)\s*\](.*)/); + if (bracketMatch) { + const startTime = parseInt(bracketMatch[1], 10); + const endTime = parseInt(bracketMatch[2], 10); + let content = bracketMatch[3].replace(/^[\(\[]\d+[\)\]]/, '').trim(); + // 有时候是 "(10)字1字2字3" - 其中 (10) 是逐字时长索引 + lines.push({ startTime, endTime: endTime || startTime + DEFAULT_LINE_DURATION, text: content, raw: rawLine }); + continue; + } + + // 格式2: <00:12.340>字<00:12.840>字...(把一整行拆成逐字) + if (/<\s*\d+[::]\d+/.test(rawLine)) { + const words = extractInlineWords(rawLine, 0); + if (words.length > 0) { + const start = words[0].startTime; + const end = words[words.length - 1].endTime; + const combinedText = words.map(w => w.text).join(''); + lines.push({ startTime: start, endTime: end, text: combinedText, words }); + } + } + } + + if (lines.length === 0) { + // 尝试整段内嵌 字的流式文本 + const words = extractInlineWords(text, 0); + if (words.length > 0) { + // 按停顿把 words 聚合为行:这里简单把每个字当一行(用户机器若用不到会被 UI 平滑合并) + for (const w of words) { + lines.push({ startTime: w.startTime, endTime: w.endTime, text: w.text, words: [w] }); + } + } + } + + sortAndFixEndTime(lines); + return lines; +} + +// ========== TTML / XML 歌词 ========== +export function parseTTML(text: string): LyricLine[] { + if (!text) return []; + const lines: LyricLine[] = []; + + // 简易解析:用正则匹配所有

内容

+ const pRegex = /<\s*p\b([^>]*)>([\s\S]*?)<\s*\/\s*p\s*>/gi; + let m; + while ((m = pRegex.exec(text)) !== null) { + const attrs = m[1]; + const content = stripTags(m[2]).trim(); + if (!content) continue; + const begin = extractTimeAttr(attrs, /\bbegin\s*=\s*["']([^"']+)["']/i); + const end = extractTimeAttr(attrs, /\bend\s*=\s*["']([^"']+)["']/i); + if (begin == null) continue; + lines.push({ + startTime: begin, + endTime: end != null ? end : begin + DEFAULT_LINE_DURATION, + text: content, + }); + } + + if (lines.length === 0) { + // 退化为纯 Start="ms">内容 的 XML + const lineRegex = /<\s*(?:line|Line|LyricLine|Item)\b([^>]*)>([\s\S]*?)<\s*\/\s*(?:line|Line|LyricLine|Item)\s*>/gi; + while ((m = lineRegex.exec(text)) !== null) { + const attrs = m[1]; + const content = stripTags(m[2]).trim(); + if (!content) continue; + const start = extractNumericAttr(attrs, /\b(?:start|Start|Start\s*Time)\s*=\s*["']([^"']+)["']/i) + ?? extractNumericAttr(attrs, /\b(\d+)\b/); + let end = extractNumericAttr(attrs, /\b(?:end|End|End\s*Time)\s*=\s*["']([^"']+)["']/i); + if (start == null) continue; + if (end == null) end = start + DEFAULT_LINE_DURATION; + lines.push({ startTime: start, endTime: end, text: content }); + } + } + + sortAndFixEndTime(lines); + return lines; +} + +function stripTags(s: string): string { + return s.replace(/<[^>]+>/g, '').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); +} + +function extractTimeAttr(attrs: string, regex: RegExp): number | null { + const m = attrs.match(regex); + if (!m) return null; + return parseTimeString(m[1]); +} + +function extractNumericAttr(attrs: string, regex: RegExp): number | null { + const m = attrs.match(regex); + if (!m) return null; + const n = Number(m[1]); + return Number.isFinite(n) ? n : parseTimeString(m[1]); +} + +/** 解析 "HH:MM:SS.mmm" / "MM:SS.mmm" / "1234ms" / "12.34s" / "1234" */ +function parseTimeString(t: string): number | null { + if (!t) return null; + const s = t.trim(); + // 纯数字 → 毫秒或秒(小于 3600 视为秒) + if (/^\d+(\.\d+)?$/.test(s)) { + const n = Number(s); + return n < 3600 && s.includes('.') ? Math.round(n * 1000) : Math.round(n); + } + // HH:MM:SS.mmm / MM:SS.mmm + let m = s.match(/^(\d+):(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?/); + if (m) { + const h = parseInt(m[1], 10), mm = parseInt(m[2], 10), ss = parseInt(m[3], 10); + const msRaw = m[4] || '0'; + const ms = parseInt(msRaw.padEnd(3, '0').substring(0, 3), 10); + return h * 3600000 + mm * 60000 + ss * 1000 + ms; + } + m = s.match(/^(\d+):(\d{1,2})(?:[.,](\d{1,3}))?/); + if (m) { + const mm = parseInt(m[1], 10), ss = parseInt(m[2], 10); + const msRaw = m[3] || '0'; + const ms = parseInt(msRaw.padEnd(3, '0').substring(0, 3), 10); + return mm * 60000 + ss * 1000 + ms; + } + // 12.34s / 1234ms + m = s.match(/^(\d+(?:\.\d+)?)\s*(ms|s)?$/i); + if (m) { + const n = Number(m[1]); + const unit = (m[2] || 'ms').toLowerCase(); + return unit === 's' ? Math.round(n * 1000) : Math.round(n); + } + return null; +} + +// ========== YRC(网易云逐字歌词)========== +export function parseYrc(raw: any): LyricLine[] { + if (!raw) return []; + const lines: LyricLine[] = []; + // YRC 常见结构: { yrc: { version: 1, lyric: [ { "time": 1234, "words": [ ... ] } ] } } + // 也可能是 stringified JSON + let data: any = raw; + if (typeof raw === 'string') { + const p = tryParseJSON(raw); + if (p) data = p; + } + // 向下取字段 + const yrc = data?.yrc ?? data?.lrclib ?? data?.klyric ?? data; + const lyricArr = yrc?.lyric ?? yrc?.lines ?? yrc?.lyrics ?? (Array.isArray(yrc) ? yrc : null); + + if (Array.isArray(lyricArr)) { + for (const row of lyricArr) { + // row: { time: start_ms, duration: ms_line, "lyric": "word (start, duration) word2 (start2, duration2)" } + // 或 row: { t: ms, d: ms, w: [ {t:ms,s:ms}, ... ] } + const startTime = toNumber(row?.time ?? row?.t ?? row?.start ?? row?.startTime ?? 0); + const duration = toNumber(row?.duration ?? row?.d ?? row?.dur ?? DEFAULT_LINE_DURATION); + const wordsRaw = row?.words ?? row?.w ?? row?.lyric ?? row?.text ?? ''; + + if (Array.isArray(wordsRaw)) { + const wordList: LyricWord[] = []; + for (const w of wordsRaw) { + const ws = toNumber(w?.time ?? w?.t ?? w?.start ?? 0); + const we = ws + toNumber(w?.duration ?? w?.d ?? 500); + const text = String(w?.text ?? w?.word ?? w?.name ?? '').trim(); + if (text) wordList.push({ startTime: ws + startTime, endTime: we + startTime, text }); + } + if (wordList.length > 0) { + const fullText = wordList.map(w => w.text).join(''); + lines.push({ + startTime: wordList[0].startTime, + endTime: wordList[wordList.length - 1].endTime, + text: fullText, + words: wordList, + }); + continue; + } + } + // 回退:纯文本形式的一行 + const text = String(wordsRaw || row?.text || '').trim(); + if (text) { + lines.push({ startTime, endTime: startTime + duration, text }); + } + } + } + + if (lines.length === 0) { + // 有些返回 { lrc: { lyric: "..." } } + const lrc = data?.lrc?.lyric; + if (typeof lrc === 'string') return parseLrc(lrc); + } + + sortAndFixEndTime(lines); + return lines; +} + +function toNumber(v: any): number { + if (typeof v === 'number') return v; + if (typeof v === 'string') { + const n = Number(v); + return Number.isFinite(n) ? n : 0; + } + return 0; +} + +// ========== JSON(通用对象形式) ========== +export function parseJSON(raw: any): LyricLine[] { + if (raw == null) return []; + if (typeof raw === 'string') { + // 先当 JSON 试一下 + const obj = tryParseJSON(raw); + if (obj) return parseJSON(obj); + // 失败就退回 LRC + return parseLrc(raw); + } + + // 优先看有没有 lrc / yrc / ttml / qrc 字段 + if (typeof raw.lrc?.lyric === 'string') return parseLrc(raw.lrc.lyric); + if (typeof raw.lrc === 'string') return parseLrc(raw.lrc); + if (typeof raw.yrc === 'string' || raw.yrc?.lyric) return parseYrc(raw); + if (typeof raw.ttml === 'string') return parseTTML(raw.ttml); + if (typeof raw.qrc === 'string') return parseQrc(raw.qrc); + + // 数组形式: [{ time: 1234, text: "..." }] + if (Array.isArray(raw)) { + return raw.map((item: any) => { + const startTime = toNumber(item?.time ?? item?.t ?? item?.start ?? item?.startTime ?? 0); + const endTime = toNumber(item?.end ?? item?.endTime ?? 0) || startTime + DEFAULT_LINE_DURATION; + return { startTime, endTime, text: String(item?.text ?? item?.content ?? item?.lyric ?? '').trim() }; + }).filter(l => l.text); + } + if (Array.isArray(raw.lines)) { + return parseJSON(raw.lines); + } + if (Array.isArray(raw.lyric)) { + return parseJSON(raw.lyric); + } + return []; +} + +// ========== SRT 字幕 ========== +export function parseSrt(text: string): LyricLine[] { + if (!text) return []; + const lines: LyricLine[] = []; + const blocks = text.replace(/\r\n/g, '\n').split(/\n{2,}/); + for (const block of blocks) { + const rows = block.split('\n').filter(Boolean); + if (rows.length < 2) continue; + const timeLine = rows[0].includes('-->') ? rows[0] : rows[1]; + const textStart = rows[0].includes('-->') ? 1 : 2; + const t = timeLine.match(/(\d+):(\d{2}):(\d{2})[.,](\d{1,3})\s*-->\s*(\d+):(\d{2}):(\d{2})[.,](\d{1,3})/); + if (!t) continue; + const start = (+t[1]) * 3600000 + (+t[2]) * 60000 + (+t[3]) * 1000 + parseInt((t[4] || '0').padEnd(3, '0').substring(0, 3), 10); + const end = (+t[5]) * 3600000 + (+t[6]) * 60000 + (+t[7]) * 1000 + parseInt((t[8] || '0').padEnd(3, '0').substring(0, 3), 10); + const content = rows.slice(textStart).join(' ').trim(); + if (content) lines.push({ startTime: start, endTime: end, text: content }); + } + sortAndFixEndTime(lines); + return lines; +} + +// ========== WebVTT ========== +export function parseVtt(text: string): LyricLine[] { + if (!text) return []; + // VTT 和 SRT 几乎一样,只是开头是 WEBVTT,并且时间格式点号 + const lines: LyricLine[] = []; + const clean = text.replace(/^WEBVTT[\s\S]*?\n\n/i, '').replace(/\r\n/g, '\n'); + const blocks = clean.split(/\n{2,}/); + for (const block of blocks) { + const rows = block.split('\n').filter(Boolean); + if (rows.length < 1) continue; + const timeLine = rows.find(r => r.includes('-->')); + if (!timeLine) continue; + const idx = rows.indexOf(timeLine); + const t = timeLine.match(/(\d+):(\d{2})(?::(\d{2}))?(?:[.,](\d{1,3}))?\s*-->\s*(\d+):(\d{2})(?::(\d{2}))?(?:[.,](\d{1,3}))?/); + if (!t) continue; + let start, end; + if (t[3] != null) { + // HH:MM:SS.mmm + start = (+t[1]) * 3600000 + (+t[2]) * 60000 + (+t[3]) * 1000 + parseInt((t[4] || '0').padEnd(3, '0').substring(0, 3), 10); + end = (+t[5]) * 3600000 + (+t[6]) * 60000 + (+t[7]) * 1000 + parseInt((t[8] || '0').padEnd(3, '0').substring(0, 3), 10); + } else { + // MM:SS.mmm + start = (+t[1]) * 60000 + (+t[2]) * 1000 + parseInt((t[4] || '0').padEnd(3, '0').substring(0, 3), 10); + end = (+t[5]) * 60000 + (+t[6]) * 1000 + parseInt((t[8] || '0').padEnd(3, '0').substring(0, 3), 10); + } + const content = rows.slice(idx + 1).join(' ').trim(); + if (content) lines.push({ startTime: start, endTime: end, text: content }); + } + sortAndFixEndTime(lines); + return lines; +} + +// ========== 纯文本 ========== +export function parseText(text: string): LyricLine[] { + if (!text) return []; + const rows = text.split(/\r?\n/).map(s => s.trim()).filter(Boolean); + const lines: LyricLine[] = []; + let cursor = 0; + for (const row of rows) { + const start = cursor; + const dur = Math.max(3000, row.length * 250); + lines.push({ startTime: start, endTime: start + dur, text: row }); + cursor += dur; + } + return lines; +} + +// ========== 公共工具:排序 & 修正每行的 endTime ========== +function sortAndFixEndTime(lines: LyricLine[]) { + if (!lines || lines.length === 0) return; + lines.sort((a, b) => a.startTime - b.startTime); + for (let i = 0; i < lines.length - 1; i++) { + if (lines[i].endTime == null || lines[i].endTime <= lines[i].startTime || lines[i].endTime > lines[i + 1].startTime) { + lines[i].endTime = lines[i + 1].startTime; + } + } + const last = lines[lines.length - 1]; + if (last.endTime == null || last.endTime <= last.startTime) { + last.endTime = last.startTime + DEFAULT_LINE_DURATION; + } +} + +/** + * 把输入归一化成 { lines: LyricLine[] } 形式,用于与现有调用方兼容 + */ +export function normalizeLyric(input: any): { lines: LyricLine[] } { + return { lines: parseAnyLyric(input) }; +} + +// @applemusic-like-lyrics/vue 的 LyricPlayer 需要 lines 中每项有 +// startTime、endTime、text(以及可选的 words),与上面的 LyricLine 完全兼容 +// 因此直接返回 parseAnyLyric() 的结果即可 +