Compare commits

...

4 Commits

Author SHA1 Message Date
imsyy
6caf99da09 feat(player): 添加播放状态信息显示功能
- 在设置中新增开关控制是否显示播放状态信息
- 在播放器界面添加当前歌曲及歌词状态信息显示
- 优化歌词管理器逻辑,改进TTML歌词处理
- 调整播放器数据组件布局,支持精简显示模式
2025-11-20 00:14:05 +08:00
底层用户
ad27d1eaea Merge pull request #578 from MoYingJi/feat
fix(local-lyric): 现在会匹配带前缀的歌词文件名
2025-11-19 22:13:30 +08:00
MoYingJi
8846d7f669 fix(local-lyric): 现在会匹配带前缀的歌词文件名
也添加了注释解释 `{,*.}` 的作用,避免被误删
2025-11-19 21:36:48 +08:00
imsyy
807b72ed9e 🌈 style: 优化样式 2025-11-19 18:32:39 +08:00
7 changed files with 176 additions and 100 deletions

View File

@@ -204,9 +204,11 @@ const initFileIpc = (): void => {
try {
// 定义需要查找的模式
// 此处的 `{,*.}` 表示这里可以取 `` (empty) 也可以取 `*.`
// 将歌词文件命名为 `歌曲ID.后缀名` 或者 `任意前缀.歌曲ID.后缀名` 均可
const patterns = {
ttml: `**/${id}.ttml`,
lrc: `**/${id}.lrc`,
ttml: `**/{,*.}${id}.ttml`,
lrc: `**/{,*.}${id}.lrc`,
};
// 遍历每一个目录

View File

@@ -217,6 +217,9 @@ const changeGlobalTheme = () => {
railColor: toRGBA(primaryRGB, 0.2),
railColorHover: toRGBA(primaryRGB, 0.3),
},
Popover: {
color: `rgb(${surfaceContainerRGB})`,
},
};
}
} catch (error) {

View File

@@ -37,14 +37,7 @@
@mousemove="playerMove"
>
<Transition name="zoom">
<div
v-if="
!(statusStore.pureLyricMode && musicStore.isHasLrc) ||
musicStore.playSong.type === 'radio'
"
:key="musicStore.playSong.id"
class="content-left"
>
<div v-if="!pureLyricMode" :key="musicStore.playSong.id" class="content-left">
<!-- 封面 -->
<PlayerCover />
<!-- 数据 -->
@@ -58,6 +51,7 @@
v-if="statusStore.pureLyricMode && musicStore.isHasLrc"
:center="statusStore.pureLyricMode"
:theme="statusStore.mainColor"
:light="pureLyricMode"
/>
<!-- 歌词 -->
<MainAMLyric v-if="settingStore.useAMLyrics" />
@@ -100,11 +94,16 @@ const isShowComment = computed<boolean>(
const noLrc = computed<boolean>(() => {
const noNormalLrc = !musicStore.isHasLrc;
const noYrcAvailable = !musicStore.isHasYrc || !settingStore.showYrc;
const notLoading = !statusStore.lyricLoading;
// const notLoading = !statusStore.lyricLoading;
return noNormalLrc && noYrcAvailable && notLoading;
return noNormalLrc && noYrcAvailable;
});
/** 是否处于纯净模式 */
const pureLyricMode = computed<boolean>(
() => (statusStore.pureLyricMode && musicStore.isHasLrc) || musicStore.playSong.type === "radio",
);
// 主内容 key
const playerContentKey = computed(() => `${statusStore.pureLyricMode}`);

View File

@@ -1,10 +1,14 @@
<template>
<div :class="['player-data', settingStore.playerType, { center }]">
<div :class="['player-data', settingStore.playerType, { center, light }]">
<!-- 名称 -->
<div class="name">
<span class="name-text text-hidden">{{ musicStore.playSong.name || "未知曲目" }}</span>
<!-- 额外信息 -->
<div v-if="statusStore.playUblock || musicStore.playSong.pc" class="extra-info">
<n-flex
v-if="statusStore.playUblock || musicStore.playSong.pc"
class="extra-info"
align="center"
>
<n-popover :show-arrow="false" placement="right" raw>
<template #trigger>
<SvgIcon
@@ -21,58 +25,74 @@
}}
</div>
</n-popover>
</div>
</n-flex>
</div>
<!-- 别名 -->
<span v-if="musicStore.playSong.alia" class="alia text-hidden">
{{ musicStore.playSong.alia }}
</span>
<!-- 歌手 -->
<div v-if="musicStore.playSong.type !== 'radio'" class="artists">
<SvgIcon :depth="3" name="Artist" size="20" />
<div v-if="Array.isArray(musicStore.playSong.artists)" class="ar-list">
<n-flex :align="center ? 'center' : undefined" size="small" vertical>
<!-- 播放状态 -->
<n-flex
v-if="settingStore.showPlayMeta && !light"
class="play-meta"
size="small"
align="center"
>
<!-- 歌词模式 -->
<span class="meta-item">{{ lyricMode }}</span>
<!-- 是否在线 -->
<span class="meta-item">
{{ musicStore.playSong.path ? "LOCAL" : "ONLINE" }}
</span>
</n-flex>
<!-- 歌手 -->
<div v-if="musicStore.playSong.type !== 'radio'" class="artists">
<SvgIcon :depth="3" name="Artist" size="20" />
<div v-if="Array.isArray(musicStore.playSong.artists)" class="ar-list">
<span
v-for="ar in musicStore.playSong.artists"
:key="ar.id"
class="ar"
@click="jumpPage({ name: 'artist', query: { id: ar.id } })"
>
{{ ar.name }}
</span>
</div>
<div v-else class="ar-list">
<span class="ar">{{ musicStore.playSong.artists || "未知艺术家" }}</span>
</div>
</div>
<div v-else class="artists">
<SvgIcon :depth="3" name="Artist" size="20" />
<div class="ar-list">
<span class="ar">{{ musicStore.playSong.dj?.creator || "未知艺术家" }}</span>
</div>
</div>
<!-- 专辑 -->
<div v-if="musicStore.playSong.type !== 'radio'" class="album">
<SvgIcon :depth="3" name="Album" size="20" />
<span
v-for="ar in musicStore.playSong.artists"
:key="ar.id"
class="ar"
@click="jumpPage({ name: 'artist', query: { id: ar.id } })"
v-if="isObject(musicStore.playSong.album)"
class="name-text text-hidden"
@click="jumpPage({ name: 'album', query: { id: musicStore.playSong.album.id } })"
>
{{ ar.name }}
{{ musicStore.playSong.album?.name || "未知专辑" }}
</span>
<span v-else class="name-text text-hidden">
{{ musicStore.playSong.album || "未知专辑" }}
</span>
</div>
<div v-else class="ar-list">
<span class="ar">{{ musicStore.playSong.artists || "未知艺术家" }}</span>
</div>
</div>
<div v-else class="artists">
<SvgIcon :depth="3" name="Artist" size="20" />
<div class="ar-list">
<span class="ar">{{ musicStore.playSong.dj?.creator || "未知艺术家" }}</span>
</div>
</div>
<!-- 专辑 -->
<div v-if="musicStore.playSong.type !== 'radio'" class="album">
<SvgIcon :depth="3" name="Album" size="20" />
<span
v-if="isObject(musicStore.playSong.album)"
class="name-text text-hidden"
@click="jumpPage({ name: 'album', query: { id: musicStore.playSong.album.id } })"
<!-- 电台 -->
<div
v-if="musicStore.playSong.type === 'radio'"
class="dj"
@click="jumpPage({ name: 'dj', query: { id: musicStore.playSong.dj?.id } })"
>
{{ musicStore.playSong.album?.name || "未知专辑" }}
</span>
<span v-else class="name-text text-hidden">
{{ musicStore.playSong.album || "未知专辑" }}
</span>
</div>
<!-- 电台 -->
<div
v-if="musicStore.playSong.type === 'radio'"
class="dj"
@click="jumpPage({ name: 'dj', query: { id: musicStore.playSong.dj?.id } })"
>
<SvgIcon :depth="3" name="Podcast" size="20" />
<span class="name-text text-hidden">{{ musicStore.playSong.dj?.name || "播客电台" }}</span>
</div>
<SvgIcon :depth="3" name="Podcast" size="20" />
<span class="name-text text-hidden">{{ musicStore.playSong.dj?.name || "播客电台" }}</span>
</div>
</n-flex>
</div>
</template>
@@ -84,6 +104,8 @@ import { debounce, isObject } from "lodash-es";
defineProps<{
center?: boolean;
theme?: string;
// 少量数据模式
light?: boolean;
}>();
const router = useRouter();
@@ -91,6 +113,15 @@ const musicStore = useMusicStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
// 当前歌词模式
const lyricMode = computed(() => {
if (settingStore.showYrc) {
if (statusStore.usingTTMLLyric) return "TTML";
if (musicStore.isHasYrc) return "YRC";
}
return musicStore.isHasLrc ? "LRC" : "NO-LRC";
});
const jumpPage = debounce(
(go: RouteLocationRaw) => {
if (!go) return;
@@ -134,14 +165,13 @@ const jumpPage = debounce(
}
}
.alia {
margin: 6px 0 6px 2px;
margin: 6px 0 6px 4px;
opacity: 0.6;
font-size: 18px;
line-clamp: 1;
-webkit-line-clamp: 1;
}
.artists {
margin-top: 2px;
display: flex;
align-items: center;
.n-icon {
@@ -178,7 +208,6 @@ const jumpPage = debounce(
}
.album,
.dj {
margin-top: 2px;
font-size: 16px;
display: flex;
align-items: center;
@@ -196,6 +225,16 @@ const jumpPage = debounce(
}
}
}
.play-meta {
padding: 4px 4px;
opacity: 0.6;
.meta-item {
font-size: 12px;
border-radius: 8px;
padding: 2px 6px;
border: 1px solid rgba(var(--main-color), 0.6);
}
}
&.record {
width: 100%;
padding: 0 80px 0 24px;
@@ -219,6 +258,20 @@ const jumpPage = debounce(
text-align: center;
}
}
&.light {
.name {
.name-text {
line-clamp: 1;
-webkit-line-clamp: 1;
}
.extra-info {
display: none;
}
}
.alia {
display: none;
}
}
}
.player-tip {
max-width: 240px;

View File

@@ -155,6 +155,13 @@
</div>
<n-switch v-model:value="settingStore.barLyricShow" class="set" :round="false" />
</n-card>
<n-card class="set-item">
<div class="label">
<n-text class="name">展示播放状态信息</n-text>
<n-text class="tip" :depth="3">展示当前歌曲及歌词的状态信息</n-text>
</div>
<n-switch v-model:value="settingStore.showPlayMeta" class="set" :round="false" />
</n-card>
<n-card class="set-item">
<div class="label">
<n-text class="name">播放列表歌曲数量</n-text>

View File

@@ -169,6 +169,8 @@ export interface SettingState {
excludeRegexes: string[];
/** 显示默认本地路径 */
showDefaultLocalPath: boolean;
/** 展示当前歌曲歌词状态信息 */
showPlayMeta: boolean;
}
export const useSettingStore = defineStore("setting", {
@@ -247,6 +249,7 @@ export const useSettingStore = defineStore("setting", {
proxyPort: 80,
useRealIP: false,
realIP: "",
showPlayMeta: false,
}),
getters: {
/**

View File

@@ -1,23 +1,10 @@
import { useStatusStore, useMusicStore, useSettingStore } from "@/stores";
import { songLyric, songLyricTTML } from "@/api/song";
import { type SongLyric } from "@/types/lyric";
import {
type LyricLine,
LyricWord,
parseLrc,
parseTTML,
parseYrc,
} from "@applemusic-like-lyrics/lyric";
import { type LyricLine, parseLrc, parseTTML, parseYrc } from "@applemusic-like-lyrics/lyric";
import { isElectron } from "./env";
import { isEmpty } from "lodash-es";
// TODO: 实现歌词统一管理类
// 先区分是在线还是本地
// 然后检查本地歌词覆盖
// 如果本地没有覆盖,进行在线请求
// 然后处理并格式化
// 然后根据配置的歌词排除内容来处理
// 然后写入 store
class LyricManager {
/**
* 在线歌词请求序列
@@ -112,6 +99,8 @@ class LyricManager {
const req = this.activeLyricReq;
// 最终结果
const result: SongLyric = { lrcData: [], yrcData: [] };
// 是否采用了 TTML
let ttmlAdopted = false;
// 过期判断
const isStale = () => this.activeLyricReq !== req || musicStore.playSong?.id !== id;
// 处理 TTML 歌词
@@ -125,6 +114,7 @@ class LyricManager {
const lines = parsed?.lines || [];
if (!lines.length) return;
result.yrcData = lines;
ttmlAdopted = true;
} catch {
/* empty */
}
@@ -166,11 +156,9 @@ class LyricManager {
/* empty */
}
};
// 统一判断与设置 TTML
// 设置 TTML
await Promise.allSettled([adoptTTML(), adoptLRC()]);
statusStore.usingTTMLLyric = Boolean(
settingStore.enableTTMLLyric && result.yrcData?.length && !result.lrcData?.length,
);
statusStore.usingTTMLLyric = ttmlAdopted;
return result;
}
/**
@@ -189,19 +177,7 @@ class LyricManager {
const ttml = parseTTML(lyric);
const lines = ttml?.lines || [];
statusStore.usingTTMLLyric = true;
// 构成普通歌词
const lrcLines: LyricLine[] = lines.map((line) => ({
...line,
words: [
{
word: line.words?.map((w) => w.word)?.join("") || "",
startTime: line.startTime || 0,
endTime: line.endTime || 0,
romanWord: line.words?.map((w) => w.romanWord)?.join("") || "",
},
] as LyricWord[],
}));
return { lrcData: lrcLines, yrcData: lines };
return { lrcData: [], yrcData: lines };
}
// 解析本地歌词并对其
const lrcLines = parseLrc(lyric);
@@ -270,6 +246,7 @@ class LyricManager {
* @returns 处理后的歌词数据
*/
private handleLyricExclude(lyricData: SongLyric): SongLyric {
const statusStore = useStatusStore();
const settingStore = useSettingStore();
const { enableExcludeLyrics, excludeKeywords, excludeRegexes } = settingStore;
// 未开启排除
@@ -300,7 +277,11 @@ class LyricManager {
const filterLines = (lines: LyricLine[]) => (lines || []).filter((l) => !isExcluded(l));
return {
lrcData: filterLines(lyricData.lrcData || []),
yrcData: filterLines(lyricData.yrcData || []),
yrcData:
// 若当前为 TTML 且开启排除
statusStore.usingTTMLLyric && settingStore.enableExcludeTTML
? filterLines(lyricData.yrcData || [])
: lyricData.yrcData || [],
};
}
/**
@@ -311,34 +292,62 @@ class LyricManager {
public async handleLyric(id: number, path?: string) {
const musicStore = useMusicStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
// 标记当前歌词请求(避免旧请求覆盖新请求)
const req = ++this.lyricReqSeq;
this.activeLyricReq = req;
try {
// 歌词加载状态
statusStore.lyricLoading = true;
// 标记当前歌词请求(避免旧请求覆盖新请求)
this.activeLyricReq = ++this.lyricReqSeq;
// 检查歌词覆盖
let lyricData = await this.checkLocalLyricOverride(id);
// 开始获取歌词
if (!isEmpty(lyricData.lrcData) || !isEmpty(lyricData.yrcData)) {
// 进行本地歌词对齐
lyricData = this.alignLocalLyrics(lyricData);
// 排除本地歌词内容
if (settingStore.enableExcludeLocalLyrics) {
lyricData = this.handleLyricExclude(lyricData);
}
} else if (path) {
lyricData = await this.handleLocalLyric(path);
// 排除本地歌词内容
if (settingStore.enableExcludeLocalLyrics) {
lyricData = this.handleLyricExclude(lyricData);
}
} else {
lyricData = await this.handleOnlineLyric(id);
// 排除内容
lyricData = this.handleLyricExclude(lyricData);
}
// 仅当请求未过期时才更新
if (this.activeLyricReq === req) {
// 如果只有逐字歌词
if (lyricData.lrcData.length === 0 && lyricData.yrcData.length > 0) {
// 构成普通歌词
lyricData.lrcData = lyricData.yrcData.map((line) => ({
...line,
words: [
{
word: line.words?.map((w) => w.word)?.join("") || "",
startTime: line.startTime || 0,
endTime: line.endTime || 0,
romanWord: line.words?.map((w) => w.romanWord)?.join("") || "",
},
],
}));
}
// 设置歌词
musicStore.setSongLyric(lyricData, true);
console.log("最终歌词数据", lyricData);
}
// 排除内容
lyricData = this.handleLyricExclude(lyricData);
// 设置歌词
musicStore.setSongLyric(lyricData, true);
console.log("最终歌词数据", lyricData);
} catch (error) {
console.error("❌ 处理歌词失败:", error);
// 重置歌词
this.resetSongLyric();
} finally {
// 歌词加载状态
if (musicStore.playSong?.id === undefined || this.activeLyricReq === this.lyricReqSeq) {
// 只有当这个请求是最新的时候,才关闭加载状态
if (req === this.activeLyricReq) {
statusStore.lyricLoading = false;
}
}