mirror of
https://github.com/imsyy/SPlayer.git
synced 2025-11-25 03:14:57 +08:00
✨feat(LyricWithTTMLFormat): 支持从steveXMH仓库获取TTML歌词
This commit is contained in:
20
package.json
20
package.json
@@ -40,7 +40,7 @@
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@imsyy/color-utils": "^1.0.2",
|
||||
"@material/material-color-utilities": "^0.3.0",
|
||||
"@neteaseapireborn/api": "^4.29.2",
|
||||
"@neteaseapireborn/api": "^4.29.7",
|
||||
"@pixi/app": "^7.4.3",
|
||||
"@pixi/core": "^7.4.3",
|
||||
"@pixi/display": "^7.4.3",
|
||||
@@ -85,9 +85,9 @@
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^22.18.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.43.0",
|
||||
"@typescript-eslint/parser": "^8.43.0",
|
||||
"@types/node": "^22.18.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.45.0",
|
||||
"@typescript-eslint/parser": "^8.45.0",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"ajv": "^8.17.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
@@ -95,24 +95,24 @@
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-vite": "^3.1.0",
|
||||
"eslint": "^9.35.0",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-vue": "^9.33.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fastify": "^5.6.0",
|
||||
"fastify": "^5.6.1",
|
||||
"naive-ui": "^2.43.1",
|
||||
"node-taglib-sharp": "^6.0.1",
|
||||
"prettier": "^3.6.2",
|
||||
"sass": "^1.92.1",
|
||||
"sass": "^1.93.2",
|
||||
"terser": "^5.44.0",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"unplugin-auto-import": "^0.19.0",
|
||||
"unplugin-vue-components": "^28.8.0",
|
||||
"vite": "^5.4.20",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vue": "^3.5.21",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue-tsc": "^3.0.7"
|
||||
"vue-tsc": "^3.1.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
|
||||
1170
pnpm-lock.yaml
generated
1170
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -65,6 +65,23 @@ export const songLyric = (id: number) => {
|
||||
});
|
||||
};
|
||||
|
||||
// 获取格式TTML的歌词
|
||||
export const songLyricTTML = async (id: number) => {
|
||||
const url = `https://amll-ttml-db.stevexmh.net/ncm/${id}`;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response === null || response.status !== 200) {
|
||||
console.error(`TTML API请求失败或TTML仓库没有歌词, 将会使用默认歌词`);
|
||||
return null;
|
||||
}
|
||||
const data = await response.text();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('TTML API请求出错:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取歌曲下载链接
|
||||
* @param id 音乐 id
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
<template>
|
||||
<Transition>
|
||||
<div
|
||||
:key="amLyricsData?.[0]?.startTime"
|
||||
:class="['lyric-am', { pure: statusStore.pureLyricMode }]"
|
||||
>
|
||||
<LyricPlayer
|
||||
ref="lyricPlayerRef"
|
||||
:lyricLines="amLyricsData"
|
||||
:currentTime="playSeek"
|
||||
:playing="statusStore.playStatus"
|
||||
:enableSpring="settingStore.useAMSpring"
|
||||
<div :key="amLyricsData?.[0]?.startTime" :class="['lyric-am', { pure: statusStore.pureLyricMode }]">
|
||||
<LyricPlayer ref="lyricPlayerRef" :lyricLines="amLyricsData" :currentTime="playSeek"
|
||||
:playing="statusStore.playStatus" :enableSpring="settingStore.useAMSpring"
|
||||
:enableScale="settingStore.useAMSpring"
|
||||
:alignPosition="settingStore.lyricsScrollPosition === 'center' ? 0.5 : 0.2"
|
||||
:enableBlur="settingStore.lyricsBlur"
|
||||
:style="{
|
||||
:enableBlur="settingStore.lyricsBlur" :style="{
|
||||
'--amll-lyric-view-color': mainColor,
|
||||
'--amll-lyric-player-font-size': settingStore.lyricFontSize + 'px',
|
||||
'font-weight': settingStore.lyricFontBold ? 'bold' : 'normal',
|
||||
'font-family': settingStore.LyricFont !== 'follow' ? settingStore.LyricFont : '',
|
||||
}"
|
||||
class="am-lyric"
|
||||
@line-click="jumpSeek"
|
||||
/>
|
||||
}" class="am-lyric" @line-click="jumpSeek" />
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
@@ -54,10 +43,39 @@ const mainColor = computed(() => {
|
||||
return `rgb(${statusStore.mainColor})`;
|
||||
});
|
||||
|
||||
// 检查是否为纯音乐歌词
|
||||
const isPureInstrumental = (lyrics: LyricLine[]): boolean => {
|
||||
if (!lyrics || lyrics.length === 0) return false;
|
||||
const instrumentalKeywords = ['纯音乐', 'instrumental', '请欣赏'];
|
||||
|
||||
if (lyrics.length === 1) {
|
||||
const content = lyrics[0].words?.[0]?.word || '';
|
||||
return instrumentalKeywords.some(keyword => content.toLowerCase().includes(keyword.toLowerCase()));
|
||||
}
|
||||
|
||||
if (lyrics.length <= 3) {
|
||||
const allContent = lyrics.map(line => line.words?.[0]?.word || '').join('');
|
||||
return instrumentalKeywords.some(keyword => allContent.toLowerCase().includes(keyword.toLowerCase()));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 当前歌词
|
||||
const amLyricsData = computed<LyricLine[]>(() => {
|
||||
const isYrc = musicStore.songLyric.yrcData?.length && settingStore.showYrc;
|
||||
return isYrc ? musicStore.songLyric.yrcAMData : musicStore.songLyric.lrcAMData;
|
||||
const { songLyric } = musicStore;
|
||||
if (!songLyric) return [];
|
||||
|
||||
// 优先使用逐字歌词(YRC/TTML)
|
||||
const useYrc = songLyric.yrcAMData?.length && settingStore.showYrc;
|
||||
const lyrics = useYrc ? songLyric.yrcAMData : songLyric.lrcAMData;
|
||||
|
||||
// 简单检查歌词有效性
|
||||
if (!Array.isArray(lyrics) || lyrics.length === 0) return [];
|
||||
|
||||
// 检查是否为纯音乐
|
||||
if (isPureInstrumental(lyrics)) return [];
|
||||
|
||||
return lyrics;
|
||||
});
|
||||
|
||||
// 进度跳转
|
||||
@@ -85,15 +103,14 @@ onBeforeUnmount(() => {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
filter: drop-shadow(0px 4px 6px rgba(0, 0, 0, 0.2));
|
||||
mask: linear-gradient(
|
||||
180deg,
|
||||
hsla(0, 0%, 100%, 0) 0,
|
||||
hsla(0, 0%, 100%, 0.6) 5%,
|
||||
#fff 10%,
|
||||
#fff 75%,
|
||||
hsla(0, 0%, 100%, 0.6) 85%,
|
||||
hsla(0, 0%, 100%, 0)
|
||||
);
|
||||
mask: linear-gradient(180deg,
|
||||
hsla(0, 0%, 100%, 0) 0,
|
||||
hsla(0, 0%, 100%, 0.6) 5%,
|
||||
#fff 10%,
|
||||
#fff 75%,
|
||||
hsla(0, 0%, 100%, 0.6) 85%,
|
||||
hsla(0, 0%, 100%, 0));
|
||||
|
||||
:deep(.am-lyric) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -104,11 +121,14 @@ onBeforeUnmount(() => {
|
||||
padding-right: 80px;
|
||||
margin-left: -2rem;
|
||||
}
|
||||
|
||||
&.pure {
|
||||
text-align: center;
|
||||
|
||||
:deep(.am-lyric) {
|
||||
margin: 0;
|
||||
padding: 0 80px;
|
||||
|
||||
div {
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
@@ -260,6 +260,15 @@
|
||||
</div>
|
||||
<n-switch v-model:value="settingStore.useAMSpring" class="set" :round="false" />
|
||||
</n-card>
|
||||
<n-card class="set-item">
|
||||
<div class="label">
|
||||
<n-text class="name">启用 TTML 歌词</n-text>
|
||||
<n-text class="tip" :depth="3">
|
||||
是否启用 TTML 歌词(如有),TTML 歌词支持逐字、翻译、音译等功能, 将会在下一首歌生效
|
||||
</n-text>
|
||||
</div>
|
||||
<n-switch v-model:value="settingStore.enableTTMLLyric" class="set" :round="false" />
|
||||
</n-card>
|
||||
</div>
|
||||
<div v-if="isElectron" class="set-list">
|
||||
<n-h3 prefix="bar"> 桌面歌词 </n-h3>
|
||||
|
||||
@@ -73,6 +73,7 @@ interface SettingState {
|
||||
showSearchHistory: boolean;
|
||||
useAMLyrics: boolean;
|
||||
useAMSpring: boolean;
|
||||
enableTTMLLyric: boolean;
|
||||
menuShowCover: boolean;
|
||||
preventSleep: boolean;
|
||||
localFilesPath: string[];
|
||||
@@ -138,6 +139,7 @@ export const useSettingStore = defineStore("setting", {
|
||||
lyricFontBold: true, // 歌词字体加粗
|
||||
useAMLyrics: false, // 是否使用 AM 歌词
|
||||
useAMSpring: false, // 是否使用 AM 歌词弹簧效果
|
||||
enableTTMLLyric: true, // 启用 TTML 歌词
|
||||
showYrc: true, // 显示逐字歌词
|
||||
showYrcAnimation: true, // 显示逐字歌词动画
|
||||
showTran: true, // 显示歌词翻译
|
||||
|
||||
109
src/types/amll.d.ts
vendored
Normal file
109
src/types/amll.d.ts
vendored
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* AMLL (Apple Music-like Lyrics) 相关类型定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 歌词播放器引用类型
|
||||
*/
|
||||
export interface LyricPlayerRef {
|
||||
setCurrentTime?: (time: number) => void;
|
||||
setPlaying?: (playing: boolean) => void;
|
||||
lyricPlayer?: { value?: any };
|
||||
}
|
||||
|
||||
/**
|
||||
* 歌词单词类型
|
||||
*/
|
||||
export interface LyricWord {
|
||||
word: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 歌词行类型
|
||||
*/
|
||||
export interface LyricLine {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
words: LyricWord[];
|
||||
translatedLyric?: string;
|
||||
romanLyric?: string;
|
||||
isBG?: boolean;
|
||||
isDuet?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 歌词点击事件类型
|
||||
*/
|
||||
export interface LyricClickEvent {
|
||||
line: {
|
||||
getLine: () => LyricLine;
|
||||
lyricLine?: LyricLine;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹簧参数类型
|
||||
*/
|
||||
export interface SpringParam {
|
||||
mass: number; // 质量,影响弹簧的惯性
|
||||
damping: number; // 阻尼,影响弹簧的减速速度
|
||||
stiffness: number; // 刚度,影响弹簧的弹力
|
||||
soft: boolean; // 是否使用软弹簧模式
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹簧参数集合
|
||||
*/
|
||||
export interface SpringParams {
|
||||
posX?: SpringParam;
|
||||
posY?: SpringParam;
|
||||
scale?: SpringParam;
|
||||
rotation?: SpringParam;
|
||||
}
|
||||
|
||||
/**
|
||||
* 背景渲染器引用类型
|
||||
*/
|
||||
export interface BackgroundRenderRef {
|
||||
bgRender: any;
|
||||
wrapperEl?: HTMLDivElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* 背景渲染器属性类型
|
||||
*/
|
||||
export interface BackgroundRenderProps {
|
||||
album?: string;
|
||||
albumIsVideo?: boolean;
|
||||
fps?: number;
|
||||
playing?: boolean;
|
||||
flowSpeed?: number;
|
||||
hasLyric?: boolean;
|
||||
lowFreqVolume?: number;
|
||||
renderScale?: number;
|
||||
staticMode?: boolean;
|
||||
renderer?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 歌词处理器设置类型
|
||||
*/
|
||||
export interface LyricsProcessorSettings {
|
||||
showYrc: boolean;
|
||||
showRoma: boolean;
|
||||
showTransl: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 歌曲歌词类型
|
||||
*/
|
||||
export interface SongLyric {
|
||||
lrc?: Array<{time: number, content: string, tran?: string, roma?: string}>;
|
||||
yrc?: Array<{time: number, endTime?: number, content: any[], tran?: string, roma?: string}>;
|
||||
ttml?: string;
|
||||
hasYrc?: boolean;
|
||||
lrcAMData?: LyricLine[];
|
||||
yrcAMData?: LyricLine[];
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LyricLine, parseLrc, parseYrc } from "@applemusic-like-lyrics/lyric";
|
||||
import type { LyricType } from "@/types/main";
|
||||
import { LyricLine, parseLrc, parseYrc, parseTTML } from "@applemusic-like-lyrics/lyric";
|
||||
import type { LyricType, } from "@/types/main";
|
||||
import type { LyricLine as AMLLLyricLine } from "@/types/amll";
|
||||
import { useMusicStore, useSettingStore } from "@/stores";
|
||||
import { msToS } from "./time";
|
||||
|
||||
@@ -207,3 +208,52 @@ const parseAMData = (lrcData: LyricLine[], tranData?: LyricLine[], romaData?: Ly
|
||||
isDuet: line.isDuet ?? false,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 从TTML格式解析歌词并转换为AMLL格式
|
||||
* @param ttmlContent TTML格式的歌词内容
|
||||
* @returns AMLL格式的歌词行数组
|
||||
*/
|
||||
export function parseTTMLToAMLL(ttmlContent: string): LyricLine[] {
|
||||
if (!ttmlContent) return [];
|
||||
|
||||
try {
|
||||
const parsedResult = parseTTML(ttmlContent);
|
||||
if (!parsedResult?.lines?.length) return [];
|
||||
|
||||
const validLines = parsedResult.lines
|
||||
.filter((line): line is any =>
|
||||
line && typeof line === 'object' && Array.isArray(line.words)
|
||||
)
|
||||
.map(line => {
|
||||
const words = line.words
|
||||
.filter((word: any) => word && typeof word === 'object')
|
||||
.map((word: any) => ({
|
||||
word: String(word.word || ' '),
|
||||
startTime: Number(word.startTime) || 0,
|
||||
endTime: Number(word.endTime) || 0
|
||||
}));
|
||||
|
||||
if (!words.length) return null;
|
||||
|
||||
const startTime = words[0].startTime;
|
||||
const endTime = words[words.length - 1].endTime;
|
||||
|
||||
return {
|
||||
words,
|
||||
startTime,
|
||||
endTime,
|
||||
translatedLyric: String(line.translatedLyric || ''),
|
||||
romanLyric: String(line.romanLyric || ''),
|
||||
isBG: Boolean(line.isBG),
|
||||
isDuet: Boolean(line.isDuet)
|
||||
};
|
||||
})
|
||||
.filter((line): line is LyricLine => line !== null);
|
||||
|
||||
return validLines;
|
||||
} catch (error) {
|
||||
console.error('TTML parsing error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { MessageReactive } from "naive-ui";
|
||||
import { Howl, Howler } from "howler";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { useMusicStore, useStatusStore, useDataStore, useSettingStore } from "@/stores";
|
||||
import { parsedLyricsData, resetSongLyric, parseLocalLyric } from "./lyric";
|
||||
import { songUrl, unlockSongUrl, songLyric, songChorus } from "@/api/song";
|
||||
import { parsedLyricsData, resetSongLyric, parseLocalLyric, parseTTMLToAMLL } from "./lyric";
|
||||
import { songUrl, unlockSongUrl, songLyric, songChorus, songLyricTTML } from "@/api/song";
|
||||
import { getCoverColorData } from "@/utils/color";
|
||||
import { calculateProgress } from "./time";
|
||||
import { isElectron, isDev } from "./helper";
|
||||
@@ -421,8 +421,35 @@ class Player {
|
||||
resetSongLyric();
|
||||
return;
|
||||
}
|
||||
const lyricRes = await songLyric(id);
|
||||
parsedLyricsData(lyricRes);
|
||||
|
||||
try {
|
||||
const musicStore = useMusicStore();
|
||||
const settingStore = useSettingStore();
|
||||
const [lyricRes, ttmlContent] = await Promise.all([
|
||||
songLyric(id),
|
||||
songLyricTTML(id)
|
||||
]);
|
||||
parsedLyricsData(lyricRes);
|
||||
if (ttmlContent && settingStore.enableTTMLLyric) {
|
||||
const ttmlLyric = parseTTMLToAMLL(ttmlContent);
|
||||
if (ttmlLyric?.length > 0) {
|
||||
settingStore.showYrc = true;
|
||||
musicStore.songLyric = {
|
||||
...musicStore.songLyric,
|
||||
yrcAMData: ttmlLyric,
|
||||
hasLrcTran: ttmlLyric.some(line => line.translatedLyric),
|
||||
hasLrcRoma: ttmlLyric.some(line => line.romanLyric),
|
||||
hasYrc: true
|
||||
};
|
||||
console.log("✅ TTML lyrics enabled");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ Error loading lyrics:", error);
|
||||
resetSongLyric();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 获取副歌时间
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"types": ["node", "electron", "electron-vite/node", "./auto-imports.d.ts", "./components.d.ts"]
|
||||
"types": ["node", "electron", "electron-vite/node", "./auto-imports.d.ts", "./components.d.ts"],
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"target": "ES2020"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user