830 lines
23 KiB
Vue
830 lines
23 KiB
Vue
<template>
|
||
<div
|
||
:style="{
|
||
'--lrc-size': getFontSize(settings.lyricFontSize.value, settings.lyricFontSizeMode.value),
|
||
'--lrc-tran-size': getFontSize(
|
||
settings.lyricTranFontSize.value,
|
||
settings.lyricFontSizeMode.value
|
||
),
|
||
'--lrc-roma-size': getFontSize(
|
||
settings.lyricRomaFontSize.value,
|
||
settings.lyricFontSizeMode.value
|
||
),
|
||
'--lrc-bold': settings.lyricFontWeight.value,
|
||
'--lrc-left-padding': `${settings.lyricHorizontalOffset.value}px`,
|
||
'font-family': settings.LyricFont.value !== 'follow' ? settings.LyricFont.value : '',
|
||
cursor: 'auto'
|
||
}"
|
||
:class="[
|
||
'lyric',
|
||
'normal',
|
||
{
|
||
'align-right': settings.lyricAlignRight.value
|
||
}
|
||
]"
|
||
@mouseleave="lrcAllLeave"
|
||
>
|
||
<div class="lyric-content">
|
||
<Transition name="fade" mode="out-in">
|
||
<div v-if="lyricManager.loading.value" class="lyric-loading">歌词正在加载中...</div>
|
||
<div v-else-if="!currentLyricData || currentLyricData.length === 0" class="lyric-loading">
|
||
暂无歌词
|
||
</div>
|
||
<div
|
||
v-else
|
||
ref="lyricScrollContainer"
|
||
class="lyric-scroll-container"
|
||
tabindex="-1"
|
||
@wheel="handleUserScroll"
|
||
@touchstart="handleUserScroll"
|
||
>
|
||
<!-- 顶部占位 -->
|
||
<div id="lrc-placeholder" class="placeholder" />
|
||
<!-- 预处理后的歌词列表 -->
|
||
<template v-for="(item, index) in processedLyrics" :key="`item-${index}`">
|
||
<!-- 倒计时行 -->
|
||
<div
|
||
v-if="item.type === 'countdown'"
|
||
class="countdown-line"
|
||
:style="{
|
||
animationPlayState: isPlaying ? 'running' : 'paused'
|
||
}"
|
||
>
|
||
<Transition name="fade" mode="out-in">
|
||
<div v-if="isCountdownVisible(item)" class="count-down">
|
||
<div
|
||
v-for="i in 3"
|
||
:key="i"
|
||
:style="{ opacity: getPointOpacity(item, i - 1) }"
|
||
class="point"
|
||
/>
|
||
</div>
|
||
</Transition>
|
||
</div>
|
||
<!-- 歌词行 -->
|
||
<div
|
||
v-else
|
||
:id="`lrc-${index}`"
|
||
:class="getLyricLineClass(item, index)"
|
||
:style="getLyricLineStyle(item, index)"
|
||
@click="jumpSeek(item.data.startTime)"
|
||
>
|
||
<!-- 逐字歌词 -->
|
||
<template v-if="isYrcMode">
|
||
<div class="content">
|
||
<div
|
||
v-for="(text, textIndex) in item.data.words"
|
||
:key="textIndex"
|
||
:class="{
|
||
'content-text': true,
|
||
'end-with-space': text.word.endsWith(' ') || text.startTime === 0
|
||
}"
|
||
:style="getYrcVars(text, index)"
|
||
>
|
||
<span class="yrc-word" :lang="getLyricLanguage(text.word)">
|
||
{{ text.word }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<!-- 普通歌词 -->
|
||
<template v-else>
|
||
<span class="content" :lang="getLyricLanguage(item.data.words?.[0]?.word)">
|
||
{{ item.data.words.map((w) => w.word).join('') }}
|
||
</span>
|
||
</template>
|
||
<!-- 翻译和音译 -->
|
||
<template v-if="settings.swapTranRoma.value">
|
||
<!-- 音译在前 -->
|
||
<span v-if="item.data.romanLyric && settings.showRoma.value" class="roma" lang="en">
|
||
{{ item.data.romanLyric }}
|
||
</span>
|
||
<span
|
||
v-if="item.data.translatedLyric && settings.showTranslation.value"
|
||
class="tran"
|
||
lang="en"
|
||
>
|
||
{{ item.data.translatedLyric }}
|
||
</span>
|
||
</template>
|
||
<template v-else>
|
||
<!-- 翻译在前(默认) -->
|
||
<span
|
||
v-if="item.data.translatedLyric && settings.showTranslation.value"
|
||
class="tran"
|
||
lang="en"
|
||
>
|
||
{{ item.data.translatedLyric }}
|
||
</span>
|
||
<span v-if="item.data.romanLyric && settings.showRoma.value" class="roma" lang="en">
|
||
{{ item.data.romanLyric }}
|
||
</span>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<!-- 底部占位 -->
|
||
<div class="placeholder" />
|
||
</div>
|
||
</Transition>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||
import type { LyricWord, LyricLine } from '@applemusic-like-lyrics/lyric'
|
||
import { useLyricManager } from '~/composables/useLyricManager'
|
||
import { useLyricSettings } from '~/composables/useLyricSettings'
|
||
import { useAudioPlayer } from '~/composables/useAudioPlayer'
|
||
import { useAudioPlayerControl } from '~/composables/useAudioPlayerControl'
|
||
|
||
const props = defineProps({
|
||
currentTime: {
|
||
type: Number,
|
||
default: 0
|
||
}
|
||
})
|
||
|
||
const lyricManager = useLyricManager()
|
||
const settings = useLyricSettings()
|
||
const audioPlayer = useAudioPlayer()
|
||
const audioPlayerControl = useAudioPlayerControl()
|
||
|
||
const lyricScrollContainer = ref<HTMLElement | null>(null)
|
||
|
||
// 是否为逐字歌词模式
|
||
const isYrcMode = computed(() => {
|
||
const format = lyricManager.lyricFormat.value
|
||
return (
|
||
settings.showYrc.value &&
|
||
(format === 'qrc' || format === 'word-by-word' || format === 'enhanced')
|
||
)
|
||
})
|
||
|
||
// 获取当前使用的歌词数据
|
||
const currentLyricData = computed(() => lyricManager.lyrics.value)
|
||
|
||
const isPlaying = computed(() => audioPlayer.getPlayingStatus().value)
|
||
|
||
/** 处理后的歌词项类型 */
|
||
type ProcessedLyricItem =
|
||
| { type: 'lyric'; originalIndex: number; data: LyricLine }
|
||
| { type: 'countdown'; startTime: number; duration: number }
|
||
|
||
/** 间奏倒计时最小时长(毫秒) */
|
||
const COUNTDOWN_THRESHOLD = 10000
|
||
/** 开头倒计时最小时长(毫秒) */
|
||
const INTRO_THRESHOLD = 3000
|
||
|
||
/** 预处理歌词数据 */
|
||
const processedLyrics = computed<ProcessedLyricItem[]>(() => {
|
||
const lyrics = currentLyricData.value
|
||
if (!lyrics || lyrics.length === 0) return []
|
||
const result: ProcessedLyricItem[] = []
|
||
// 检查开头是否需要倒计时
|
||
const firstLyricStart = lyrics[0]?.startTime || 0
|
||
if (settings.countDownShow.value && firstLyricStart >= INTRO_THRESHOLD) {
|
||
result.push({
|
||
type: 'countdown',
|
||
startTime: 0,
|
||
duration: firstLyricStart
|
||
})
|
||
}
|
||
// 遍历歌词
|
||
for (let i = 0; i < lyrics.length; i++) {
|
||
const item = lyrics[i]
|
||
result.push({
|
||
type: 'lyric',
|
||
originalIndex: i,
|
||
data: item
|
||
})
|
||
// 检查是否需要插入倒计时
|
||
if (settings.countDownShow.value && i < lyrics.length - 1) {
|
||
const currentEnd = item.endTime
|
||
const nextStart = lyrics[i + 1]?.startTime || 0
|
||
const gap = nextStart - currentEnd
|
||
if (gap >= COUNTDOWN_THRESHOLD) {
|
||
result.push({
|
||
type: 'countdown',
|
||
startTime: currentEnd,
|
||
duration: gap
|
||
})
|
||
}
|
||
}
|
||
}
|
||
return result
|
||
})
|
||
|
||
/** 倒计时是否可见 */
|
||
const isCountdownVisible = (item: ProcessedLyricItem): boolean => {
|
||
if (item.type !== 'countdown') return false
|
||
// 计算实时时间 - 0.5s 是否小于开始 + 持续时间
|
||
// currentTime 是秒,转换为毫秒
|
||
return props.currentTime * 1000 + 500 < item.startTime + item.duration
|
||
}
|
||
|
||
/** 计算当前活跃的歌词行索引列表 */
|
||
const activeLineIndices = computed<number[]>(() => {
|
||
const lyrics = processedLyrics.value
|
||
if (!lyrics || lyrics.length === 0) return []
|
||
const currentSeek = props.currentTime * 1000 // 秒转毫秒
|
||
const activeCandidates: number[] = []
|
||
|
||
for (let i = 0; i < lyrics.length; i++) {
|
||
const item = lyrics[i]
|
||
let start = 0
|
||
let end = Infinity
|
||
if (item.type === 'lyric') {
|
||
start = item.data.startTime || 0
|
||
end = item.data.endTime ?? Infinity
|
||
} else {
|
||
start = item.startTime
|
||
end = item.startTime + item.duration
|
||
}
|
||
|
||
if (currentSeek >= start && currentSeek < end) {
|
||
activeCandidates.push(i)
|
||
}
|
||
}
|
||
// 如果没有活跃行,找最近的上一行
|
||
if (activeCandidates.length === 0 && currentSeek > 0) {
|
||
// 找到第一个开始时间大于当前时间的行
|
||
const nextIndex = lyrics.findIndex((item) => {
|
||
const start = item.type === 'lyric' ? item.data.startTime : item.startTime
|
||
return (start || 0) > currentSeek
|
||
})
|
||
if (nextIndex === -1) return [lyrics.length - 1] // 都在后面,取最后一行
|
||
if (nextIndex > 0) return [nextIndex - 1] // 取前一行
|
||
}
|
||
return activeCandidates
|
||
})
|
||
|
||
/** 首个高亮行索引 */
|
||
const firstActiveIndex = computed(() => {
|
||
return activeLineIndices.value[0] ?? -1
|
||
})
|
||
|
||
/** 判断某行是否高亮 */
|
||
const isLineActive = (index: number): boolean => {
|
||
return activeLineIndices.value.includes(index)
|
||
}
|
||
|
||
/**
|
||
* 计算倒计时圆点透明度
|
||
*/
|
||
const getPointOpacity = (item: ProcessedLyricItem, index: number): number => {
|
||
if (item.type !== 'countdown') return 0
|
||
const perPointTime = item.duration / 3
|
||
const currentTime = props.currentTime * 1000 - item.startTime
|
||
if (currentTime <= 0) return 0
|
||
if (currentTime < perPointTime * (index + 1)) {
|
||
const percentage = (currentTime - perPointTime * index) / perPointTime
|
||
return 0.1 + 0.7 * (1 - percentage)
|
||
}
|
||
return 0.1
|
||
}
|
||
|
||
/** 当前滚动动画 ID */
|
||
let scrollAnimationId: number | null = null
|
||
/** 用户是否正在滚动 */
|
||
const userScrolling = ref(false)
|
||
/** 用户滚动恢复超时 ID */
|
||
let userScrollTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||
/** 用户滚动恢复超时时间(毫秒) */
|
||
const USER_SCROLL_TIMEOUT = 3000
|
||
|
||
/**
|
||
* 自主滚动
|
||
*/
|
||
const handleUserScroll = () => {
|
||
userScrolling.value = true
|
||
if (userScrollTimeoutId !== null) {
|
||
clearTimeout(userScrollTimeoutId)
|
||
}
|
||
// 超时后恢复
|
||
userScrollTimeoutId = setTimeout(() => {
|
||
userScrolling.value = false
|
||
userScrollTimeoutId = null
|
||
lyricsScroll(firstActiveIndex.value)
|
||
}, USER_SCROLL_TIMEOUT)
|
||
}
|
||
|
||
/**
|
||
* 平滑滚动
|
||
*/
|
||
const smoothScrollTo = (container: HTMLElement, targetY: number, duration = 300) => {
|
||
// 取消之前的动画
|
||
if (scrollAnimationId !== null) {
|
||
cancelAnimationFrame(scrollAnimationId)
|
||
scrollAnimationId = null
|
||
}
|
||
// 计算起始位置和目标位置
|
||
const startY = container.scrollTop
|
||
const diff = targetY - startY
|
||
// 如果差值很小,直接设置
|
||
if (Math.abs(diff) < 0.5) {
|
||
container.scrollTop = targetY
|
||
return
|
||
}
|
||
const startTime = performance.now()
|
||
const step = (currentTime: number) => {
|
||
const elapsed = currentTime - startTime
|
||
const progress = Math.min(elapsed / duration, 1)
|
||
// easeInOutQuad 缓动
|
||
const easedProgress =
|
||
progress < 0.5 ? 2 * progress * progress : 1 - Math.pow(-2 * progress + 2, 2) / 2
|
||
container.scrollTop = startY + diff * easedProgress
|
||
if (progress < 1) {
|
||
scrollAnimationId = requestAnimationFrame(step)
|
||
} else {
|
||
scrollAnimationId = null
|
||
}
|
||
}
|
||
scrollAnimationId = requestAnimationFrame(step)
|
||
}
|
||
|
||
/**
|
||
* 歌词滚动
|
||
*/
|
||
const lyricsScroll = (index: number) => {
|
||
const container = lyricScrollContainer.value
|
||
if (!container) return
|
||
// 用户滚动时不自动滚动
|
||
if (userScrolling.value) return
|
||
const lrcItemDom = document.getElementById(index >= 0 ? `lrc-${index}` : 'lrc-placeholder')
|
||
if (!lrcItemDom) return
|
||
// 计算目标滚动位置
|
||
const containerHeight = container.clientHeight
|
||
const elementTop = lrcItemDom.offsetTop
|
||
const elementHeight = lrcItemDom.offsetHeight
|
||
// 居中偏移滚动
|
||
let targetY = elementTop - (containerHeight - elementHeight) * settings.lyricsScrollOffset.value
|
||
// 确保不超出边界
|
||
targetY = Math.max(0, Math.min(targetY, container.scrollHeight - container.clientHeight))
|
||
// 执行平滑滚动
|
||
smoothScrollTo(container, targetY, 500)
|
||
}
|
||
|
||
/**
|
||
* 鼠标移出歌词区域恢复自动滚动
|
||
*/
|
||
const lrcAllLeave = () => {
|
||
userScrolling.value = false
|
||
if (userScrollTimeoutId !== null) {
|
||
clearTimeout(userScrollTimeoutId)
|
||
userScrollTimeoutId = null
|
||
}
|
||
lyricsScroll(firstActiveIndex.value)
|
||
}
|
||
|
||
type CssVars = Record<`--${string}`, string>
|
||
|
||
/** 逐字歌词淡入淡出因子 */
|
||
const YRC_DIM_ALPHA = 0.3
|
||
/** 逐字歌词淡入淡出时间 */
|
||
const YRC_LINE_FADE_MS = 250
|
||
/** 淡入淡出行索引 */
|
||
const yrcFadingLineIndex = ref<number | null>(null)
|
||
/** 淡入淡出结束时间 */
|
||
const yrcFadingUntilAt = ref<number>(0)
|
||
|
||
const getYrcFadeFactor = (index: number): number => {
|
||
if (yrcFadingLineIndex.value !== index) return 1
|
||
const now = Date.now()
|
||
if (now >= yrcFadingUntilAt.value) return 1
|
||
const remain = yrcFadingUntilAt.value - now
|
||
return Math.min(Math.max(remain / YRC_LINE_FADE_MS, 0), 1)
|
||
}
|
||
|
||
const getYrcVars = (wordData: LyricWord, lyricIndex: number): CssVars => {
|
||
const currentSeek = props.currentTime * 1000
|
||
const fadeFactor = getYrcFadeFactor(lyricIndex)
|
||
// 判断是否显示
|
||
const currentLineItem = processedLyrics.value[lyricIndex]
|
||
if (!currentLineItem || currentLineItem.type !== 'lyric') return {}
|
||
|
||
if (!isYrcLineOn(lyricIndex)) return {}
|
||
// 计算进度
|
||
const duration = wordData.endTime - wordData.startTime
|
||
const safeDuration = Math.max(duration, 1)
|
||
const rawProgress = (currentSeek - wordData.startTime) / safeDuration
|
||
const progress = Math.min(Math.max(rawProgress, 0), 1)
|
||
const maskX = `${(1 - Math.min(progress, 1)) * 100}%`
|
||
// 计算透明度
|
||
const hasStarted = currentSeek >= wordData.startTime
|
||
const brightAlpha = hasStarted ? YRC_DIM_ALPHA + (1 - YRC_DIM_ALPHA) * fadeFactor : YRC_DIM_ALPHA
|
||
const darkAlpha = YRC_DIM_ALPHA
|
||
|
||
let transform = 'scale(1)'
|
||
if (progress > 0) {
|
||
const scale = 1 + 0.08 * progress
|
||
const y = -2 * progress
|
||
transform = `scale(${scale}) translateY(${y}px)`
|
||
}
|
||
|
||
return {
|
||
'--yrc-mask-x': maskX,
|
||
'--yrc-opacity': '1',
|
||
'--yrc-bright-alpha': `${brightAlpha}`,
|
||
'--yrc-dark-alpha': `${darkAlpha}`,
|
||
'--yrc-transform': transform
|
||
}
|
||
}
|
||
|
||
const isYrcLineOn = (index: number): boolean => {
|
||
const isActive = isLineActive(index)
|
||
const isFading = yrcFadingLineIndex.value === index && Date.now() < yrcFadingUntilAt.value
|
||
return isActive || isFading
|
||
}
|
||
|
||
const getLyricLineClass = (item: ProcessedLyricItem, index: number) => {
|
||
if (item.type !== 'lyric') return []
|
||
const lyricData = item.data
|
||
const isOn = isYrcMode.value ? isYrcLineOn(index) : isLineActive(index)
|
||
return [
|
||
'lrc-line',
|
||
isYrcMode.value ? 'is-yrc' : 'is-lrc',
|
||
{
|
||
on: isOn,
|
||
'is-bg': lyricData.isBG,
|
||
'is-duet': lyricData.isDuet
|
||
}
|
||
]
|
||
}
|
||
|
||
const getLyricLineStyle = (item: ProcessedLyricItem, index: number) => {
|
||
if (item.type !== 'lyric') return {}
|
||
|
||
// 如果用户正在滚动,或者模糊设置关闭,则不模糊
|
||
if (!settings.lyricsBlur.value || userScrolling.value) return { filter: 'blur(0)' }
|
||
|
||
const activeIdx = firstActiveIndex.value
|
||
const isOn = isLineActive(index)
|
||
return {
|
||
filter: isOn ? 'blur(0)' : `blur(${Math.min(Math.abs(activeIdx - index) * 1.8, 10)}px)`
|
||
}
|
||
}
|
||
|
||
const jumpSeek = (time: number) => {
|
||
if (!time) return
|
||
userScrolling.value = false
|
||
if (userScrollTimeoutId !== null) {
|
||
clearTimeout(userScrollTimeoutId)
|
||
userScrollTimeoutId = null
|
||
}
|
||
|
||
const offsetMs = settings.lyricOffset.value
|
||
const targetTime = (time - offsetMs) / 1000
|
||
|
||
audioPlayerControl.seekAndPlay(targetTime)
|
||
}
|
||
|
||
// Utils
|
||
const getFontSize = (size: number, mode: string) => {
|
||
if (mode === 'adaptive') {
|
||
return `calc(${size} / 1080 * 100vh)`
|
||
}
|
||
return `${size}px`
|
||
}
|
||
|
||
const getLyricLanguage = (lyric: string): 'ja' | 'ko' | 'zh-CN' | 'en' => {
|
||
if (!lyric || typeof lyric !== 'string') return 'en'
|
||
if (/[\u3040-\u309F\u30A0-\u30FF]/.test(lyric)) return 'ja'
|
||
if (/[\uAC00-\uD7AF]/.test(lyric)) return 'ko'
|
||
if (/[\u4E00-\u9FFF]/.test(lyric)) return 'zh-CN'
|
||
return 'en'
|
||
}
|
||
|
||
// Watchers
|
||
watch(firstActiveIndex, (val, oldVal) => {
|
||
lyricsScroll(val)
|
||
if (typeof oldVal === 'number' && oldVal >= 0 && oldVal !== val) {
|
||
yrcFadingLineIndex.value = oldVal
|
||
yrcFadingUntilAt.value = Date.now() + YRC_LINE_FADE_MS
|
||
}
|
||
})
|
||
|
||
onMounted(() => {
|
||
nextTick().then(() => {
|
||
lyricsScroll(firstActiveIndex.value)
|
||
})
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
if (scrollAnimationId !== null) {
|
||
cancelAnimationFrame(scrollAnimationId)
|
||
scrollAnimationId = null
|
||
}
|
||
if (userScrollTimeoutId !== null) {
|
||
clearTimeout(userScrollTimeoutId)
|
||
userScrollTimeoutId = null
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.lyric {
|
||
position: relative;
|
||
width: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
align-items: flex-start;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
.lyric-scroll-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
padding-left: var(--lrc-left-padding, 10px);
|
||
padding-right: 80px;
|
||
box-sizing: border-box;
|
||
/* 隐藏滚动条 */
|
||
scrollbar-width: none;
|
||
-ms-overflow-style: none;
|
||
&::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
@media (max-width: 990px) {
|
||
padding-right: 20px;
|
||
}
|
||
}
|
||
.placeholder {
|
||
width: 100%;
|
||
&:first-child {
|
||
height: 300px;
|
||
display: flex;
|
||
align-items: flex-end;
|
||
}
|
||
&:last-child {
|
||
height: 0;
|
||
padding-top: 100%;
|
||
}
|
||
}
|
||
.lyric-content {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
/* 倒计时行 */
|
||
.countdown-line {
|
||
margin: 6px 0;
|
||
padding: 10px 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
min-height: 48px;
|
||
.count-down {
|
||
display: flex;
|
||
flex-direction: row-reverse;
|
||
align-items: center;
|
||
animation: breathe 4s ease-in-out infinite;
|
||
.point {
|
||
width: 28px;
|
||
height: 28px;
|
||
margin-right: 12px;
|
||
border-radius: 50%;
|
||
background-color: rgb(var(--main-cover-color));
|
||
transition: opacity 0.5s;
|
||
@media (max-width: 900px) {
|
||
width: 24px;
|
||
height: 24px;
|
||
}
|
||
@media (max-width: 700px) {
|
||
width: 20px;
|
||
height: 20px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.lrc-line {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
margin: 6px 0;
|
||
padding: 10px 16px;
|
||
transform: scale(0.95);
|
||
transform-origin: left center;
|
||
will-change: filter, opacity, transform;
|
||
transition:
|
||
filter 0.35s,
|
||
opacity 0.35s,
|
||
transform 0.5s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||
cursor: pointer;
|
||
width: 100%;
|
||
.content {
|
||
display: block;
|
||
font-size: var(--lrc-size);
|
||
font-weight: var(--lrc-bold);
|
||
width: 100%;
|
||
overflow-wrap: anywhere;
|
||
word-break: break-word;
|
||
white-space: normal;
|
||
hyphens: auto;
|
||
.content-text {
|
||
position: relative;
|
||
display: inline-block;
|
||
overflow: visible;
|
||
overflow-wrap: anywhere;
|
||
word-break: break-word;
|
||
white-space: normal;
|
||
.yrc-word {
|
||
display: inline-block;
|
||
box-sizing: border-box;
|
||
padding-block: 0.2em;
|
||
margin-block: -0.2em;
|
||
opacity: var(--yrc-opacity, 0.3);
|
||
}
|
||
&.end-with-space {
|
||
margin-right: 12px;
|
||
&:last-child {
|
||
margin-right: 0;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.tran {
|
||
margin-top: 8px;
|
||
opacity: 0.6;
|
||
font-size: var(--lrc-tran-size);
|
||
transition: opacity 0.35s;
|
||
width: 100%;
|
||
overflow-wrap: anywhere;
|
||
word-break: break-word;
|
||
white-space: normal;
|
||
hyphens: auto;
|
||
}
|
||
.roma {
|
||
margin-top: 4px;
|
||
opacity: 0.5;
|
||
font-size: var(--lrc-roma-size);
|
||
transition: opacity 0.35s;
|
||
width: 100%;
|
||
overflow-wrap: anywhere;
|
||
word-break: break-word;
|
||
white-space: normal;
|
||
hyphens: auto;
|
||
}
|
||
&.is-lrc {
|
||
opacity: 0.3;
|
||
}
|
||
&.is-yrc {
|
||
.content {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
width: 100%;
|
||
overflow-wrap: anywhere;
|
||
word-break: break-word;
|
||
white-space: normal;
|
||
}
|
||
.tran,
|
||
.roma {
|
||
opacity: 0.3;
|
||
}
|
||
&.is-bg {
|
||
opacity: 0.4;
|
||
transform: scale(0.7);
|
||
padding: 0px 20px;
|
||
}
|
||
&.is-duet {
|
||
transform-origin: right;
|
||
.content,
|
||
.tran,
|
||
.roma {
|
||
text-align: right;
|
||
justify-content: flex-end;
|
||
}
|
||
}
|
||
}
|
||
&.on {
|
||
opacity: 1 !important;
|
||
transform: scale(1);
|
||
.tran,
|
||
.roma {
|
||
opacity: 0.6;
|
||
}
|
||
&.is-bg {
|
||
opacity: 0.85 !important;
|
||
}
|
||
}
|
||
&::before {
|
||
content: '';
|
||
display: block;
|
||
position: absolute;
|
||
left: 0px;
|
||
top: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 8px;
|
||
background-color: rgba(var(--main-cover-color), 0.14);
|
||
opacity: 0;
|
||
z-index: 0;
|
||
transform: scale(1.05);
|
||
transition:
|
||
transform 0.35s ease,
|
||
opacity 0.35s ease;
|
||
pointer-events: none;
|
||
}
|
||
}
|
||
@media (hover: hover) and (pointer: fine) {
|
||
.lrc-line:hover {
|
||
opacity: 1;
|
||
&::before {
|
||
transform: scale(1);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
.lrc-line:active {
|
||
&::before {
|
||
transform: scale(0.95);
|
||
}
|
||
}
|
||
}
|
||
&.flex-end,
|
||
&.align-right {
|
||
span {
|
||
text-align: right;
|
||
}
|
||
.placeholder {
|
||
justify-content: flex-end;
|
||
}
|
||
.lrc-line {
|
||
transform-origin: right;
|
||
.content {
|
||
text-align: right;
|
||
}
|
||
&.is-duet {
|
||
transform-origin: left;
|
||
.content,
|
||
.tran,
|
||
.roma {
|
||
text-align: left;
|
||
justify-content: flex-start;
|
||
}
|
||
}
|
||
}
|
||
.countdown-line {
|
||
justify-content: flex-end;
|
||
.count-down {
|
||
flex-direction: row;
|
||
}
|
||
}
|
||
}
|
||
&.center,
|
||
&.pure {
|
||
span {
|
||
text-align: center !important;
|
||
}
|
||
.placeholder {
|
||
justify-content: center !important;
|
||
}
|
||
.lrc-line {
|
||
transform-origin: center !important;
|
||
.content {
|
||
text-align: center !important;
|
||
justify-content: center !important;
|
||
}
|
||
}
|
||
.countdown-line {
|
||
justify-content: center;
|
||
}
|
||
}
|
||
|
||
/* 逐字歌词:激活行启用 mask 动画 */
|
||
.lrc-line.is-yrc.on {
|
||
.content-text {
|
||
.yrc-word {
|
||
will-change: -webkit-mask-position-x;
|
||
mask-image: linear-gradient(
|
||
to right,
|
||
rgba(0, 0, 0, var(--yrc-bright-alpha, 1)) 45.4545454545%,
|
||
rgba(0, 0, 0, var(--yrc-dark-alpha, 0.3)) 54.5454545455%
|
||
);
|
||
mask-size: 220% 100%;
|
||
mask-repeat: no-repeat;
|
||
-webkit-mask-image: linear-gradient(
|
||
to right,
|
||
rgba(0, 0, 0, var(--yrc-bright-alpha, 1)) 45.4545454545%,
|
||
rgba(0, 0, 0, var(--yrc-dark-alpha, 0.3)) 54.5454545455%
|
||
);
|
||
-webkit-mask-size: 220% 100%;
|
||
-webkit-mask-repeat: no-repeat;
|
||
-webkit-mask-position-x: var(--yrc-mask-x, 0%);
|
||
transition: none;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.lyric-loading {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 22px;
|
||
color: var(--text-primary, #ffffff);
|
||
}
|
||
</style>
|