Skip to content

Commit a7a6108

Browse files
authored
Merge pull request #610 from imsyy/dev-player
✨ feat: 增加复制歌词功能
2 parents 50d03c1 + 179cfbb commit a7a6108

5 files changed

Lines changed: 220 additions & 3 deletions

File tree

components.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ declare module 'vue' {
1919
ChangeRate: typeof import('./src/components/Modal/ChangeRate.vue')['default']
2020
CloudMatch: typeof import('./src/components/Modal/CloudMatch.vue')['default']
2121
CommentList: typeof import('./src/components/List/CommentList.vue')['default']
22+
CopyLyrics: typeof import('./src/components/Modal/CopyLyrics.vue')['default']
2223
CountDown: typeof import('./src/components/Player/CountDown.vue')['default']
2324
CoverList: typeof import('./src/components/List/CoverList.vue')['default']
2425
CoverMenu: typeof import('./src/components/Menu/CoverMenu.vue')['default']
@@ -56,6 +57,7 @@ declare module 'vue' {
5657
NButton: typeof import('naive-ui')['NButton']
5758
NCard: typeof import('naive-ui')['NCard']
5859
NCheckbox: typeof import('naive-ui')['NCheckbox']
60+
NCheckboxGroup: typeof import('naive-ui')['NCheckboxGroup']
5961
NCollapse: typeof import('naive-ui')['NCollapse']
6062
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
6163
NCollapseTransition: typeof import('naive-ui')['NCollapseTransition']
@@ -113,6 +115,7 @@ declare module 'vue' {
113115
NSelect: typeof import('naive-ui')['NSelect']
114116
NSkeleton: typeof import('naive-ui')['NSkeleton']
115117
NSlider: typeof import('naive-ui')['NSlider']
118+
NSpace: typeof import('naive-ui')['NSpace']
116119
NSpin: typeof import('naive-ui')['NSpin']
117120
NSwitch: typeof import('naive-ui')['NSwitch']
118121
NTab: typeof import('naive-ui')['NTab']
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
<template>
2+
<div class="copy-lyrics">
3+
4+
<n-scrollbar class="lyrics-list">
5+
<n-checkbox-group v-model:value="selectedLines">
6+
<div v-for="line in displayLyrics" :key="line.index" class="lyric-item">
7+
<n-checkbox :value="line.index" class="lyric-checkbox">
8+
<div class="lyric-content">
9+
<div v-if="showOriginal && line.text" class="text">{{ line.text }}</div>
10+
<div v-if="showTranslation && line.translation" class="translation">
11+
{{ line.translation }}
12+
</div>
13+
<div v-if="showRomaji && line.romaji" class="romaji">{{ line.romaji }}</div>
14+
</div>
15+
</n-checkbox>
16+
</div>
17+
</n-checkbox-group>
18+
</n-scrollbar>
19+
20+
<div class="footer">
21+
<div class="filters">
22+
<n-checkbox-group v-model:value="selectedFilters">
23+
<n-space>
24+
<n-checkbox value="original" label="原词" />
25+
<n-checkbox value="translation" label="翻译" />
26+
<n-checkbox value="romaji" label="音译" />
27+
</n-space>
28+
</n-checkbox-group>
29+
</div>
30+
<div class="actions">
31+
<n-button class="action-btn" @click="selectAll">全选</n-button>
32+
<n-button
33+
class="action-btn"
34+
type="primary"
35+
:disabled="selectedLines.length === 0"
36+
@click="handleCopy"
37+
>
38+
复制 ({{ selectedLines.length }})
39+
</n-button>
40+
</div>
41+
</div>
42+
</div>
43+
</template>
44+
45+
<script setup lang="ts">
46+
import { useMusicStore } from "@/stores";
47+
import { useClipboard } from "@vueuse/core";
48+
49+
const props = defineProps<{
50+
onClose: () => void;
51+
}>();
52+
53+
const musicStore = useMusicStore();
54+
const { copy } = useClipboard();
55+
56+
const selectedFilters = ref<string[]>(["original", "translation", "romaji"]);
57+
const selectedLines = ref<number[]>([]);
58+
59+
const rawLyrics = computed(() => {
60+
const { songLyric } = musicStore;
61+
return songLyric.yrcData?.length ? songLyric.yrcData : songLyric.lrcData;
62+
});
63+
64+
const displayLyrics = computed(() => {
65+
return rawLyrics.value.map((line, index) => {
66+
// 兼容 lrcData (content) 和 yrcData (words)
67+
const text =
68+
line.words?.map((w) => w.word).join("") || (line as any).content || (line as any).text || "";
69+
const translation = line.translatedLyric || "";
70+
const romaji = line.romanLyric || line.words?.map((w) => w.romanWord).join("") || "";
71+
return {
72+
index,
73+
text,
74+
translation,
75+
romaji,
76+
};
77+
});
78+
});
79+
80+
const showOriginal = computed(() => selectedFilters.value.includes("original"));
81+
const showTranslation = computed(() => selectedFilters.value.includes("translation"));
82+
const showRomaji = computed(() => selectedFilters.value.includes("romaji"));
83+
84+
const selectAll = () => {
85+
if (selectedLines.value.length === displayLyrics.value.length) {
86+
selectedLines.value = [];
87+
} else {
88+
selectedLines.value = displayLyrics.value.map((l) => l.index);
89+
}
90+
};
91+
92+
const handleCopy = async () => {
93+
const linesToCopy = displayLyrics.value
94+
.filter((l) => selectedLines.value.includes(l.index))
95+
.map((l) => {
96+
const parts: string[] = [];
97+
if (showOriginal.value && l.text) parts.push(l.text);
98+
if (showTranslation.value && l.translation) parts.push(l.translation);
99+
if (showRomaji.value && l.romaji) parts.push(l.romaji);
100+
return parts.join("\n");
101+
})
102+
.filter((s) => s)
103+
.join("\n\n");
104+
105+
if (linesToCopy) {
106+
await copy(linesToCopy);
107+
window.$message.success("复制成功");
108+
props.onClose();
109+
} else {
110+
window.$message.warning("没有可复制的内容");
111+
}
112+
};
113+
</script>
114+
115+
<style lang="scss" scoped>
116+
.copy-lyrics {
117+
display: flex;
118+
flex-direction: column;
119+
height: 60vh;
120+
width: 100%;
121+
}
122+
123+
124+
.lyrics-list {
125+
flex: 1;
126+
padding: 12px 20px;
127+
128+
.lyric-item {
129+
margin-bottom: 12px;
130+
padding: 8px;
131+
border-radius: 8px;
132+
transition: background-color 0.2s;
133+
134+
&:hover {
135+
background-color: rgba(0, 0, 0, 0.05);
136+
}
137+
138+
.lyric-checkbox {
139+
width: 100%;
140+
align-items: flex-start;
141+
142+
:deep(.n-checkbox__label) {
143+
flex: 1;
144+
}
145+
}
146+
147+
.lyric-content {
148+
font-size: 14px;
149+
line-height: 1.6;
150+
151+
.text {
152+
font-weight: 500;
153+
}
154+
.translation {
155+
color: var(--n-text-color-3);
156+
font-size: 13px;
157+
}
158+
.romaji {
159+
color: var(--n-text-color-3);
160+
font-size: 12px;
161+
font-style: italic;
162+
}
163+
}
164+
}
165+
}
166+
167+
.footer {
168+
padding: 16px 20px;
169+
display: flex;
170+
justify-content: space-between;
171+
align-items: center;
172+
border-top: 1px solid var(--n-border-color);
173+
174+
.filters {
175+
display: flex;
176+
align-items: center;
177+
}
178+
179+
.actions {
180+
display: flex;
181+
gap: 12px;
182+
.action-btn {
183+
width: 90px;
184+
}
185+
}
186+
}
187+
</style>

src/components/Player/LyricMenu.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@
1313
<div class="menu-icon" @click="openSetting('lyrics')">
1414
<SvgIcon name="Settings" />
1515
</div>
16+
<div class="menu-icon" @click="openCopyLyrics">
17+
<SvgIcon name="Copy" />
18+
</div>
1619
</n-flex>
1720
</template>
1821

1922
<script setup lang="ts">
2023
import { useMusicStore, useStatusStore } from "@/stores";
21-
import { openSetting } from "@/utils/modal";
24+
import { openSetting, openCopyLyrics } from "@/utils/modal";
2225
2326
const musicStore = useMusicStore();
2427
const statusStore = useStatusStore();

src/utils/downloadManager.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class DownloadManager {
1717
private queue: DownloadTask[] = [];
1818
private isProcessing: boolean = false;
1919

20-
private constructor() {}
20+
private constructor() { }
2121

2222
public static getInstance(): DownloadManager {
2323
if (!DownloadManager.instance) {
@@ -256,7 +256,14 @@ class DownloadManager {
256256
let lyric = "";
257257
if (downloadLyric) {
258258
const lyricResult = await songLyric(song.id);
259-
lyric = lyricResult?.lrc?.lyric || "";
259+
const rawLyric = lyricResult?.lrc?.lyric || "";
260+
// 排除特定格式的脏数据
261+
const excludeRegex =
262+
/^\{"t":\d+,"c":\[\{"[^"]+":\"[^"]*\"}(?:,\{"[^"]+":\"[^"]*\"})*]}$/;
263+
lyric = rawLyric
264+
.split("\n")
265+
.filter((line) => !excludeRegex.test(line.trim()))
266+
.join("\n");
260267
}
261268

262269
const config = {

src/utils/modal.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import Equalizer from "@/components/Modal/Equalizer.vue";
2424
import SongUnlockManager from "@/components/Modal/SongUnlockManager.vue";
2525
import SidebarHideManager from "@/components/Modal/SidebarHideManager.vue";
2626
import HomePageSectionManager from "@/components/Modal/HomePageSectionManager.vue";
27+
import CopyLyrics from "@/components/Modal/CopyLyrics.vue";
2728

2829
// 用户协议
2930
export const openUserAgreement = () => {
@@ -383,3 +384,19 @@ export const openHomePageSectionManager = () => {
383384
},
384385
});
385386
};
387+
388+
/** 打开复制歌词弹窗 */
389+
export const openCopyLyrics = () => {
390+
const modal = window.$modal.create({
391+
preset: "card",
392+
transformOrigin: "center",
393+
autoFocus: false,
394+
style: { width: "500px" },
395+
title: "复制歌词",
396+
content: () => {
397+
return h(CopyLyrics, {
398+
onClose: () => modal.destroy(),
399+
});
400+
},
401+
});
402+
};

0 commit comments

Comments
 (0)