feat: sweep lyrics with a soft edge and emphasize held words
This commit is contained in:
1 parent
37ee162c83
commit
75c5dad4b9
4 files changed
+69
-9
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildLines, lineIndexAt, parseLrc, parseYrc, withTranslations, wordProgress } from './lrc'
|
||||
import { buildLines, lineIndexAt, parseLrc, parseYrc, withTranslations, wordEmphasis, wordProgress } from './lrc'
|
||||
|
||||
describe('parseLrc', () => {
|
||||
it('解析分、秒和不同精度的小数部分', () => {
|
||||
@@ -64,6 +64,15 @@ describe('parseYrc', () => {
|
||||
expect(lines[0].words).toEqual([{ startMs: 1000, durationMs: 500, text: '词' }])
|
||||
})
|
||||
|
||||
it('只含空格的字并入前一个字,行首的空白丢弃', () => {
|
||||
const [line] = parseYrc('[0,1500](0,500,0) (500,500,0)Hello(1000,0,0) (1000,500,0)love')
|
||||
expect(line.words).toEqual([
|
||||
{ startMs: 500, durationMs: 500, text: 'Hello ' },
|
||||
{ startMs: 1000, durationMs: 500, text: 'love' },
|
||||
])
|
||||
expect(line.text).toBe('Hello love')
|
||||
})
|
||||
|
||||
it('按行起始时间排序', () => {
|
||||
const lines = parseYrc('[2000,500](2000,500,0)二\n[1000,500](1000,500,0)一')
|
||||
expect(lines.filter(line => line.text).map(line => line.text)).toEqual(['一', '二'])
|
||||
@@ -99,6 +108,27 @@ describe('wordProgress', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('wordEmphasis', () => {
|
||||
const word = (durationMs: number, text = '爱') => ({ startMs: 0, durationMs, text })
|
||||
|
||||
it('持续不超过 1 秒的字不强调', () => {
|
||||
expect(wordEmphasis(word(400))).toBe(0)
|
||||
expect(wordEmphasis(word(1000))).toBe(0)
|
||||
})
|
||||
|
||||
it('强调的程度随时长增加,到 2 秒封顶', () => {
|
||||
expect(wordEmphasis(word(1500))).toBe(0.5)
|
||||
expect(wordEmphasis(word(2000))).toBe(1)
|
||||
expect(wordEmphasis(word(6000))).toBe(1)
|
||||
})
|
||||
|
||||
it('太长的词和空白不强调,两端的空格不算长度', () => {
|
||||
expect(wordEmphasis(word(2000, 'Forevers'))).toBe(0)
|
||||
expect(wordEmphasis(word(2000, 'Forever '))).toBe(1)
|
||||
expect(wordEmphasis(word(2000, ' '))).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('withTranslations', () => {
|
||||
const line = (timeMs: number, text: string) => ({ timeMs, text })
|
||||
|
||||
|
||||
@@ -40,11 +40,15 @@ export function parseYrc(yrc: string): LyricLine[] {
|
||||
if (!head)
|
||||
continue
|
||||
const tags = [...row.matchAll(yrcWordTag)]
|
||||
const words = tags.map((tag, i) => ({
|
||||
startMs: Number(tag[1]),
|
||||
durationMs: Number(tag[2]),
|
||||
text: row.slice(tag.index + tag[0].length, tags[i + 1]?.index),
|
||||
}))
|
||||
const words: LyricWord[] = []
|
||||
for (const [i, tag] of tags.entries()) {
|
||||
const text = row.slice(tag.index + tag[0].length, tags[i + 1]?.index)
|
||||
if (text.trim())
|
||||
words.push({ startMs: Number(tag[1]), durationMs: Number(tag[2]), text })
|
||||
// 字独立绘制:只含空格的字单独成字时,它两侧的空格不会合并,词间距会变成两倍。
|
||||
else if (words.length)
|
||||
words[words.length - 1].text += text
|
||||
}
|
||||
const text = words.map(word => word.text).join('').trim()
|
||||
const timeMs = Number(head[1])
|
||||
if (text)
|
||||
@@ -61,6 +65,12 @@ export function wordProgress({ startMs, durationMs }: LyricWord, positionMs: num
|
||||
return Math.min(Math.max((positionMs - startMs) / Math.max(durationMs, 1), 0), 1)
|
||||
}
|
||||
|
||||
// 唱得久的短词才强调:短音节一闪而过看不出来,长词放大会挤到相邻的字。
|
||||
export function wordEmphasis({ durationMs, text }: LyricWord): number {
|
||||
const { length } = text.trim()
|
||||
return durationMs > 1000 && length > 0 && length < 8 ? Math.min(durationMs, 2000) / 1000 - 1 : 0
|
||||
}
|
||||
|
||||
// 由翻译找行,而不是由行找翻译:否则没有翻译的行会借用相邻行的翻译。
|
||||
export function withTranslations(lines: LyricLine[], translations: LyricLine[]): LyricLine[] {
|
||||
const sung = lines.filter(line => line.text)
|
||||
|
||||
@@ -8,7 +8,12 @@
|
||||
.lyric-line { max-width: 880px; margin: 0 auto; padding: 8px 32px; line-height: 1.5; text-align: center; color: var(--color-foreground-subtle); opacity: .78; font-weight: 600; overflow-wrap: anywhere; transform: translateY(4px) scale(.7); transform-origin: center; transition: transform 420ms cubic-bezier(.2, .8, .2, 1), opacity 300ms ease, color 300ms ease; }
|
||||
.lyric-line.near-lyric { opacity: .9; transform: translateY(2px) scale(.8); }
|
||||
.lyric-line.active-lyric { color: var(--color-foreground); opacity: 1; transform: translateY(0) scale(1.18); }
|
||||
.lyric-line.active-lyric .lyric-word { color: transparent; background-image: linear-gradient(90deg, var(--color-foreground) calc(var(--lyric-fill, 0) * 100%), var(--color-foreground-subtle) 0); -webkit-background-clip: text; background-clip: text; }
|
||||
/* inline-block 才能位移和缩放,只有放大的字需要滤镜。 */
|
||||
.lyric-word { display: inline-block; transform-origin: 50% 80%; transition: transform 800ms cubic-bezier(.2, .8, .2, 1), filter 500ms ease; }
|
||||
/* 边缘羽化 .6em:进度为 0 时渐变带在字的左外侧,为 1 时在右外侧。 */
|
||||
.lyric-line.active-lyric .lyric-word { color: transparent; background-image: linear-gradient(90deg, var(--color-foreground) calc(var(--lyric-fill, 0) * (100% + .6em) - .6em), color-mix(in srgb, var(--color-foreground) 35%, transparent) calc(var(--lyric-fill, 0) * (100% + .6em))); -webkit-background-clip: text; background-clip: text; }
|
||||
.lyric-line.active-lyric .lyric-word.lyric-sung { transform: translateY(-.05em); }
|
||||
.lyric-line.active-lyric .lyric-word.lyric-held { transform: translateY(-.1em) scale(calc(1 + var(--lyric-emphasis) * .14)); filter: drop-shadow(0 0 .12em color-mix(in srgb, var(--color-foreground) calc(var(--lyric-emphasis) * 40%), transparent)); }
|
||||
.lyric-translation { display: block; margin-top: 2px; color: var(--color-foreground-subtle); font-size: .62em; font-weight: 500; }
|
||||
.lyrics-scroll:has(.empty-state) { display: grid; place-items: center; }
|
||||
:root[data-performance='low'] .lyric-line { transition: opacity 160ms ease; }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { LyricWord } from './lrc'
|
||||
import { AlignLeft } from 'lucide-react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
@@ -5,10 +6,22 @@ import { playerStore } from '@/features/playback'
|
||||
import { performanceStore } from '@/features/settings'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
import { EmptyState } from '@/shared/ui'
|
||||
import { wordProgress } from './lrc'
|
||||
import { wordEmphasis, wordProgress } from './lrc'
|
||||
import { lyricsStore } from './lyrics-store'
|
||||
import './lyrics.css'
|
||||
|
||||
// 字是 inline-block 才能位移和缩放,而 inline-block 会折叠首尾的空格,所以空格放在字的外面。
|
||||
function LyricWordText({ word }: { word: LyricWord }) {
|
||||
const emphasis = wordEmphasis(word)
|
||||
return (
|
||||
<>
|
||||
{/^\s/.test(word.text) && ' '}
|
||||
<span className="lyric-word" style={emphasis ? { '--lyric-emphasis': emphasis } as CSSProperties : undefined}>{word.text.trim()}</span>
|
||||
{/\s$/.test(word.text) && ' '}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 空白行不渲染节点:当前行为空白行时,滚动到前后两行的中间。
|
||||
function centerLine(container: HTMLElement, nodes: Map<number, HTMLElement>, index: number, smooth: boolean): void {
|
||||
let before: HTMLElement | undefined
|
||||
@@ -91,6 +104,8 @@ export function Lyrics() {
|
||||
if (fill !== item.fill) {
|
||||
item.fill = fill
|
||||
item.node.style.setProperty('--lyric-fill', String(fill))
|
||||
item.node.classList.toggle('lyric-sung', smooth && fill > 0)
|
||||
item.node.classList.toggle('lyric-held', smooth && fill > 0 && fill < 1 && wordEmphasis(item.word) > 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,7 +137,7 @@ export function Lyrics() {
|
||||
{lines.some(line => line.text)
|
||||
? lines.map((line, index) => line.text && (
|
||||
<p className="lyric-line text-ui-xl" data-lyric-index={index} key={`${line.timeMs}:${line.text}`}>
|
||||
{line.words?.map(word => <span className="lyric-word" key={`${word.startMs}:${word.text}`}>{word.text}</span>) ?? line.text}
|
||||
{line.words?.map(word => <LyricWordText key={`${word.startMs}:${word.text}`} word={word} />) ?? line.text}
|
||||
{line.translation && <span className="lyric-translation">{line.translation}</span>}
|
||||
</p>
|
||||
))
|
||||
|
||||
Reference in new issue
Block a user