diff --git a/web/src/features/lyrics/lrc.test.ts b/web/src/features/lyrics/lrc.test.ts index b5b7b2e..c681c2e 100644 --- a/web/src/features/lyrics/lrc.test.ts +++ b/web/src/features/lyrics/lrc.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildLines, lineIndexAt, parseLrc, parseYrc, withTranslations, wordEmphasis, wordProgress } from './lrc' +import { buildLines, lineIndexAt, parseLrc, parseYrc, withInterludes, withTranslations, wordEmphasis, wordProgress } from './lrc' describe('parseLrc', () => { it('解析分、秒和不同精度的小数部分', () => { @@ -172,6 +172,51 @@ describe('withTranslations', () => { }) }) +describe('withInterludes', () => { + const line = (timeMs: number, text: string) => ({ timeMs, text }) + + it('7 秒以上的间奏平分成三个点的时间', () => { + const [, gap] = withInterludes([line(1000, '一'), line(2000, ''), line(11_000, '二')]) + expect(gap.dots).toEqual([ + { startMs: 2000, durationMs: 3000, text: '' }, + { startMs: 5000, durationMs: 3000, text: '' }, + { startMs: 8000, durationMs: 3000, text: '' }, + ]) + }) + + it('不足 7 秒的间奏和结尾的空白行没有点', () => { + const lines = withInterludes([line(1000, '一'), line(2000, ''), line(8999, '二'), line(20_000, '')]) + expect(lines.filter(({ dots }) => dots)).toEqual([]) + }) + + it('两句歌词之间没有空白行时没有点', () => { + expect(withInterludes([line(1000, '一'), line(20_000, '二')])).toEqual([line(1000, '一'), line(20_000, '二')]) + }) + + it('连续的空白行只有紧挨下一句的一行有点', () => { + const lines = withInterludes([line(9000, ''), line(20_000, '一')]) + expect(lines.map(({ timeMs, dots }) => [timeMs, dots?.length])).toEqual([[0, undefined], [9000, 3], [20_000, undefined]]) + }) + + it('恰好 7 秒的间奏有点', () => { + const lines = withInterludes([line(1000, '一'), line(2000, ''), line(9000, '二')]) + expect(lines[1].dots).toHaveLength(3) + }) + + it('前奏足够长时从歌曲开头补一段,太短或已有开头的空白行时不补', () => { + const [intro] = withInterludes([line(7000, '一')]) + expect(intro).toMatchObject({ timeMs: 0, text: '' }) + expect(intro.dots?.at(-1)).toEqual({ startMs: 14_000 / 3, durationMs: 7000 / 3, text: '' }) + expect(withInterludes([line(6999, '一')])).toEqual([line(6999, '一')]) + expect(withInterludes([line(0, ''), line(9000, '一')])).toHaveLength(2) + }) + + it('没有歌词时不补前奏', () => { + expect(withInterludes([])).toEqual([]) + expect(withInterludes([line(9000, '')])).toEqual([line(9000, '')]) + }) +}) + describe('buildLines', () => { it('有逐字歌词时用逐字歌词的行,翻译配到这些行上', () => { const lines = buildLines('[00:01.00]行级歌词', '[1000,500](1000,500,0)逐(1250,250,0)字', '[ti:标题]\n[00:01.00]Word by word') @@ -183,6 +228,11 @@ describe('buildLines', () => { expect(buildLines('[00:01.00]行级歌词')).toEqual([{ timeMs: 1000, text: '行级歌词' }]) }) + it('行级歌词的长前奏和长间奏也有点', () => { + const lines = buildLines('[00:10.00]一\n[00:11.00]\n[00:20.00]二') + expect(lines.filter(({ dots }) => dots).map(({ timeMs }) => timeMs)).toEqual([0, 11_000]) + }) + it('逐字歌词解析不出内容时退回行级歌词', () => { expect(buildLines('[00:01.00]行级歌词', '{"t":0}')).toEqual([{ timeMs: 1000, text: '行级歌词' }]) }) diff --git a/web/src/features/lyrics/lrc.ts b/web/src/features/lyrics/lrc.ts index dec314b..05f2255 100644 --- a/web/src/features/lyrics/lrc.ts +++ b/web/src/features/lyrics/lrc.ts @@ -9,6 +9,7 @@ export interface LyricLine { text: string words?: LyricWord[] translation?: string + dots?: LyricWord[] } const timeTag = /\[(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?\]/g @@ -17,6 +18,8 @@ const yrcWordTag = /\((\d+),(\d+),\d+\)/g // 逐字歌词没有空白行,要靠行尾和下一行的间隔判断间奏。间隔太短就算作换气:清除高亮再点亮会闪烁。 const interludeGapMs = 3000 const maxTranslationGapMs = 500 +const minInterludeMs = 7000 +const interludeDots = 3 // 字亮起和声音同时发生时,看起来反而慢半拍。 export const lyricLeadMs = 100 @@ -93,9 +96,22 @@ export function withTranslations(lines: LyricLine[], translations: LyricLine[]): }) } +// 长间奏用点倒计时:每个点是一个不带文字的字,绘制时和逐字歌词走同一条路径。 +export function withInterludes(lines: LyricLine[]): LyricLine[] { + const first = lines.find(line => line.text) + const withIntro = first && lines[0].timeMs > 0 && first.timeMs >= minInterludeMs ? [{ timeMs: 0, text: '' }, ...lines] : lines + return withIntro.map((line, i) => { + const next = withIntro[i + 1] + if (line.text || !next?.text || next.timeMs - line.timeMs < minInterludeMs) + return line + const durationMs = (next.timeMs - line.timeMs) / interludeDots + return { ...line, dots: Array.from({ length: interludeDots }, (_, dot) => ({ startMs: line.timeMs + dot * durationMs, durationMs, text: '' })) } + }) +} + export function buildLines(lyric: string, words = '', translation = ''): LyricLine[] { const wordLines = parseYrc(words) - return withTranslations(wordLines.length ? wordLines : parseLrc(lyric), parseLrc(translation)) + return withInterludes(withTranslations(wordLines.length ? wordLines : parseLrc(lyric), parseLrc(translation))) } export function lineIndexAt(lines: LyricLine[], positionMs: number): number { diff --git a/web/src/features/lyrics/lyrics.css b/web/src/features/lyrics/lyrics.css index 85d857f..29f1788 100644 --- a/web/src/features/lyrics/lyrics.css +++ b/web/src/features/lyrics/lyrics.css @@ -15,9 +15,17 @@ .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; } +.lyric-interlude { display: flex; align-items: center; justify-content: center; gap: 12px; height: 56px; opacity: 0; } +.lyric-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--color-foreground); opacity: calc(.2 + var(--lyric-fill, 0) * .8); } +.lyric-interlude.active-lyric .lyric-dot { animation: lyric-breathe 3.2s ease-in-out infinite; } .lyrics-scroll:has(.empty-state) { display: grid; place-items: center; } :root[data-performance='low'] .lyric-line { transition: opacity 160ms ease; } +:root[data-performance='low'] .lyric-interlude.active-lyric .lyric-dot { animation: none; } :root[data-performance='off'] .lyrics-scroll { mask-image: none; } @media (max-width: 767px) { .listen-page { padding: 16px; } } +@keyframes lyric-breathe { + 0%, 100% { transform: scale(.9); } + 50% { transform: scale(1.2); } +} diff --git a/web/src/features/lyrics/lyrics.tsx b/web/src/features/lyrics/lyrics.tsx index 6cca75f..22193c1 100644 --- a/web/src/features/lyrics/lyrics.tsx +++ b/web/src/features/lyrics/lyrics.tsx @@ -22,7 +22,13 @@ function LyricWordText({ word }: { word: LyricWord }) { ) } -// 空白行不渲染节点:当前行为空白行时,滚动到前后两行的中间。 +// 间奏的点平时不可见,不算相邻的行。 +function neighborLine(node: Element, side: 'previousElementSibling' | 'nextElementSibling'): Element | null { + const neighbor = node[side] + return neighbor?.classList.contains('lyric-interlude') ? neighbor[side] : neighbor +} + +// 没有点的空白行不渲染节点:当前行为这种空白行时,滚动到前后两行的中间。 function centerLine(container: HTMLElement, nodes: Map, index: number, smooth: boolean): void { let before: HTMLElement | undefined let after: HTMLElement | undefined @@ -68,14 +74,14 @@ export function Lyrics() { previous?.classList.remove('active-lyric') previous?.removeAttribute('aria-current') const current = nodes.get(index) - const words = lines[index]?.words + const words = lines[index]?.words ?? lines[index]?.dots activeWords = current && words - ? Array.from(current.querySelectorAll('.lyric-word'), (node, i) => ({ node, word: words[i] })) + ? Array.from(current.querySelectorAll('.lyric-word, .lyric-dot'), (node, i) => ({ node, word: words[i] })) : [] if (current) { current.classList.add('active-lyric') current.setAttribute('aria-current', 'true') - near = [current.previousElementSibling, current.nextElementSibling].filter((node): node is Element => node !== null) + near = [neighborLine(current, 'previousElementSibling'), neighborLine(current, 'nextElementSibling')].filter((node): node is Element => node !== null) } else { let before: HTMLElement | undefined @@ -135,12 +141,18 @@ export function Lyrics() {
{lines.some(line => line.text) - ? lines.map((line, index) => line.text && ( -

- {line.words?.map(word => ) ?? line.text} - {line.translation && {line.translation}} -

- )) + ? lines.map((line, index) => line.dots + ? ( + + ) + : line.text && ( +

+ {line.words?.map(word => ) ?? line.text} + {line.translation && {line.translation}} +

+ )) : 播放带有歌词的歌曲时,会在这里同步显示。}