初次提交
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s

This commit is contained in:
2026-09-06 08:19:02 +08:00
commit 4ce4649aaf
1061 changed files with 337258 additions and 0 deletions
@@ -0,0 +1,205 @@
<template>
<div
class="lyric-am"
:class="{
'align-right': settings.lyricAlignRight.value
}"
:style="{
'--amll-lp-color': 'rgb(var(--main-cover-color, 239 239 239))',
'--amll-lp-hover-bg-color': 'rgba(255,255,255,0.08)',
'--amll-lyric-left-padding': settings.lyricAlignRight.value
? ''
: `${settings.lyricHorizontalOffset.value}px`,
'--amll-lyric-right-padding': settings.lyricAlignRight.value
? `${settings.lyricHorizontalOffset.value}px`
: ''
}"
>
<Transition name="fade" mode="out-in">
<div v-if="lyricManager.loading.value" class="lyric-message-container">
<Icon name="loader" :size="32" class="spin-animation" />
<div class="message-text">歌词加载中...</div>
</div>
<div v-else-if="lyricManager.error.value" class="lyric-message-container">
<Icon name="alert-circle" :size="32" />
<div class="message-text">{{ lyricManager.error.value }}</div>
</div>
<div v-else-if="!lyricLines || lyricLines.length === 0" class="lyric-message-container">
<Icon name="music" :size="48" style="opacity: 0.5" />
<div class="message-text">暂无歌词</div>
</div>
<LyricPlayer
v-else
ref="lyricPlayerRef"
:lyric-lines="lyricLines"
:current-time="currentTime * 1000"
:playing="audioPlayer.getPlayingStatus().value"
:enable-spring="settings.useAMSpring.value"
:enable-scale="settings.useAMSpring.value"
:align-position="settings.lyricsScrollOffset.value"
:align-anchor="settings.lyricsScrollOffset.value > 0.4 ? 'center' : 'top'"
:enable-blur="settings.lyricsBlur.value"
:hide-passed-lines="settings.hidePassedLines.value"
:word-fade-width="settings.wordFadeWidth.value"
:style="{
'--display-count-down-show': settings.countDownShow.value ? 'flex' : 'none',
'--amll-lp-font-size': `${settings.lyricFontSize.value}px`,
'font-weight': settings.lyricFontWeight.value,
'font-family': settings.LyricFont.value !== 'follow' ? settings.LyricFont.value : ''
}"
class="am-lyric"
@line-click="jumpSeek"
/>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import LyricPlayer, { type LyricPlayerRef } from '~/components/AMLL/LyricPlayer.vue'
import Icon from '~/components/UI/Icon.vue'
import { useLyricManager } from '~/composables/useLyricManager'
import { useLyricSettings } from '~/composables/useLyricSettings'
import { useAudioPlayer } from '~/composables/useAudioPlayer'
import { useAudioPlayerControl } from '~/composables/useAudioPlayerControl'
import type { LyricLineMouseEvent } from '@applemusic-like-lyrics/core'
import { cloneDeep } from 'lodash-es'
const props = defineProps({
currentTime: {
type: Number,
default: 0
}
})
const lyricManager = useLyricManager()
const settings = useLyricSettings()
const audioPlayer = useAudioPlayer()
const audioPlayerControl = useAudioPlayerControl()
const lyricPlayerRef = ref<LyricPlayerRef | null>(null)
const lyricLines = computed(() => {
// 使用 cloneDeep 剥离 Vue 响应式代理,防止 structuredClone 错误
return cloneDeep(lyricManager.lyrics.value)
})
// 进度跳转
const jumpSeek = (line: LyricLineMouseEvent) => {
// 移除焦点,防止组件内部焦点导致键盘事件异常
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
const lineContent = line.line.getLine()
if (!lineContent?.startTime) return
const time = lineContent.startTime
const offsetMs = settings.lyricOffset.value
const targetTime = (time - offsetMs) / 1000
audioPlayerControl.seekAndPlay(targetTime)
}
</script>
<style scoped>
.lyric-am {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
isolation: isolate;
}
.am-lyric {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
padding-left: var(--amll-lyric-left-padding, 10px);
padding-right: 80px;
}
/* 移动端适配 */
@media (max-width: 990px) {
.am-lyric {
padding: 0;
margin-left: 0;
}
}
.lyric-am.align-right .am-lyric {
padding-left: 80px;
padding-right: var(--amll-lyric-right-padding, 10px);
}
@media (max-width: 990px) {
.lyric-am.align-right .am-lyric {
padding: 0;
margin-right: -20px;
}
}
/* 高亮效果 */
:deep(.am-lyric .current),
:deep(.am-lyric .is-current),
:deep(.am-lyric .active),
:deep(.am-lyric .is-active),
:deep(.am-lyric .lyric-line.current),
:deep(.am-lyric .lyric-line.is-current) {
mix-blend-mode: plus-lighter;
color: rgba(255, 255, 255, 0.95);
text-shadow: 0 2px 12px rgba(255, 255, 255, 0.06);
will-change: transform, opacity, color;
}
@supports not (mix-blend-mode: plus-lighter) {
:deep(.am-lyric .current),
:deep(.am-lyric .is-current),
:deep(.am-lyric .active),
:deep(.am-lyric .is-active),
:deep(.am-lyric .lyric-line.current),
:deep(.am-lyric .lyric-line.is-current) {
color: #ffffff;
text-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
}
}
:deep(.am-lyric div[class*='lyricMainLine'] span) {
text-align: start;
}
.lyric-message-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--amll-lp-color, #efefef);
gap: 16px;
opacity: 0.8;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
.message-text {
font-size: 18px;
font-weight: 500;
letter-spacing: 0.5px;
opacity: 0.8;
}
.spin-animation {
animation: spin 1s linear infinite;
opacity: 0.9;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,829 @@
<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: #fff;
}
</style>