mirror of
https://github.com/imsyy/SPlayer.git
synced 2025-11-25 03:14:57 +08:00
Compare commits
16 Commits
dev-lyric
...
6caf99da09
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6caf99da09 | ||
|
|
ad27d1eaea | ||
|
|
8846d7f669 | ||
|
|
807b72ed9e | ||
|
|
d89be488e2 | ||
|
|
4bf986b763 | ||
|
|
ffb1fcc1ea | ||
|
|
ea822f91e8 | ||
|
|
5e260ffc0d | ||
|
|
e3dcde71e8 | ||
|
|
7de1355f18 | ||
|
|
0d66ced637 | ||
|
|
ae1ee71e75 | ||
|
|
84d9c999eb | ||
|
|
1a36fbf1d5 | ||
|
|
e0e62cd906 |
56
.github/ISSUE_TEMPLATE/bug.yml
vendored
56
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -1,35 +1,75 @@
|
||||
name: 遇到问题
|
||||
description: 关于使用过程中遇到的问题
|
||||
title: 请填写标题
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: input
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
---
|
||||
在此之前,我们默认你已经知道该如何提问。简而言之,**你要精确地描述问题并提供充足的信息**
|
||||
|
||||
此外,如果需要,你可以使用 <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>I</kbd> 打开开发者工具为我们提供信息
|
||||
|
||||
- type: checkboxes
|
||||
validations:
|
||||
required: false
|
||||
attributes:
|
||||
label: "检查清单"
|
||||
description: |
|
||||
我们需要了解一些信息,你需要检查下面的检查项 <br />
|
||||
**这里并不是每一个检查项都必要,根据你的真实情况勾选即可**
|
||||
options:
|
||||
- label: "我已检索仓库中所有的 Issues,确保我**没有重复提交问题**;或有相似 Issue,但我觉得我的情况不包含在那个相似 Issue 之内"
|
||||
- label: "我已经找到了可以复现这个问题的方法,并且写在了下面的「具体信息」中"
|
||||
- label: "此问题可以在我的设备和当前环境中**稳定复现**"
|
||||
- label: "此问题可以在最新版本 (Latest Release) 中复现"
|
||||
- label: "此问题是在我更新到当前版本后**才**出现的"
|
||||
|
||||
- type: dropdown
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "是网页端还是客户端"
|
||||
placeholder: "客户端"
|
||||
options:
|
||||
- "客户端"
|
||||
- "网页端"
|
||||
default: 0
|
||||
|
||||
- type: input
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "当前系统环境"
|
||||
placeholder: "win11"
|
||||
placeholder: "如:Windows 11"
|
||||
|
||||
- type: input
|
||||
validations:
|
||||
required: true
|
||||
required: false
|
||||
attributes:
|
||||
label: "当前 Node.js 及 npm 版本"
|
||||
placeholder: "v18.16.0 / v9.6.7"
|
||||
placeholder: "如:v18.16.0 / v9.6.7 (选填)"
|
||||
|
||||
- type: input
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "当前版本"
|
||||
placeholder: "v1.0.0"
|
||||
description: |
|
||||
填写关于软件里的或 Releases 中版本号即可 <br />
|
||||
如果是自行构建或从 GitHub Actions 下载的开发版,还需要提供 Commit ID
|
||||
placeholder: "如:v1.0.0"
|
||||
|
||||
- type: textarea
|
||||
id: other
|
||||
attributes:
|
||||
label: "具体信息"
|
||||
description: "请填写完整的复现步骤和遇到的问题,包括但不限于报错信息、控制台输出、网络请求等"
|
||||
description: |
|
||||
请填写完整的复现步骤和遇到的问题,还请尽量提供所有可能的信息,提供包括但不限于:
|
||||
|
||||
- 分步的复现步骤,可以使用 `1. xxx` (换行) `2. xxx` 的格式
|
||||
- 截图(如果结合复现步骤还是无法详细表述,甚至可以录屏)
|
||||
- 开发者工具中的:控制台输出报错、网络请求等
|
||||
- 出现问题的在线歌曲链接、出现问题的文件(本地歌曲、歌词等)的下载链接
|
||||
|
||||
等信息,以更好地帮助我们解决你的问题
|
||||
placeholder: "请填写具体的复现步骤和遇到的问题"
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
|
||||
// 遍历每一个目录
|
||||
|
||||
@@ -217,6 +217,9 @@ const changeGlobalTheme = () => {
|
||||
railColor: toRGBA(primaryRGB, 0.2),
|
||||
railColorHover: toRGBA(primaryRGB, 0.3),
|
||||
},
|
||||
Popover: {
|
||||
color: `rgb(${surfaceContainerRGB})`,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -234,10 +234,13 @@ const listData = computed<SongType[]>(() => {
|
||||
const listKey = computed(() => {
|
||||
// 每日推荐
|
||||
if (props.isDailyRecommend) {
|
||||
return musicStore.dailySongsData.timestamp || 0;
|
||||
return `daily-${musicStore.dailySongsData.timestamp || 0}`;
|
||||
}
|
||||
// 其他列表长度(检测增删操作)
|
||||
return listData.value?.length || 0;
|
||||
// 使用 playListId 作为主要 key
|
||||
if (props.playListId) {
|
||||
return `playlist-${props.playListId}`;
|
||||
}
|
||||
return `type-${props.type}`;
|
||||
});
|
||||
|
||||
// 列表是否具有播放歌曲
|
||||
@@ -261,7 +264,9 @@ const sortMenuOptions = computed<DropdownOption[]>(() =>
|
||||
// 列表滚动
|
||||
const onScroll = (e: Event) => {
|
||||
emit("scroll", e);
|
||||
scrollTop.value = (e.target as HTMLElement).scrollTop;
|
||||
const top = (e.target as HTMLElement).scrollTop;
|
||||
scrollTop.value = top;
|
||||
offset.value = top;
|
||||
};
|
||||
|
||||
// 列表触底
|
||||
|
||||
@@ -30,21 +30,14 @@
|
||||
:class="[
|
||||
'player-content',
|
||||
{
|
||||
'no-lrc': noLrc,
|
||||
pure: statusStore.pureLyricMode && musicStore.isHasLrc,
|
||||
'no-lrc': !musicStore.isHasLrc || (!musicStore.isHasLrc && !statusStore.lyricLoading),
|
||||
},
|
||||
]"
|
||||
@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" />
|
||||
@@ -96,6 +90,20 @@ const isShowComment = computed<boolean>(
|
||||
() => !musicStore.playSong.path && statusStore.showPlayerComment,
|
||||
);
|
||||
|
||||
/** 没有歌词 */
|
||||
const noLrc = computed<boolean>(() => {
|
||||
const noNormalLrc = !musicStore.isHasLrc;
|
||||
const noYrcAvailable = !musicStore.isHasYrc || !settingStore.showYrc;
|
||||
// const notLoading = !statusStore.lyricLoading;
|
||||
|
||||
return noNormalLrc && noYrcAvailable;
|
||||
});
|
||||
|
||||
/** 是否处于纯净模式 */
|
||||
const pureLyricMode = computed<boolean>(
|
||||
() => (statusStore.pureLyricMode && musicStore.isHasLrc) || musicStore.playSong.type === "radio",
|
||||
);
|
||||
|
||||
// 主内容 key
|
||||
const playerContentKey = computed(() => `${statusStore.pureLyricMode}`);
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ const lyricsScroll = (index: number) => {
|
||||
const container = lrcItemDom.parentElement;
|
||||
if (!container) return;
|
||||
// 调整滚动的距离
|
||||
const scrollDistance = lrcItemDom.offsetTop - container.offsetTop - 80;
|
||||
const scrollDistance = lrcItemDom.offsetTop - container.offsetTop - 100;
|
||||
// 开始滚动
|
||||
if (settingStore.lyricsScrollPosition === "center") {
|
||||
lrcItemDom?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
@@ -399,8 +399,8 @@ onBeforeUnmount(() => {
|
||||
top: 0;
|
||||
transform: none;
|
||||
will-change: -webkit-mask-position-x, transform, opacity;
|
||||
// padding: 2px 8px;
|
||||
// margin: -2px -8px;
|
||||
padding: 0.3em 0;
|
||||
margin: -0.3em 0;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
rgb(0, 0, 0) 45.4545454545%,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="setting">
|
||||
<div class="set-left">
|
||||
<div class="title">
|
||||
<n-flex class="title" :size="0" vertical>
|
||||
<n-h1>设置</n-h1>
|
||||
<n-text :depth="3">个性化与全局设置</n-text>
|
||||
</div>
|
||||
</n-flex>
|
||||
<!-- 设置菜单 -->
|
||||
<n-menu
|
||||
v-model:value="activeKey"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ class Player {
|
||||
}
|
||||
if (!this.player.playing()) return;
|
||||
const currentTime = this.getSeek();
|
||||
const duration = Math.floor(this.player.duration() * 1000);
|
||||
const duration = this.getDuration();
|
||||
// 计算进度条距离
|
||||
const progress = calculateProgress(currentTime, duration);
|
||||
// 计算歌词索引(支持 LRC 与逐字 YRC,对唱重叠处理)
|
||||
@@ -298,7 +298,7 @@ class Player {
|
||||
}
|
||||
// 恢复进度(仅在明确指定且大于0时才恢复,避免切换歌曲时意外恢复进度)
|
||||
if (seek && seek > 0) {
|
||||
const duration = Math.floor(this.player.duration() * 1000);
|
||||
const duration = this.getDuration();
|
||||
// 确保恢复的进度有效且距离歌曲结束大于2秒
|
||||
if (duration && seek < duration - 2000) {
|
||||
this.setSeek(seek);
|
||||
@@ -507,6 +507,7 @@ class Player {
|
||||
}
|
||||
// 超过次数:切到下一首或清空
|
||||
this.retryInfo.count = 0;
|
||||
this.switching = false;
|
||||
if (dataStore.playList.length > 1) {
|
||||
window.$message.error("当前歌曲播放失败,已跳至下一首");
|
||||
await this.nextOrPrev("next");
|
||||
@@ -914,6 +915,10 @@ class Player {
|
||||
console.warn("⚠️ Player not ready for seek");
|
||||
return;
|
||||
}
|
||||
if (time < 0 || time > this.getDuration()) {
|
||||
console.warn("⚠️ Invalid seek time", time);
|
||||
time = Math.max(0, Math.min(time, this.getDuration()));
|
||||
}
|
||||
this.player.seek(time / 1000);
|
||||
statusStore.currentTime = time;
|
||||
}
|
||||
@@ -926,6 +931,13 @@ class Player {
|
||||
if (!this.player || this.player.state() !== "loaded") return 0;
|
||||
return Math.floor(this.player.seek() * 1000);
|
||||
}
|
||||
/**
|
||||
* 获取播放时长
|
||||
* @returns 播放时长(单位:毫秒)
|
||||
*/
|
||||
getDuration(): number {
|
||||
return Math.floor(this.player.duration() * 1000);
|
||||
}
|
||||
/**
|
||||
* 设置播放速率
|
||||
* @param rate 播放速率
|
||||
|
||||
@@ -89,12 +89,16 @@
|
||||
'end-with-space': text.word.endsWith(' ') || text.startTime === 0,
|
||||
}"
|
||||
>
|
||||
<span class="word" :style="{ color: lyricConfig.unplayedColor }">
|
||||
{{ text.word }}
|
||||
</span>
|
||||
<span
|
||||
class="filler"
|
||||
:style="[{ color: lyricConfig.playedColor }, getYrcStyle(text, line.index)]"
|
||||
class="word"
|
||||
:style="[
|
||||
{
|
||||
backgroundImage: `linear-gradient(to right, ${lyricConfig.playedColor} 50%, ${lyricConfig.unplayedColor} 50%)`,
|
||||
textShadow: 'none',
|
||||
filter: `drop-shadow(0 0 1px ${lyricConfig.shadowColor}) drop-shadow(0 0 2px ${lyricConfig.shadowColor})`,
|
||||
},
|
||||
getYrcStyle(text, line.index),
|
||||
]"
|
||||
>
|
||||
{{ text.word }}
|
||||
</span>
|
||||
@@ -121,7 +125,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRafFn } from "@vueuse/core";
|
||||
import { useRafFn, useTimeoutFn, useThrottleFn } from "@vueuse/core";
|
||||
import { LyricLine, LyricWord } from "@applemusic-like-lyrics/lyric";
|
||||
import { LyricConfig, LyricData, RenderLine } from "@/types/desktop-lyric";
|
||||
import defaultDesktopLyricConfig from "@/assets/data/lyricConfig";
|
||||
@@ -164,7 +168,14 @@ const desktopLyricRef = ref<HTMLElement>();
|
||||
|
||||
// hover 状态控制
|
||||
const isHovered = ref<boolean>(false);
|
||||
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const { start: startHoverTimer } = useTimeoutFn(
|
||||
() => {
|
||||
isHovered.value = false;
|
||||
},
|
||||
1000,
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* 处理鼠标移动,更新 hover 状态
|
||||
@@ -172,16 +183,7 @@ let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const handleMouseMove = () => {
|
||||
// 设置 hover 状态(锁定和非锁定状态都响应)
|
||||
isHovered.value = true;
|
||||
// 清除之前的定时器
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer);
|
||||
hoverTimer = null;
|
||||
}
|
||||
// 设置新的定时器,延迟后移除 hover 状态
|
||||
hoverTimer = setTimeout(() => {
|
||||
isHovered.value = false;
|
||||
hoverTimer = null;
|
||||
}, 1000);
|
||||
startHoverTimer();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -334,7 +336,7 @@ const renderLyricLines = computed<RenderLine[]>(() => {
|
||||
*/
|
||||
const getYrcStyle = (wordData: LyricWord, lyricIndex: number) => {
|
||||
const currentLine = lyricData.yrcData?.[lyricIndex];
|
||||
if (!currentLine) return { WebkitMaskPositionX: "100%" };
|
||||
if (!currentLine) return { backgroundPositionX: "100%" };
|
||||
const seekSec = playSeekMs.value;
|
||||
const startSec = currentLine.startTime || 0;
|
||||
const endSec = currentLine.endTime || 0;
|
||||
@@ -343,14 +345,12 @@ const getYrcStyle = (wordData: LyricWord, lyricIndex: number) => {
|
||||
|
||||
if (!isLineActive) {
|
||||
const hasPlayed = seekSec >= (wordData.endTime || 0);
|
||||
return { WebkitMaskPositionX: hasPlayed ? "0%" : "100%" };
|
||||
return { backgroundPositionX: hasPlayed ? "0%" : "100%" };
|
||||
}
|
||||
const durationSec = Math.max((wordData.endTime || 0) - (wordData.startTime || 0), 0.001);
|
||||
const progress = Math.max(Math.min((seekSec - (wordData.startTime || 0)) / durationSec, 1), 0);
|
||||
return {
|
||||
transitionDuration: `0s, 0s, 0.35s`,
|
||||
transitionDelay: `0ms`,
|
||||
WebkitMaskPositionX: `${100 - progress * 100}%`,
|
||||
backgroundPositionX: `${100 - progress * 100}%`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -409,6 +409,11 @@ const dragState = reactive({
|
||||
startWinY: 0,
|
||||
winWidth: 0,
|
||||
winHeight: 0,
|
||||
// 缓存屏幕边界
|
||||
minX: -99999,
|
||||
minY: -99999,
|
||||
maxX: 99999,
|
||||
maxY: 99999,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -436,6 +441,14 @@ const startDrag = async (event: MouseEvent) => {
|
||||
const { width, height } = await window.api.store.get("lyric");
|
||||
const safeWidth = Number(width) > 0 ? Number(width) : 800;
|
||||
const safeHeight = Number(height) > 0 ? Number(height) : 136;
|
||||
// 如果开启了限制边界,在拖拽开始时预先获取一次屏幕范围
|
||||
if (lyricConfig.limitBounds) {
|
||||
const bounds = await window.electron.ipcRenderer.invoke("get-virtual-screen-bounds");
|
||||
dragState.minX = bounds.minX ?? -99999;
|
||||
dragState.minY = bounds.minY ?? -99999;
|
||||
dragState.maxX = bounds.maxX ?? 99999;
|
||||
dragState.maxY = bounds.maxY ?? 99999;
|
||||
}
|
||||
window.electron.ipcRenderer.send("toggle-fixed-max-size", {
|
||||
width: safeWidth,
|
||||
height: safeHeight,
|
||||
@@ -456,19 +469,20 @@ const startDrag = async (event: MouseEvent) => {
|
||||
* 桌面歌词拖动移动
|
||||
* @param event 鼠标事件
|
||||
*/
|
||||
const onDocMouseMove = async (event: MouseEvent) => {
|
||||
const onDocMouseMove = useThrottleFn((event: MouseEvent) => {
|
||||
if (!dragState.isDragging || lyricConfig.isLock) return;
|
||||
const screenX = event?.screenX ?? 0;
|
||||
const screenY = event?.screenY ?? 0;
|
||||
let newWinX = Math.round(dragState.startWinX + (screenX - dragState.startX));
|
||||
let newWinY = Math.round(dragState.startWinY + (screenY - dragState.startY));
|
||||
// 是否限制在屏幕边界(支持多屏)
|
||||
// 是否限制在屏幕边界(支持多屏)- 使用缓存的边界数据同步计算
|
||||
if (lyricConfig.limitBounds) {
|
||||
const { minX, minY, maxX, maxY } = await window.electron.ipcRenderer.invoke(
|
||||
"get-virtual-screen-bounds",
|
||||
newWinX = Math.round(
|
||||
Math.max(dragState.minX, Math.min(dragState.maxX - dragState.winWidth, newWinX)),
|
||||
);
|
||||
newWinY = Math.round(
|
||||
Math.max(dragState.minY, Math.min(dragState.maxY - dragState.winHeight, newWinY)),
|
||||
);
|
||||
newWinX = Math.round(Math.max(minX as number, Math.min(maxX - dragState.winWidth, newWinX)));
|
||||
newWinY = Math.round(Math.max(minY as number, Math.min(maxY - dragState.winHeight, newWinY)));
|
||||
}
|
||||
window.electron.ipcRenderer.send(
|
||||
"move-window",
|
||||
@@ -477,7 +491,7 @@ const onDocMouseMove = async (event: MouseEvent) => {
|
||||
dragState.winWidth,
|
||||
dragState.winHeight,
|
||||
);
|
||||
};
|
||||
}, 16);
|
||||
|
||||
/**
|
||||
* 桌面歌词拖动结束
|
||||
@@ -650,11 +664,6 @@ onBeforeUnmount(() => {
|
||||
// 解绑事件
|
||||
document.removeEventListener("mousedown", onDocMouseDown);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
// 清理定时器
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer);
|
||||
hoverTimer = null;
|
||||
}
|
||||
if (dragState.isDragging) onDocMouseUp();
|
||||
});
|
||||
</script>
|
||||
@@ -739,6 +748,7 @@ onBeforeUnmount(() => {
|
||||
.lyric-line {
|
||||
width: 100%;
|
||||
line-height: normal;
|
||||
padding: 4px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -761,34 +771,14 @@ onBeforeUnmount(() => {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
.word {
|
||||
opacity: 1;
|
||||
display: inline-block;
|
||||
}
|
||||
.filler {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
will-change: -webkit-mask-position-x, transform, opacity;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
rgb(0, 0, 0) 45.4545454545%,
|
||||
rgba(0, 0, 0, 0) 54.5454545455%
|
||||
);
|
||||
mask-size: 220% 100%;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
rgb(0, 0, 0) 45.4545454545%,
|
||||
rgba(0, 0, 0, 0) 54.5454545455%
|
||||
);
|
||||
-webkit-mask-size: 220% 100%;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
transition:
|
||||
opacity 0.3s,
|
||||
filter 0.3s,
|
||||
margin 0.3s,
|
||||
padding 0.3s !important;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
background-size: 200% 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-position-x: 100%;
|
||||
will-change: background-position-x;
|
||||
}
|
||||
&.end-with-space {
|
||||
margin-right: 5vh;
|
||||
@@ -797,16 +787,6 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
&.active {
|
||||
.content-text {
|
||||
.filler {
|
||||
opacity: 1;
|
||||
-webkit-mask-position-x: 0%;
|
||||
transition-property: -webkit-mask-position-x, transform, opacity;
|
||||
transition-timing-function: linear, ease, ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&.center {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div :key="searchKeyword" class="search">
|
||||
<div class="search">
|
||||
<div class="title">
|
||||
<n-text class="keyword">{{ searchKeyword }}</n-text>
|
||||
<n-text depth="3">的相关搜索</n-text>
|
||||
@@ -17,9 +17,20 @@
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition :name="`router-${settingStore.routeAnimation}`" mode="out-in">
|
||||
<KeepAlive v-if="settingStore.useKeepAlive">
|
||||
<component :is="Component" :keyword="searchKeyword" class="router-view" />
|
||||
<component
|
||||
:is="Component"
|
||||
:key="route.fullPath"
|
||||
:keyword="searchKeyword"
|
||||
class="router-view"
|
||||
/>
|
||||
</KeepAlive>
|
||||
<component v-else :is="Component" :keyword="searchKeyword" class="router-view" />
|
||||
<component
|
||||
v-else
|
||||
:is="Component"
|
||||
:key="route.fullPath"
|
||||
:keyword="searchKeyword"
|
||||
class="router-view"
|
||||
/>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</div>
|
||||
@@ -28,14 +39,15 @@
|
||||
<script setup lang="ts">
|
||||
import { useSettingStore } from "@/stores";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const settingStore = useSettingStore();
|
||||
|
||||
// 搜索关键词
|
||||
const searchKeyword = computed(() => router.currentRoute.value.query.keyword as string);
|
||||
const searchKeyword = computed(() => route.query.keyword as string);
|
||||
|
||||
// 搜索分类
|
||||
const searchType = ref<string>((router.currentRoute.value?.name as string) || "search-songs");
|
||||
const searchType = ref<string>("search-songs");
|
||||
|
||||
// Tabs 改变
|
||||
const tabChange = (value: string) => {
|
||||
@@ -47,10 +59,16 @@ const tabChange = (value: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
onBeforeRouteUpdate((to) => {
|
||||
if (to.matched[0].name !== "search") return;
|
||||
searchType.value = to.name as string;
|
||||
});
|
||||
// 监听路由变化,同步 Tab 状态
|
||||
watch(
|
||||
() => route.name,
|
||||
(name) => {
|
||||
if (name && name.toString().startsWith("search-")) {
|
||||
searchType.value = name as string;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
Reference in New Issue
Block a user