仰望星辰工作室

starmusic-pc

starmusic-pc/ frontend/src/components/LrcView.vue 7.8 KB · 306 行 原始文件
1<script setup>
2/**
3 * LrcView — 歌词滚动视图
4 *
5 * 相比移动端 scroll-view + createSelectorQuery 的实现,桌面端用原生 offsetTop
6 * 计算目标滚动位置,更精确也没有平台差异:
7 * - 上下各留半屏留白,使首尾行也能居中
8 * - 当前行放大 + 主题色;近邻行稍亮、远端行更淡,形成层次
9 * - 支持翻译行(tlyric)与纯文本歌词
10 * - 点击任意行即跳转到该时间点
11 */
12import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
13import Icon from './Icon.vue'
14
15const props = defineProps({
16 lyric: { type: String, default: '' },
17 tlyric: { type: String, default: '' },
18 currentTime: { type: Number, default: 0 },
19 /** 歌词尚未取到时的加载态 */
20 loading: { type: Boolean, default: false },
21 /** 允许点击跳转 */
22 seekable: { type: Boolean, default: true },
23})
24
25const emit = defineEmits(['seek'])
26
27const scrollRef = ref(null)
28const lineRefs = ref([])
29const activeIndex = ref(-1)
30const userScrolling = ref(false)
31let resumeTimer = null
32
33/** 解析单行时间标签 [mm:ss.xx] */
34const parseTime = (tag) => {
35 const m = /^(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?$/.exec(tag)
36 if (!m) return 0
37 const min = parseInt(m[1], 10)
38 const sec = parseInt(m[2], 10)
39 let frac = 0
40 if (m[3]) frac = m[3].length === 3 ? parseInt(m[3], 10) / 1000 : parseInt(m[3], 10) / 100
41 return min * 60 + sec + frac
42}
43
44/** 解析 LRC 文本 → [{ time, text }] */
45const parseLrc = (text) => {
46 if (!text) return []
47 const out = []
48 const rows = String(text).split(/\r\n|\r|\n/)
49 const tagRe = /\[(\d{1,3}:\d{1,2}(?:[.:]\d{1,3})?)\]/g
50 for (const row of rows) {
51 tagRe.lastIndex = 0
52 const tags = []
53 let m
54 while ((m = tagRe.exec(row)) !== null) tags.push(m[1])
55 if (!tags.length) {
56 // 无时间标签(纯文本歌词):按顺序给一个递增时间,保证可展示
57 const plain = row.trim()
58 if (plain && !/^(\[|【).*(\]|】)$/.test(plain)) {
59 out.push({ time: out.length ? out[out.length - 1].time + 6 : 0, text: plain, noTime: true })
60 }
61 continue
62 }
63 const text = row.replace(tagRe, '').trim()
64 for (const tag of tags) out.push({ time: parseTime(tag), text })
65 }
66 return out
67}
68
69const lines = computed(() => {
70 const main = parseLrc(props.lyric)
71 if (!main.length) return []
72 const trans = parseLrc(props.tlyric)
73 const transMap = new Map()
74 for (const t of trans) if (t.text) transMap.set(Math.round(t.time * 10) / 10, t.text)
75
76 // 去掉纯空白行(保留有翻译的)
77 const filtered = main.filter((l) => l.text || transMap.has(Math.round(l.time * 10) / 10))
78 return filtered.map((l) => ({
79 ...l,
80 trans: transMap.get(Math.round(l.time * 10) / 10) || '',
81 }))
82})
83
84const hasLyric = computed(() => lines.value.length > 0)
85
86/** 根据当前时间定位歌词行(0.25s 提前量,观感更跟手) */
87const updateActive = () => {
88 const list = lines.value
89 if (!list.length) {
90 if (activeIndex.value !== -1) activeIndex.value = -1
91 return
92 }
93 const t = props.currentTime + 0.25
94 let index = -1
95 for (let i = 0; i < list.length; i++) {
96 if (list[i].time <= t) index = i
97 else break
98 }
99 if (index !== activeIndex.value) {
100 activeIndex.value = index
101 nextTick(() => scrollToActive(false))
102 }
103}
104
105/** 滚动到当前行并居中 */
106const scrollToActive = (instant = false) => {
107 const container = scrollRef.value
108 const el = lineRefs.value[activeIndex.value]
109 if (!container || !el) return
110 const target = el.offsetTop - (container.clientHeight - el.clientHeight) / 2
111 container.scrollTo({ top: Math.max(0, target), behavior: instant ? 'auto' : 'smooth' })
112}
113
114watch(() => props.currentTime, updateActive)
115watch(
116 () => [props.lyric, props.tlyric],
117 () => {
118 activeIndex.value = -1
119 nextTick(() => {
120 updateActive()
121 scrollToActive(true)
122 })
123 },
124)
125watch(activeIndex, () => {
126 if (userScrolling.value) return
127 nextTick(() => scrollToActive(false))
128})
129
130/** 用户主动滚动后短暂暂停自动跟随,避免「抢滚动条」 */
131const onWheel = () => {
132 userScrolling.value = true
133 if (resumeTimer) clearTimeout(resumeTimer)
134 resumeTimer = setTimeout(() => {
135 userScrolling.value = false
136 scrollToActive(false)
137 }, 3200)
138}
139
140const setLineRef = (el, index) => {
141 if (el) lineRefs.value[index] = el
142}
143
144const lineClass = (index) => {
145 const active = index === activeIndex.value
146 const distance = Math.abs(index - activeIndex.value)
147 return {
148 active,
149 near: !active && distance <= 2,
150 far: distance > 5,
151 }
152}
153
154const onLineClick = (line) => {
155 if (!props.seekable || line.noTime) return
156 emit('seek', line.time)
157}
158
159onMounted(() => nextTick(() => scrollToActive(true)))
160onUnmounted(() => {
161 if (resumeTimer) clearTimeout(resumeTimer)
162})
163</script>
164
165<template>
166 <div class="lrc">
167 <div v-if="hasLyric" ref="scrollRef" class="lrc-scroll scroll-y" @wheel="onWheel">
168 <div class="lrc-pad" />
169 <p
170 v-for="(line, index) in lines"
171 :key="`${index}-${line.time}`"
172 :ref="(el) => setLineRef(el, index)"
173 class="lrc-line"
174 :class="lineClass(index)"
175 @click="onLineClick(line)"
176 >
177 <span class="lrc-text">{{ line.text || '♪' }}</span>
178 <span v-if="line.trans" class="lrc-trans">{{ line.trans }}</span>
179 </p>
180 <div class="lrc-pad" />
181 </div>
182
183 <div v-else class="lrc-empty">
184 <span class="lrc-empty-icon" :class="{ 'is-loading': loading }">
185 <Icon :name="loading ? 'refresh' : 'music'" :size="34" :width="1.5" :spin="loading" />
186 </span>
187 <p class="lrc-empty-text">{{ loading ? '歌词加载中…' : '暂无歌词' }}</p>
188 <p v-if="!loading" class="lrc-empty-hint">换首歌试试,或在播放页手动刷新歌词</p>
189 </div>
190 </div>
191</template>
192
193<style scoped>
194.lrc {
195 position: relative;
196 height: 100%;
197 min-height: 0;
198 overflow: hidden;
199}
200
201.lrc-scroll {
202 height: 100%;
203 overflow-y: auto;
204 overflow-x: hidden;
205 mask-image: linear-gradient(180deg, transparent 0, #000 16%, #000 84%, transparent 100%);
206 -webkit-mask-image: linear-gradient(180deg, transparent 0, #000 16%, #000 84%, transparent 100%);
207}
208
209/* 上下留白:使第一行与最后一行也能滚到正中央 */
210.lrc-scroll .lrc-pad {
211 height: 42%;
212}
213
214.lrc-line {
215 padding: 9px 16px;
216 text-align: center;
217 cursor: pointer;
218 transition: color var(--t-slow) var(--ease-out), transform var(--t-slow) var(--ease-out),
219 opacity var(--t-slow) var(--ease-out);
220 transform-origin: center center;
221}
222
223.lrc-text {
224 display: block;
225 font-size: 15px;
226 line-height: 1.6;
227 color: var(--text-3);
228 font-weight: 400;
229 transition: inherit;
230}
231
232.lrc-trans {
233 display: block;
234 margin-top: 3px;
235 font-size: 12.5px;
236 line-height: 1.5;
237 color: var(--text-4);
238 transition: inherit;
239}
240
241/* 层次:远端更淡 */
242.lrc-line.far .lrc-text {
243 opacity: 0.42;
244}
245
246.lrc-line.near .lrc-text {
247 color: var(--text-2);
248}
249
250.lrc-line:hover .lrc-text {
251 color: var(--theme-color-dark);
252}
253
254/* 当前行 */
255.lrc-line.active {
256 transform: scale(1.06);
257}
258
259.lrc-line.active .lrc-text {
260 color: var(--theme-color-dark);
261 font-size: 18px;
262 font-weight: 650;
263 letter-spacing: 0.2px;
264 text-shadow: 0 2px 14px rgba(var(--theme-color-rgb), 0.22);
265}
266
267.lrc-line.active .lrc-trans {
268 color: var(--text-2);
269}
270
271/* 空态 */
272.lrc-empty {
273 height: 100%;
274 display: flex;
275 flex-direction: column;
276 align-items: center;
277 justify-content: center;
278 gap: 10px;
279 animation: kFadeIn var(--t-slow) ease both;
280}
281
282.lrc-empty-icon {
283 display: grid;
284 place-items: center;
285 width: 76px;
286 height: 76px;
287 border-radius: var(--r-full);
288 background: var(--surface-3);
289 color: var(--text-5);
290}
291
292.lrc-empty-icon.is-loading {
293 color: var(--theme-color);
294 background: var(--theme-color-soft);
295}
296
297.lrc-empty-text {
298 font-size: 14px;
299 color: var(--text-3);
300}
301
302.lrc-empty-hint {
303 font-size: 12px;
304 color: var(--text-5);
305}
306</style>