feat(歌词): 添加滚动歌词组件和接口定义
新增 ScrollingLyrics.vue 组件实现歌词滚动显示功能,包括: 1. 解析 LRC 格式歌词文本 2. 根据当前播放时间高亮对应歌词行 3. 支持歌词和间隔时间的处理 同时在 vite-env.d.ts 中新增 LyricsLine 和 GapLine 接口定义,并在 Playroom.vue 中集成歌词组件
This commit is contained in:
parent
3a5377595b
commit
7ecc38dda8
143
src/components/ScrollingLyrics.vue
Normal file
143
src/components/ScrollingLyrics.vue
Normal file
|
@ -0,0 +1,143 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { watch } from 'vue'
|
||||
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
||||
|
||||
const playQueueStore = usePlayQueueStore()
|
||||
|
||||
const parsedLyrics = ref<(LyricsLine | GapLine)[]>([])
|
||||
const currentLineIndex = ref(0)
|
||||
|
||||
const props = defineProps<{
|
||||
lrcSrc?: string
|
||||
}>()
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.lrcSrc) {
|
||||
const lrcContent = await axios.get(props.lrcSrc)
|
||||
parsedLyrics.value = parseLyrics(lrcContent.data)
|
||||
console.log(parsedLyrics.value)
|
||||
}
|
||||
})
|
||||
|
||||
function parseLyrics(lrcText: string, minGapDuration: number = 5): (LyricsLine | GapLine)[] {
|
||||
if (!lrcText) return []
|
||||
|
||||
const lines = lrcText.split('\n')
|
||||
const tempParsedLines: (LyricsLine | GapLine)[] = []
|
||||
|
||||
// LRC时间格式正则: [mm:ss.xx] 或 [mm:ss]
|
||||
const timeRegex = /\[(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?\]/g
|
||||
|
||||
// 第一步:解析所有时间标签和歌词
|
||||
for (const line of lines) {
|
||||
const matches = [...line.matchAll(timeRegex)]
|
||||
if (matches.length === 0) continue
|
||||
|
||||
// 提取歌词文本(去掉所有时间标签)
|
||||
const text = line.replace(/\[\d{1,2}:\d{2}(?:\.\d{1,3})?\]/g, '').trim()
|
||||
|
||||
// 解析每个时间标签
|
||||
for (const match of matches) {
|
||||
const minutes = parseInt(match[1])
|
||||
const seconds = parseInt(match[2])
|
||||
const milliseconds = match[3] ? parseInt(match[3].padEnd(3, '0')) : 0
|
||||
|
||||
const totalSeconds = minutes * 60 + seconds + milliseconds / 1000
|
||||
|
||||
if (text) {
|
||||
tempParsedLines.push({
|
||||
type: 'lyric',
|
||||
time: totalSeconds,
|
||||
text: text,
|
||||
originalTime: match[0]
|
||||
})
|
||||
} else {
|
||||
tempParsedLines.push({
|
||||
type: 'gap',
|
||||
time: totalSeconds,
|
||||
originalTime: match[0]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按时间排序
|
||||
tempParsedLines.sort((a, b) => a.time - b.time)
|
||||
|
||||
// 第二步:处理间隔,只保留超过指定时长的间隔
|
||||
const finalLines: (LyricsLine | GapLine)[] = []
|
||||
const lyricLines = tempParsedLines.filter(line => line.type === 'lyric') as LyricsLine[]
|
||||
const gapLines = tempParsedLines.filter(line => line.type === 'gap') as GapLine[]
|
||||
|
||||
// 如果没有歌词行,直接返回
|
||||
if (lyricLines.length === 0) {
|
||||
return tempParsedLines
|
||||
}
|
||||
|
||||
// 为每个间隔计算持续时间
|
||||
for (let i = 0; i < gapLines.length; i++) {
|
||||
const gapLine = gapLines[i]
|
||||
|
||||
// 找到这个间隔之后的第一个歌词行
|
||||
const nextLyricLine = lyricLines.find(lyric => lyric.time > gapLine.time)
|
||||
|
||||
if (nextLyricLine) {
|
||||
const duration = nextLyricLine.time - gapLine.time
|
||||
gapLine.duration = duration
|
||||
|
||||
// 只有当间隔超过最小持续时间时才保留
|
||||
if (duration >= minGapDuration) {
|
||||
finalLines.push(gapLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加所有歌词行
|
||||
finalLines.push(...lyricLines)
|
||||
|
||||
// 最终排序
|
||||
return finalLines.sort((a, b) => a.time - b.time)
|
||||
}
|
||||
|
||||
function findCurrentLineIndex(time: number): number {
|
||||
if (parsedLyrics.value.length === 0) return -1
|
||||
|
||||
let index = -1
|
||||
for (let i = 0; i < parsedLyrics.value.length; i++) {
|
||||
if (time >= parsedLyrics.value[i].time) {
|
||||
index = i
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
watch(() => playQueueStore.currentTime, (value) => {
|
||||
currentLineIndex.value = findCurrentLineIndex(value)
|
||||
})
|
||||
|
||||
function calculateTopRange() {
|
||||
return window.innerHeight / 3
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-[40rem] overflow-x-visible flex flex-col gap-4 pl-16" :style="{
|
||||
paddingTop: `${calculateTopRange()}px`,
|
||||
paddingBottom: `${calculateTopRange() * 2}px`
|
||||
}">
|
||||
<div class="" v-for="(line, index) in parsedLyrics" :key="index">
|
||||
<div v-if="line.type === 'lyric'" class="text-3xl font-bold relative">
|
||||
<div :class="currentLineIndex === index ? 'text-black/80 blur-3xl' : 'text-black/20 blur-3xl'">{{ line.text }}</div>
|
||||
<div class="absolute top-0" :class="currentLineIndex === index ? 'text-white' : 'text-white/50'">{{ line.text }}</div>
|
||||
</div>
|
||||
<!-- <div v-else class="text-white/50 text-sm">
|
||||
{{ line.originalTime }}
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -8,6 +8,8 @@ import { useTemplateRef } from 'vue'
|
|||
import { ref, watch } from 'vue'
|
||||
import { usePreferences } from '../stores/usePreferences'
|
||||
|
||||
import ScrollingLyrics from '../components/ScrollingLyrics.vue'
|
||||
|
||||
import RewindIcon from '../assets/icons/rewind.vue'
|
||||
import ForwardIcon from '../assets/icons/forward.vue'
|
||||
import PlayIcon from '../assets/icons/play.vue'
|
||||
|
@ -157,182 +159,187 @@ function calculateStickyTop() {
|
|||
<div class="bg-transparent w-full h-full absolute top-0 left-0" />
|
||||
</div>
|
||||
|
||||
<div class="absolute top-0 left-0 flex justify-center items-center h-screen w-screen overflow-y-auto gap-16 z-10 select-none">
|
||||
<div class="flex flex-col w-96 gap-4 sticky" :style="{
|
||||
top: `${calculateStickyTop()}px`
|
||||
}" ref="controllerRef">
|
||||
<img :src="getCurrentTrack().album?.coverUrl" class="rounded-2xl shadow-2xl border border-white/20 w-96 h-96" />
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<div class="relative flex-auto w-0">
|
||||
<div class="">
|
||||
<div class="text-black/90 blur-lg text-lg font-medium truncate w-80">
|
||||
{{ getCurrentTrack().song.name }}
|
||||
<div class="absolute top-0 left-0 flex justify-center h-screen w-screen overflow-y-auto z-10 select-none">
|
||||
<div class="sticky w-96" :style="{
|
||||
top: `${calculateStickyTop()}px`,
|
||||
height: `${controllerRef?.clientHeight?? 0}px`
|
||||
}">
|
||||
<div class="flex flex-col w-96 gap-4" ref="controllerRef">
|
||||
<img :src="getCurrentTrack().album?.coverUrl" class="rounded-2xl shadow-2xl border border-white/20 w-96 h-96" />
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<div class="relative flex-auto w-0">
|
||||
<div class="">
|
||||
<div class="text-black/90 blur-lg text-lg font-medium truncate w-80">
|
||||
{{ getCurrentTrack().song.name }}
|
||||
</div>
|
||||
<div class="text-black/90 blur-lg text-base truncate w-80">
|
||||
{{ getCurrentTrack().song.artists ?? [] }} —
|
||||
{{ getCurrentTrack().album?.name ?? '未知专辑' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-black/90 blur-lg text-base truncate w-80">
|
||||
{{ getCurrentTrack().song.artists ?? [] }} —
|
||||
{{ getCurrentTrack().album?.name ?? '未知专辑' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-0">
|
||||
<div class="text-white text-lg font-medium truncate w-80">
|
||||
{{ getCurrentTrack().song.name }}
|
||||
</div>
|
||||
<div class="text-white/75 text-base truncate w-80">
|
||||
{{ artistsOrganize(getCurrentTrack().song.artists ?? []) }} —
|
||||
{{ getCurrentTrack().album?.name ?? '未知专辑' }}
|
||||
|
||||
<div class="absolute top-0">
|
||||
<div class="text-white text-lg font-medium truncate w-80">
|
||||
{{ getCurrentTrack().song.name }}
|
||||
</div>
|
||||
<div class="text-white/75 text-base truncate w-80">
|
||||
{{ artistsOrganize(getCurrentTrack().song.artists ?? []) }} —
|
||||
{{ getCurrentTrack().album?.name ?? '未知专辑' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<button class="h-10 w-10 flex justify-center items-center rounded-full bg-black/10 backdrop-blur-3xl">
|
||||
<span class="text-white">
|
||||
<StarEmptyIcon :size="6" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="h-10 w-10 flex justify-center items-center rounded-full bg-black/10 backdrop-blur-3xl">
|
||||
<span class="text-white">
|
||||
<StarEmptyIcon :size="6" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="w-full p-[0.125rem] bg-white/20 shadow-[0_.125rem_1rem_0_#00000010] rounded-full backdrop-blur-3xl">
|
||||
<div class="w-full" ref="progressBarContainer">
|
||||
<div class="w-2 h-2 bg-white rounded-full shadow-md" ref="progressBarThumb" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="w-full p-[0.125rem] bg-white/20 shadow-[0_.125rem_1rem_0_#00000010] rounded-full backdrop-blur-3xl">
|
||||
<div class="w-full" ref="progressBarContainer">
|
||||
<div class="w-2 h-2 bg-white rounded-full shadow-md" ref="progressBarThumb" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-between">
|
||||
<div class="font-medium flex-1 text-left relative">
|
||||
<span
|
||||
class="text-black blur-lg absolute top-0">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span>
|
||||
<span class="text-white/90">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-center relative">
|
||||
<span class="text-black blur-lg absolute top-0">{{ formatDetector() }}</span>
|
||||
<span class="text-white">{{ formatDetector() }}</span>
|
||||
</div>
|
||||
<div class="flex flex-1">
|
||||
<div class="flex-1" />
|
||||
<button class="text-white/90 font-medium text-right relative" @click="preferences.displayTimeLeft = !preferences.displayTimeLeft">
|
||||
<div class="w-full flex justify-between">
|
||||
<div class="font-medium flex-1 text-left relative">
|
||||
<span
|
||||
class="text-black blur-lg absolute top-0">{{ `${preferences.displayTimeLeft ? '-' : ''}${timeFormatter(preferences.displayTimeLeft ? Math.floor(playQueueStore.duration) - Math.floor(playQueueStore.currentTime) : playQueueStore.duration)}` }}</span>
|
||||
<span>{{ `${preferences.displayTimeLeft ? '-' : ''}${timeFormatter(preferences.displayTimeLeft ? Math.floor(playQueueStore.duration) - Math.floor(playQueueStore.currentTime) : playQueueStore.duration)}` }}</span>
|
||||
class="text-black blur-lg absolute top-0">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span>
|
||||
<span class="text-white/90">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-center relative">
|
||||
<span class="text-black blur-lg absolute top-0">{{ formatDetector() }}</span>
|
||||
<span class="text-white">{{ formatDetector() }}</span>
|
||||
</div>
|
||||
<div class="flex flex-1">
|
||||
<div class="flex-1" />
|
||||
<button class="text-white/90 font-medium text-right relative" @click="preferences.displayTimeLeft = !preferences.displayTimeLeft">
|
||||
<span
|
||||
class="text-black blur-lg absolute top-0">{{ `${preferences.displayTimeLeft ? '-' : ''}${timeFormatter(preferences.displayTimeLeft ? Math.floor(playQueueStore.duration) - Math.floor(playQueueStore.currentTime) : playQueueStore.duration)}` }}</span>
|
||||
<span>{{ `${preferences.displayTimeLeft ? '-' : ''}${timeFormatter(preferences.displayTimeLeft ? Math.floor(playQueueStore.duration) - Math.floor(playQueueStore.currentTime) : playQueueStore.duration)}` }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div class="flex-1 text-left flex gap-1">
|
||||
<button class="h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25">
|
||||
<div class="w-6 h-6 relative">
|
||||
<span class="text-black blur-md absolute top-0 left-0">
|
||||
<SpeakerIcon :size="6" />
|
||||
</span>
|
||||
<span class="text-white">
|
||||
<SpeakerIcon :size="6" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25"
|
||||
@click="makePlayQueueListPresent">
|
||||
<div class="w-6 h-6 relative">
|
||||
<span class="text-black blur-md absolute top-0 left-0">
|
||||
<MusicListIcon :size="6" />
|
||||
</span>
|
||||
<span class="text-white">
|
||||
<MusicListIcon :size="6" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-2 text-center align-center justify-center gap-2 flex">
|
||||
|
||||
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25"
|
||||
@click="playPrevious">
|
||||
<div class="w-8 h-8 relative">
|
||||
<span class="text-black/80 blur-lg absolute top-0 left-0">
|
||||
<RewindIcon :size="8" />
|
||||
</span>
|
||||
<span class="text-white">
|
||||
<RewindIcon :size="8" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25"
|
||||
@click="playQueueStore.isPlaying = !playQueueStore.isPlaying">
|
||||
<div v-if="playQueueStore.isPlaying">
|
||||
<div v-if="playQueueStore.isBuffering" class="w-6 h-6 relative">
|
||||
<span class="text-black/80 blur-lg absolute top-0 left-0">
|
||||
<LoadingIndicator :size="6" />
|
||||
</span>
|
||||
<span class="text-white">
|
||||
<LoadingIndicator :size="6" />
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="w-8 h-8 relative">
|
||||
<span class="text-black blur-md absolute top-0 left-0">
|
||||
<PauseIcon :size="8" />
|
||||
</span>
|
||||
<span class="text-white">
|
||||
<PauseIcon :size="8" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="w-8 h-8 relative">
|
||||
<span class="text-black/80 blur-lg absolute top-0 left-0">
|
||||
<PlayIcon :size="8" />
|
||||
</span>
|
||||
<span class="text-white">
|
||||
<PlayIcon :size="8" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25"
|
||||
@click="playNext">
|
||||
<div class="w-8 h-8 relative">
|
||||
<span class="text-black/80 blur-lg absolute top-0 left-0">
|
||||
<ForwardIcon :size="8" />
|
||||
</span>
|
||||
<span class="text-white">
|
||||
<ForwardIcon :size="8" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 text-right flex gap-1">
|
||||
<div class="flex-1" />
|
||||
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25">
|
||||
<div class="w-6 h-6 relative">
|
||||
<span class="text-black blur-md absolute top-0 left-0">
|
||||
<ChatBubbleQuoteIcon :size="6" />
|
||||
</span>
|
||||
<span class="text-white">
|
||||
<ChatBubbleQuoteIcon :size="6" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25">
|
||||
<div class="w-6 h-6 relative">
|
||||
<span class="text-black blur-sm absolute top-0 left-0">
|
||||
<EllipsisHorizontalIcon :size="6" />
|
||||
</span>
|
||||
<span class="text-white">
|
||||
<EllipsisHorizontalIcon :size="6" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div class="flex-1 text-left flex gap-1">
|
||||
<button class="h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25">
|
||||
<div class="w-6 h-6 relative">
|
||||
<span class="text-black blur-md absolute top-0 left-0">
|
||||
<SpeakerIcon :size="6" />
|
||||
</span>
|
||||
<span class="text-white">
|
||||
<SpeakerIcon :size="6" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25"
|
||||
@click="makePlayQueueListPresent">
|
||||
<div class="w-6 h-6 relative">
|
||||
<span class="text-black blur-md absolute top-0 left-0">
|
||||
<MusicListIcon :size="6" />
|
||||
</span>
|
||||
<span class="text-white">
|
||||
<MusicListIcon :size="6" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-2 text-center align-center justify-center gap-2 flex">
|
||||
|
||||
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25"
|
||||
@click="playPrevious">
|
||||
<div class="w-8 h-8 relative">
|
||||
<span class="text-black/80 blur-lg absolute top-0 left-0">
|
||||
<RewindIcon :size="8" />
|
||||
</span>
|
||||
<span class="text-white">
|
||||
<RewindIcon :size="8" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25"
|
||||
@click="playQueueStore.isPlaying = !playQueueStore.isPlaying">
|
||||
<div v-if="playQueueStore.isPlaying">
|
||||
<div v-if="playQueueStore.isBuffering" class="w-6 h-6 relative">
|
||||
<span class="text-black/80 blur-lg absolute top-0 left-0">
|
||||
<LoadingIndicator :size="6" />
|
||||
</span>
|
||||
<span class="text-white">
|
||||
<LoadingIndicator :size="6" />
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="w-8 h-8 relative">
|
||||
<span class="text-black blur-md absolute top-0 left-0">
|
||||
<PauseIcon :size="8" />
|
||||
</span>
|
||||
<span class="text-white">
|
||||
<PauseIcon :size="8" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="w-8 h-8 relative">
|
||||
<span class="text-black/80 blur-lg absolute top-0 left-0">
|
||||
<PlayIcon :size="8" />
|
||||
</span>
|
||||
<span class="text-white">
|
||||
<PlayIcon :size="8" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25"
|
||||
@click="playNext">
|
||||
<div class="w-8 h-8 relative">
|
||||
<span class="text-black/80 blur-lg absolute top-0 left-0">
|
||||
<ForwardIcon :size="8" />
|
||||
</span>
|
||||
<span class="text-white">
|
||||
<ForwardIcon :size="8" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 text-right flex gap-1">
|
||||
<div class="flex-1" />
|
||||
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25">
|
||||
<div class="w-6 h-6 relative">
|
||||
<span class="text-black blur-md absolute top-0 left-0">
|
||||
<ChatBubbleQuoteIcon :size="6" />
|
||||
</span>
|
||||
<span class="text-white">
|
||||
<ChatBubbleQuoteIcon :size="6" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button class="text-white h-8 w-8 flex justify-center items-center rounded-full hover:bg-white/25">
|
||||
<div class="w-6 h-6 relative">
|
||||
<span class="text-black blur-sm absolute top-0 left-0">
|
||||
<EllipsisHorizontalIcon :size="6" />
|
||||
</span>
|
||||
<span class="text-white">
|
||||
<EllipsisHorizontalIcon :size="6" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ScrollingLyrics :lrcSrc="getCurrentTrack().song.lyricUrl ?? undefined" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queue list -->
|
||||
|
|
14
src/vite-env.d.ts
vendored
14
src/vite-env.d.ts
vendored
|
@ -38,4 +38,18 @@ interface ApiResponse {
|
|||
interface QueueItem {
|
||||
song: Song
|
||||
album?: Album
|
||||
}
|
||||
|
||||
interface LyricsLine {
|
||||
type: 'lyric'
|
||||
time: number
|
||||
text: string
|
||||
originalTime: string
|
||||
}
|
||||
|
||||
interface GapLine {
|
||||
type: 'gap'
|
||||
time: number
|
||||
originalTime: string
|
||||
duration?: number // 添加间隔持续时间
|
||||
}
|
Loading…
Reference in New Issue
Block a user