From 7ed663d00985c838c819e986f2d360d960e81a44 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 00:46:41 +0800 Subject: [PATCH 01/54] feat(i18n): add strings for enhanced line effect and empty line symbol option --- src/i18n/resources/en.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index 85638a0f6d..628c5366cb 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -828,6 +828,10 @@ "line-effect": { "label": "Line effect", "submenu": { + "enhanced": { + "label": "Enhanced", + "tooltip": "A refined lyric effect for smoother, more enjoyable reading." + }, "fancy": { "label": "Fancy", "tooltip": "Use large, app-like effects on the current line" @@ -847,6 +851,10 @@ }, "tooltip": "Choose the effect to apply to the current line" }, + "show-empty-line-symbols": { + "label": "Show character between lyrics", + "tooltip": "Choose whether to always display the character between empty lyric lines." + }, "precise-timing": { "label": "Make the lyrics perfectly synced", "tooltip": "Calculate to the milisecond the display of the next line (can have a small impact on performance)" From 7e35f88bcfde91850673b82144ad8fe0e7ef6d27 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 00:50:57 +0800 Subject: [PATCH 02/54] feat(synced-lyrics): update defaults and add author --- src/plugins/synced-lyrics/index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/plugins/synced-lyrics/index.ts b/src/plugins/synced-lyrics/index.ts index 533f05bfb1..b5fb3e623d 100644 --- a/src/plugins/synced-lyrics/index.ts +++ b/src/plugins/synced-lyrics/index.ts @@ -11,7 +11,7 @@ import type { SyncedLyricsPluginConfig } from './types'; export default createPlugin({ name: () => t('plugins.synced-lyrics.name'), description: () => t('plugins.synced-lyrics.description'), - authors: ['Non0reo', 'ArjixWasTaken', 'KimJammer', 'Strvm'], + authors: ['Non0reo', 'ArjixWasTaken', 'KimJammer', 'Strvm', 'robroid'], restartNeeded: true, addedVersion: '3.5.X', config: { @@ -19,8 +19,9 @@ export default createPlugin({ preciseTiming: true, showLyricsEvenIfInexact: true, showTimeCodes: false, - defaultTextString: '♪', - lineEffect: 'fancy', + defaultTextString: '•••', + lineEffect: 'enhanced', + showEmptyLineSymbols: false, romanization: true, } satisfies SyncedLyricsPluginConfig as SyncedLyricsPluginConfig, From 9d9e1f2c632547051a395e0b00094103b7b06900 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 00:54:14 +0800 Subject: [PATCH 03/54] feat(synced-lyrics): add enhanced line effect option and toggle for empty line symbols --- src/plugins/synced-lyrics/menu.ts | 87 +++++++++++++++++++++---------- 1 file changed, 60 insertions(+), 27 deletions(-) diff --git a/src/plugins/synced-lyrics/menu.ts b/src/plugins/synced-lyrics/menu.ts index d1b9e12f41..123b3d4a33 100644 --- a/src/plugins/synced-lyrics/menu.ts +++ b/src/plugins/synced-lyrics/menu.ts @@ -41,22 +41,26 @@ export const menu = async ( ), ], }, - { - label: t('plugins.synced-lyrics.menu.precise-timing.label'), - toolTip: t('plugins.synced-lyrics.menu.precise-timing.tooltip'), - type: 'checkbox', - checked: config.preciseTiming, - click(item) { - ctx.setConfig({ - preciseTiming: item.checked, - }); - }, - }, { label: t('plugins.synced-lyrics.menu.line-effect.label'), toolTip: t('plugins.synced-lyrics.menu.line-effect.tooltip'), type: 'submenu', submenu: [ + { + label: t( + 'plugins.synced-lyrics.menu.line-effect.submenu.enhanced.label', + ), + toolTip: t( + 'plugins.synced-lyrics.menu.line-effect.submenu.enhanced.tooltip', + ), + type: 'radio', + checked: config.lineEffect === 'enhanced', + click() { + ctx.setConfig({ + lineEffect: 'enhanced', + }); + }, + }, { label: t( 'plugins.synced-lyrics.menu.line-effect.submenu.fancy.label', @@ -124,23 +128,52 @@ export const menu = async ( toolTip: t('plugins.synced-lyrics.menu.default-text-string.tooltip'), type: 'submenu', submenu: [ - { label: '♪', value: '♪' }, - { label: '" "', value: ' ' }, - { label: '...', value: ['.', '..', '...'] }, - { label: '•••', value: ['•', '••', '•••'] }, - { label: '———', value: '———' }, - ].map(({ label, value }) => ({ - label, - type: 'radio', - checked: - typeof value === 'string' - ? config.defaultTextString === value - : JSON.stringify(config.defaultTextString) === - JSON.stringify(value), - click() { - ctx.setConfig({ defaultTextString: value }); + ...[ + { label: '•••', value: ['•', '•', '•'] }, + { label: '...', value: ['.', '.', '.'] }, + { label: '♪', value: '♪' }, + { label: '———', value: '———' }, + { label: '(𝑏𝑙𝑎𝑛𝑘)', value: '\u00A0' }, + ].map( + ({ label, value }) => + ({ + label, + type: 'radio', + checked: + JSON.stringify(config.defaultTextString) === + JSON.stringify(value), + enabled: config.showEmptyLineSymbols, + click() { + ctx.setConfig({ defaultTextString: value }); + }, + }) as const, + ), + { type: 'separator' }, + { + label: t('plugins.synced-lyrics.menu.show-empty-line-symbols.label'), + toolTip: t( + 'plugins.synced-lyrics.menu.show-empty-line-symbols.tooltip', + ), + type: 'checkbox', + checked: config.showEmptyLineSymbols ?? false, + click(item) { + ctx.setConfig({ + showEmptyLineSymbols: item.checked, + }); + }, }, - })), + ], + }, + { + label: t('plugins.synced-lyrics.menu.precise-timing.label'), + toolTip: t('plugins.synced-lyrics.menu.precise-timing.tooltip'), + type: 'checkbox', + checked: config.preciseTiming, + click(item) { + ctx.setConfig({ + preciseTiming: item.checked, + }); + }, }, { label: t('plugins.synced-lyrics.menu.romanization.label'), From 3107ee2f45e61441172765d84341cb6dfe691a06 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 00:55:34 +0800 Subject: [PATCH 04/54] style(synced-lyrics): refine font family and improve empty line & fade effects --- src/plugins/synced-lyrics/style.css | 42 ++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/src/plugins/synced-lyrics/style.css b/src/plugins/synced-lyrics/style.css index 19154b4468..d3fac7be42 100644 --- a/src/plugins/synced-lyrics/style.css +++ b/src/plugins/synced-lyrics/style.css @@ -27,9 +27,7 @@ --lyrics-padding: 0; /* Typography */ - --lyrics-font-family: Satoshi, Avenir, -apple-system, BlinkMacSystemFont, - Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue, - sans-serif; + --lyrics-font-family: "Satoshi", sans-serif !important; --lyrics-font-size: clamp(1.4rem, 1.1vmax, 3rem); --lyrics-line-height: var(--ytmusic-body-line-height); --lyrics-width: 100%; @@ -137,6 +135,11 @@ transition: opacity var(--lyrics-opacity-transition); } +.text-lyrics .placeholder { + opacity: var(--lyrics-inactive-opacity); + transition: opacity var(--lyrics-opacity-transition); +} + .current .text-lyrics { font-weight: var(--lyrics-active-font-weight) !important; scale: var(--lyrics-active-scale); @@ -148,6 +151,21 @@ animation: var(--lyrics-animations); } +.synced-line.final-empty .text-lyrics { + color: transparent; + padding-top: 0 !important; + padding-bottom: 0 !important; + height: 1em; + display: block; +} + +.synced-line.no-padding .text-lyrics { + padding-top: 0 !important; + padding-bottom: 0 !important; + height: 0.3em; + overflow: hidden; +} + .lyrics-renderer { display: flex; flex-direction: column; @@ -232,6 +250,24 @@ div:has(> .lyrics-picker) { } } +.fade { + opacity: 0; + transition: var(--lyrics-opacity-transition) ease-in-out; +} + +.fade.show { + opacity: var(--lyrics-active-opacity); +} + +.fade.dim { + opacity: var(--lyrics-inactive-opacity); +} + +.fade, +.placeholder { + animation-name: none; +} + /* Animations */ @keyframes lyrics-wobble { from { From bd2c03027443521e46ad067d1138d962c7950912 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 00:55:58 +0800 Subject: [PATCH 05/54] feat(synced-lyrics): add showEmptyLineSymbols config and enhanced line effect type --- src/plugins/synced-lyrics/types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/plugins/synced-lyrics/types.ts b/src/plugins/synced-lyrics/types.ts index dab1edf9ba..db09b0693c 100644 --- a/src/plugins/synced-lyrics/types.ts +++ b/src/plugins/synced-lyrics/types.ts @@ -9,6 +9,7 @@ export type SyncedLyricsPluginConfig = { defaultTextString: string | string[]; showLyricsEvenIfInexact: boolean; lineEffect: LineEffect; + showEmptyLineSymbols: boolean; romanization: boolean; }; @@ -23,7 +24,7 @@ export type LineLyrics = { status: LineLyricsStatus; }; -export type LineEffect = 'fancy' | 'scale' | 'offset' | 'focus'; +export type LineEffect = 'enhanced' | 'fancy' | 'scale' | 'offset' | 'focus'; export interface LyricResult { title: string; From 07eb2ade174b015abf3314255f17a0b24422ef0c Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 01:00:22 +0800 Subject: [PATCH 06/54] fix(synced-lyrics): improve LRC parser to handle variable millisecond precision --- src/plugins/synced-lyrics/parsers/lrc.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/plugins/synced-lyrics/parsers/lrc.ts b/src/plugins/synced-lyrics/parsers/lrc.ts index 355c0a4d5c..598b46b081 100644 --- a/src/plugins/synced-lyrics/parsers/lrc.ts +++ b/src/plugins/synced-lyrics/parsers/lrc.ts @@ -17,7 +17,7 @@ interface LRC { const tagRegex = /^\[(?\w+):\s*(?.+?)\s*\]$/; // prettier-ignore -const lyricRegex = /^\[(?\d+):(?\d+)\.(?\d+)\](?.+)$/; +const lyricRegex = /^\[(?\d+):(?\d+)\.(?\d{1,3})\](?.*)$/; export const LRC = { parse: (text: string): LRC => { @@ -50,13 +50,18 @@ export const LRC = { } const { minutes, seconds, milliseconds, text } = lyric; + + // Normalize: take first 2 digits, pad if only 1 digit + const ms2 = milliseconds.padEnd(2, '0').slice(0, 2); + + // Convert to ms (xx → xx0) const timeInMs = parseInt(minutes) * 60 * 1000 + parseInt(seconds) * 1000 + - parseInt(milliseconds); + parseInt(ms2) * 10; const currentLine: LRCLine = { - time: `${minutes}:${seconds}:${milliseconds}`, + time: `${minutes.padStart(2, '0')}:${seconds.padStart(2, '0')}.${ms2}`, timeInMs, text: text.trim(), duration: Infinity, From e54505a0ca28136b1d401f1ec6bb48cd0a1c174c Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 01:04:08 +0800 Subject: [PATCH 07/54] fix(synced-lyrics): append trailing empty line in synced lyrics when missing --- src/plugins/synced-lyrics/providers/LRCLib.ts | 71 +++++++++++++++---- 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/src/plugins/synced-lyrics/providers/LRCLib.ts b/src/plugins/synced-lyrics/providers/LRCLib.ts index cfdea3ab48..6ffba991b1 100644 --- a/src/plugins/synced-lyrics/providers/LRCLib.ts +++ b/src/plugins/synced-lyrics/providers/LRCLib.ts @@ -157,21 +157,66 @@ export class LRCLib implements LyricProvider { const raw = closestResult.syncedLyrics; const plain = closestResult.plainLyrics; - if (!raw && !plain) { - return null; + + if (raw) { + // Prefer synced + const parsed = LRC.parse(raw).lines.map((l) => ({ + ...l, + status: 'upcoming' as const, + })); + + // If the final parsed line is not empty, append a computed empty line + if (parsed.length > 0) { + const last = parsed[parsed.length - 1]; + const lastIsEmpty = !last.text || !last.text.trim(); + if (lastIsEmpty) { + // last line already empty, don't append another + } else { + // If duration is infinity (no following line), treat end as start for midpoint calculation + const lastEndCandidate = Number.isFinite(last.duration) + ? last.timeInMs + last.duration + : last.timeInMs; + const songEnd = songDuration * 1000; + + if (lastEndCandidate < songEnd) { + const midpoint = Math.floor((lastEndCandidate + songEnd) / 2); + + // update last duration to end at midpoint + last.duration = midpoint - last.timeInMs; + + const minutes = Math.floor(midpoint / 60000); + const seconds = Math.floor((midpoint % 60000) / 1000); + const centiseconds = Math.floor((midpoint % 1000) / 10); + const timeStr = `${minutes.toString().padStart(2, '0')}:${seconds + .toString() + .padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`; + + parsed.push({ + timeInMs: midpoint, + time: timeStr, + duration: songEnd - midpoint, + text: '', + status: 'upcoming' as const, + }); + } + } + } + + return { + title: closestResult.trackName, + artists: closestResult.artistName.split(/[&,]/g), + lines: parsed, + }; + } else if (plain) { + // Fallback to plain if no synced + return { + title: closestResult.trackName, + artists: closestResult.artistName.split(/[&,]/g), + lyrics: plain, + }; } - return { - title: closestResult.trackName, - artists: closestResult.artistName.split(/[&,]/g), - lines: raw - ? LRC.parse(raw).lines.map((l) => ({ - ...l, - status: 'upcoming' as const, - })) - : undefined, - lyrics: plain, - }; + return null; } } From e09bf9995a7f82204f8b6ca337c8a522f8852064 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 01:05:14 +0800 Subject: [PATCH 08/54] fix(synced-lyrics): ensure final empty line is added to Genius lyrics for padding --- src/plugins/synced-lyrics/providers/LyricsGenius.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/plugins/synced-lyrics/providers/LyricsGenius.ts b/src/plugins/synced-lyrics/providers/LyricsGenius.ts index 0ebed6285d..e24aaf516d 100644 --- a/src/plugins/synced-lyrics/providers/LyricsGenius.ts +++ b/src/plugins/synced-lyrics/providers/LyricsGenius.ts @@ -89,10 +89,16 @@ export class LyricsGenius implements LyricProvider { return null; } + // final empty line for padding. + let finalLyrics = lyrics; + if (!finalLyrics.endsWith('\n\n')) { + finalLyrics = finalLyrics.endsWith('\n') ? finalLyrics + '\n' : finalLyrics + '\n\n'; + } + return { title: closestHit.result.title, artists: closestHit.result.primary_artists.map(({ name }) => name), - lyrics, + lyrics: finalLyrics, }; } } From fc1ffc63e24f0296c408955d4e75322a2fbb08a9 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 01:06:17 +0800 Subject: [PATCH 09/54] fix(synced-lyrics): merge consecutive empty lines in MusixMatch lyrics --- .../synced-lyrics/providers/MusixMatch.ts | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/plugins/synced-lyrics/providers/MusixMatch.ts b/src/plugins/synced-lyrics/providers/MusixMatch.ts index 275c13329c..a0b6441784 100644 --- a/src/plugins/synced-lyrics/providers/MusixMatch.ts +++ b/src/plugins/synced-lyrics/providers/MusixMatch.ts @@ -42,10 +42,32 @@ export class MusixMatch implements LyricProvider { title: track.track_name, artists: [track.artist_name], lines: subtitle - ? LRC.parse(subtitle.subtitle.subtitle_body).lines.map((l) => ({ - ...l, - status: 'upcoming' as const, - })) + ? (() => { + const parsed = LRC.parse(subtitle.subtitle.subtitle_body).lines.map( + (l) => ({ ...l, status: 'upcoming' as const }), + ); + + // Merge consecutive empty lines into a single empty line + const merged: typeof parsed = []; + for (const line of parsed) { + const isEmpty = !line.text || !line.text.trim(); + if (isEmpty && merged.length > 0) { + const prev = merged[merged.length - 1]; + const prevEmpty = !prev.text || !prev.text.trim(); + if (prevEmpty) { + // extend previous duration to cover this line + const prevEnd = prev.timeInMs + prev.duration; + const thisEnd = line.timeInMs + line.duration; + const newEnd = Math.max(prevEnd, thisEnd); + prev.duration = newEnd - prev.timeInMs; + continue; // skip adding this line + } + } + merged.push(line); + } + + return merged; + })() : undefined, lyrics: lyrics, }; From 9bf0fe8ec1a84d938a78bb5ac56540f8596b92f9 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 01:09:52 +0800 Subject: [PATCH 10/54] fix(synced-lyrics): ensure final empty line exists in YTMusic synced lyrics --- .../synced-lyrics/providers/YTMusic.ts | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/plugins/synced-lyrics/providers/YTMusic.ts b/src/plugins/synced-lyrics/providers/YTMusic.ts index a655289a7f..24926b1ed3 100644 --- a/src/plugins/synced-lyrics/providers/YTMusic.ts +++ b/src/plugins/synced-lyrics/providers/YTMusic.ts @@ -51,13 +51,14 @@ export class YTMusic implements LyricProvider { const synced = syncedLines?.length && syncedLines[0]?.cueRange ? syncedLines.map((it) => ({ - time: this.millisToTime(parseInt(it.cueRange.startTimeMilliseconds)), - timeInMs: parseInt(it.cueRange.startTimeMilliseconds), - duration: parseInt(it.cueRange.endTimeMilliseconds) - - parseInt(it.cueRange.startTimeMilliseconds), - text: it.lyricLine.trim() === '♪' ? '' : it.lyricLine.trim(), - status: 'upcoming' as const, - })) + time: this.millisToTime(parseInt(it.cueRange.startTimeMilliseconds)), + timeInMs: parseInt(it.cueRange.startTimeMilliseconds), + duration: + parseInt(it.cueRange.endTimeMilliseconds) - + parseInt(it.cueRange.startTimeMilliseconds), + text: it.lyricLine.trim() === '♪' ? '' : it.lyricLine.trim(), + status: 'upcoming' as const, + })) : undefined; const plain = !synced @@ -66,9 +67,9 @@ export class YTMusic implements LyricProvider { : contents?.messageRenderer ? contents?.messageRenderer?.text?.runs?.map((it) => it.text).join('\n') : contents?.sectionListRenderer?.contents?.[0] - ?.musicDescriptionShelfRenderer?.description?.runs?.map((it) => - it.text - )?.join('\n') + ?.musicDescriptionShelfRenderer?.description?.runs + ?.map((it) => it.text) + ?.join('\n') : undefined; if (typeof plain === 'string' && plain === 'Lyrics not available') { @@ -85,6 +86,23 @@ export class YTMusic implements LyricProvider { }); } + // ensure a final empty line exists + if (synced?.length) { + const last = synced[synced.length - 1]; + const lastEnd = parseInt(last.timeInMs.toString()) + last.duration; + + // youtube sometimes omits trailing silence, add our own + if (last.text !== '') { + synced.push({ + duration: 0, + text: '', + time: this.millisToTime(lastEnd), + timeInMs: lastEnd, + status: 'upcoming' as const, + }); + } + } + return { title, artists: [artist], From db660af483d884f2bfd9332ea3df3214f1e917f3 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 01:16:26 +0800 Subject: [PATCH 11/54] feat(synced-lyrics): support enhanced effect, precise timing, and final empty line handling --- .../synced-lyrics/renderer/renderer.tsx | 80 ++++++++++++++++--- 1 file changed, 70 insertions(+), 10 deletions(-) diff --git a/src/plugins/synced-lyrics/renderer/renderer.tsx b/src/plugins/synced-lyrics/renderer/renderer.tsx index 24c3d70102..be1eb1284e 100644 --- a/src/plugins/synced-lyrics/renderer/renderer.tsx +++ b/src/plugins/synced-lyrics/renderer/renderer.tsx @@ -10,7 +10,7 @@ import { type VirtualizerHandle, VList } from 'virtua/solid'; import { LyricsPicker } from './components/LyricsPicker'; -import { selectors } from './utils'; +import { selectors, getSeekTime, SFont } from './utils'; import { ErrorDisplay, @@ -34,6 +34,27 @@ createEffect(() => { // Set the line effect switch (config()?.lineEffect) { + case 'enhanced': + root.style.setProperty('--lyrics-font-size', '3rem'); + root.style.setProperty('--lyrics-line-height', '1.333'); + root.style.setProperty('--lyrics-width', '100%'); + root.style.setProperty('--lyrics-padding', '12.5px'); + + root.style.setProperty( + '--lyrics-animations', + 'lyrics-glow var(--lyrics-glow-duration) forwards, lyrics-wobble var(--lyrics-wobble-duration) forwards', + ); + + root.style.setProperty('--lyrics-inactive-font-weight', '700'); + root.style.setProperty('--lyrics-inactive-opacity', '0.33'); + root.style.setProperty('--lyrics-inactive-scale', '0.95'); + root.style.setProperty('--lyrics-inactive-offset', '0'); + + root.style.setProperty('--lyrics-active-font-weight', '700'); + root.style.setProperty('--lyrics-active-opacity', '1'); + root.style.setProperty('--lyrics-active-scale', '1'); + root.style.setProperty('--lyrics-active-offset', '0'); + break; case 'fancy': root.style.setProperty('--lyrics-font-size', '3rem'); root.style.setProperty('--lyrics-line-height', '1.333'); @@ -174,6 +195,7 @@ export const LyricsRenderer = () => { }; onMount(() => { + SFont(); const vList = document.querySelector('.synced-lyrics-vlist'); tab.addEventListener('mousemove', mousemoveListener); @@ -190,6 +212,9 @@ export const LyricsRenderer = () => { const [children, setChildren] = createSignal([ { kind: 'LoadingKaomoji' }, ]); + const [firstEmptyIndex, setFirstEmptyIndex] = createSignal( + null, + ); createEffect(() => { const current = currentLyrics(); @@ -210,20 +235,36 @@ export const LyricsRenderer = () => { } if (data?.lines) { - return data.lines.map((line) => ({ + const lines = data.lines; + const firstEmpty = lines.findIndex((l) => !l.text?.trim()); + setFirstEmptyIndex(firstEmpty === -1 ? null : firstEmpty); + + return lines.map((line) => ({ kind: 'SyncedLine' as const, line, })); } if (data?.lyrics) { - const lines = data.lyrics.split('\n').filter((line) => line.trim()); + const rawLines = data.lyrics.split('\n'); + + // Preserve a single trailing empty line if provided by the provider + const hasTrailingEmpty = + rawLines.length > 0 && rawLines[rawLines.length - 1].trim() === ''; + + const lines = rawLines.filter((line, idx) => { + if (line.trim()) return true; + // keep only the final empty line (for padding) if it exists + return hasTrailingEmpty && idx === rawLines.length - 1; + }); + return lines.map((line) => ({ kind: 'PlainLine' as const, line, })); } + setFirstEmptyIndex(null); return [{ kind: 'NotFoundKaomoji' }]; }); }); @@ -232,23 +273,37 @@ export const LyricsRenderer = () => { ('previous' | 'current' | 'upcoming')[] >([]); createEffect(() => { - const time = currentTime(); + const precise = config()?.preciseTiming ?? false; const data = currentLyrics()?.data; + const currentTimeMs = currentTime(); - if (!data || !data.lines) return setStatuses([]); + if (!data || !data.lines) { + setStatuses([]); + return; + } const previous = untrack(statuses); + const current = data.lines.map((line) => { - if (line.timeInMs >= time) return 'upcoming'; - if (time - line.timeInMs >= line.duration) return 'previous'; + const startTimeMs = getSeekTime(line.timeInMs, precise) * 1000; + const endTimeMs = + getSeekTime(line.timeInMs + line.duration, precise) * 1000; + + if (currentTimeMs < startTimeMs) return 'upcoming'; + if (currentTimeMs >= endTimeMs) return 'previous'; return 'current'; }); - if (previous.length !== current.length) return setStatuses(current); - if (previous.every((status, idx) => status === current[idx])) return; + if (previous.length !== current.length) { + setStatuses(current); + return; + } + + if (previous.every((status, idx) => status === current[idx])) { + return; + } setStatuses(current); - return; }); const [currentIndex, setCurrentIndex] = createSignal(0); @@ -302,6 +357,11 @@ export const LyricsRenderer = () => { From 6c84a3b9152e5946928af2563c184188ed721351 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 01:17:21 +0800 Subject: [PATCH 12/54] feat(synced-lyrics): add getSeekTime and SFont utils --- src/plugins/synced-lyrics/renderer/utils.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/plugins/synced-lyrics/renderer/utils.tsx b/src/plugins/synced-lyrics/renderer/utils.tsx index 1c6a410bd2..2470c182a6 100644 --- a/src/plugins/synced-lyrics/renderer/utils.tsx +++ b/src/plugins/synced-lyrics/renderer/utils.tsx @@ -213,3 +213,16 @@ export const romanize = async (line: string) => { return line; }; + +// timeInMs to seek time in seconds (precise or rounded to nearest second for preciseTiming) +export const getSeekTime = (timeInMs: number, precise: boolean) => + precise ? timeInMs / 1000 : Math.round(timeInMs / 1000); + +export const SFont = () => { + if (document.getElementById('satoshi-font-link')) return; + const link = document.createElement('link'); + link.id = 'satoshi-font-link'; + link.rel = 'stylesheet'; + link.href = 'https://api.fontshare.com/v2/css?f[]=satoshi@1&display=swap'; + document.head.appendChild(link); +}; From 082c7a46e4cafb0cd9bfd7faad2997890ae0465a Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 01:20:02 +0800 Subject: [PATCH 13/54] feat(synced-lyrics): improve SyncedLine rendering and precise timing --- .../renderer/components/SyncedLine.tsx | 148 ++++++++++++++---- 1 file changed, 115 insertions(+), 33 deletions(-) diff --git a/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx index b34f0982ea..cc75ac1a03 100644 --- a/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx +++ b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx @@ -7,7 +7,7 @@ import { type LineLyrics } from '@/plugins/synced-lyrics/types'; import { config, currentTime } from '../renderer'; import { _ytAPI } from '..'; -import { canonicalize, romanize, simplifyUnicode } from '../utils'; +import { canonicalize, romanize, simplifyUnicode, getSeekTime } from '../utils'; interface SyncedLineProps { scroller: VirtualizerHandle; @@ -15,6 +15,28 @@ interface SyncedLineProps { line: LineLyrics; status: 'upcoming' | 'current' | 'previous'; + isFinalLine?: boolean; + isFirstEmptyLine?: boolean; +} + +function formatTime(timeInMs: number, preciseTiming: boolean): string { + if (!preciseTiming) { + const totalSeconds = Math.round(timeInMs / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + return `${minutes.toString().padStart(2, '0')}:${seconds + .toString() + .padStart(2, '0')}`; + } + + const minutes = Math.floor(timeInMs / 60000); + const seconds = Math.floor((timeInMs % 60000) / 1000); + const ms = Math.floor((timeInMs % 1000) / 10); + + return `${minutes.toString().padStart(2, '0')}:${seconds + .toString() + .padStart(2, '0')}.${ms.toString().padStart(2, '0')}`; } const EmptyLine = (props: SyncedLineProps) => { @@ -26,54 +48,111 @@ const EmptyLine = (props: SyncedLineProps) => { const index = createMemo(() => { const progress = currentTime() - props.line.timeInMs; const total = props.line.duration; + const stepCount = states().length; + const precise = config()?.preciseTiming ?? false; + + if (stepCount === 1) return 0; - const percentage = Math.min(1, progress / total); - return Math.max(0, Math.floor((states().length - 1) * percentage)); + let earlyCut: number; + if (total > 3000) { + earlyCut = 1000; + } else if (total >= 1000) { + const ratio = (total - 1000) / 2000; + const addend = ratio * 500; + earlyCut = 500 + addend; + } else { + earlyCut = Math.min(total * 0.8, total - 150); + } + + const effectiveTotal = + total <= 1000 + ? total - earlyCut + : precise + ? total - earlyCut + : Math.round((total - earlyCut) / 1000) * 1000; + + if (effectiveTotal <= 0) return 0; + + const effectiveProgress = precise + ? progress + : Math.round(progress / 1000) * 1000; + const percentage = Math.min(1, effectiveProgress / effectiveTotal); + + return Math.max(0, Math.floor((stepCount - 1) * percentage)); + }); + + const shouldRenderPlaceholder = createMemo(() => { + const isEmpty = !props.line.text?.trim(); + const showEmptySymbols = config()?.showEmptyLineSymbols ?? false; + + return isEmpty + ? showEmptySymbols || props.status === 'current' + : props.status === 'current'; + }); + + const isHighlighted = createMemo(() => props.status === 'current'); + const isFinalEmpty = createMemo(() => { + return props.isFinalLine && !props.line.text?.trim(); + }); + + const shouldRemovePadding = createMemo(() => { + // remove padding only when this is the first empty line and the configured label is blank (empty string or NBSP) + if (!props.isFirstEmptyLine) return false; + const defaultText = config()?.defaultTextString ?? ''; + const first = Array.isArray(defaultText) ? defaultText[0] : defaultText; + return first === '' || first === '\u00A0'; }); return (
{ - _ytAPI?.seekTo((props.line.timeInMs + 10) / 1000); - }} + class={`synced-line ${props.status} ${isFinalEmpty() ? 'final-empty' : ''} ${shouldRemovePadding() ? 'no-padding' : ''}`} + onClick={() => + _ytAPI?.seekTo( + getSeekTime(props.line.timeInMs, config()?.preciseTiming ?? false), + ) + } >
-
- + {props.isFinalLine && !props.line.text?.trim() ? ( - - } - when={states().length > 1} - > - - + + + - + ) : ( + + {(text, i) => ( + + + + )} + + )}
@@ -98,7 +177,8 @@ export const SyncedLine = (props: SyncedLineProps) => {
{ - _ytAPI?.seekTo((props.line.timeInMs + 10) / 1000); + const precise = config()?.preciseTiming ?? false; + _ytAPI?.seekTo(getSeekTime(props.line.timeInMs, precise)); }} >
@@ -106,7 +186,9 @@ export const SyncedLine = (props: SyncedLineProps) => { text={{ runs: [ { - text: config()?.showTimeCodes ? `[${props.line.time}] ` : '', + text: config()?.showTimeCodes + ? `[${formatTime(props.line.timeInMs, config()?.preciseTiming ?? false)}] ` + : '', }, ], }} From 3e8da098ef5014e7004ea731831e6cbe7e92a78b Mon Sep 17 00:00:00 2001 From: RobRoid Date: Mon, 15 Sep 2025 03:39:19 +0800 Subject: [PATCH 14/54] refactor(synced-lyrics): use ProviderNames enum instead of hardcoded string --- .../synced-lyrics/renderer/components/LyricsPicker.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx index 6f6ac92a71..6be9f484c3 100644 --- a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx +++ b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx @@ -15,6 +15,7 @@ import * as z from 'zod'; import { type ProviderName, + ProviderNames, providerNames, ProviderNameSchema, type ProviderState, @@ -47,7 +48,9 @@ const shouldSwitchProvider = (providerData: ProviderState) => { const providerBias = (p: ProviderName) => (lyricsStore.lyrics[p].state === 'done' ? 1 : -1) + (lyricsStore.lyrics[p].data?.lines?.length ? 2 : -1) + - (lyricsStore.lyrics[p].data?.lines?.length && p === 'YTMusic' ? 1 : 0) + + (lyricsStore.lyrics[p].data?.lines?.length && p === ProviderNames.YTMusic + ? 1 + : 0) + (lyricsStore.lyrics[p].data?.lyrics ? 1 : -1); const pickBestProvider = () => { From 54751aca1fc4c0ef1d8562836e997ac548d7ff21 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Fri, 19 Sep 2025 19:54:28 +0800 Subject: [PATCH 15/54] feat(synced-lyrics): enable display of empty line symbols by default --- src/plugins/synced-lyrics/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/synced-lyrics/index.ts b/src/plugins/synced-lyrics/index.ts index b5fb3e623d..53c26afedd 100644 --- a/src/plugins/synced-lyrics/index.ts +++ b/src/plugins/synced-lyrics/index.ts @@ -21,7 +21,7 @@ export default createPlugin({ showTimeCodes: false, defaultTextString: '•••', lineEffect: 'enhanced', - showEmptyLineSymbols: false, + showEmptyLineSymbols: true, romanization: true, } satisfies SyncedLyricsPluginConfig as SyncedLyricsPluginConfig, From 01dca6243f22b4b459096214b0418ff1328c936e Mon Sep 17 00:00:00 2001 From: RobRoid Date: Fri, 19 Sep 2025 20:33:15 +0800 Subject: [PATCH 16/54] feat(synced-lyrics): enhance styling with hover effects, empty line handling, and transform improvements --- src/plugins/synced-lyrics/style.css | 139 ++++++++++++++++++---------- 1 file changed, 89 insertions(+), 50 deletions(-) diff --git a/src/plugins/synced-lyrics/style.css b/src/plugins/synced-lyrics/style.css index d3fac7be42..cee03cff88 100644 --- a/src/plugins/synced-lyrics/style.css +++ b/src/plugins/synced-lyrics/style.css @@ -27,7 +27,9 @@ --lyrics-padding: 0; /* Typography */ - --lyrics-font-family: "Satoshi", sans-serif !important; + --lyrics-font-family: "Satoshi", Avenir, -apple-system, BlinkMacSystemFont, + Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue, + sans-serif; --lyrics-font-size: clamp(1.4rem, 1.1vmax, 3rem); --lyrics-line-height: var(--ytmusic-body-line-height); --lyrics-width: 100%; @@ -50,12 +52,22 @@ --lyrics-animations: lyrics-glow var(--lyrics-glow-duration) forwards, lyrics-wobble var(--lyrics-wobble-duration) forwards; --lyrics-scale-duration: 0.166s; + --lyrics-scale-hover-duration: 0.3s; --lyrics-opacity-transition: 0.33s; --lyrics-glow-duration: var(--lyrics-duration); --lyrics-wobble-duration: calc(var(--lyrics-duration) / 2); /* Colors */ --glow-color: rgba(255, 255, 255, 0.5); + + /* Other */ + --lyrics-hover-scale: 1; + --lyrics-hover-opacity: 0.33; + --lyrics-hover-empty-opacity: 1; + + --lyrics-empty-opacity: 1; + + --lyrics-will-change: auto; } .lyric-container { @@ -68,28 +80,77 @@ text-align: left !important; } -.synced-line { +.synced-line, +.synced-emptyline { width: var(--lyrics-width, 100%); & .text-lyrics { cursor: pointer; /*fix cuted lyrics-glow and romanized j at line start */ padding-left: 1.5rem; + transform-origin: center left; + transition: transform var(--lyrics-scale-hover-duration) ease-in-out, + opacity var(--lyrics-opacity-transition) ease; + /* will-change fixes jitter but may impact performance, remove if needed */ + will-change: var(--lyrics-will-change); + + & > span > span { + transition: transform var(--lyrics-scale-hover-duration) ease-in-out, + opacity var(--lyrics-opacity-transition) ease; + } + + & > .romaji { + color: var(--ytmusic-text-secondary) !important; + font-size: calc(var(--lyrics-font-size) * 0.7) !important; + font-style: italic !important; + } } - & .text-lyrics > .romaji { - color: var(--ytmusic-text-secondary) !important; - font-size: calc(var(--lyrics-font-size) * 0.7) !important; - font-style: italic !important; + &.final-empty .text-lyrics { + padding-top: 0 !important; + padding-bottom: 0 !important; + height: 1em; + display: block; + } + + &.no-padding .text-lyrics { + padding-top: 0 !important; + padding-bottom: 0 !important; + height: 0.3em; + overflow: hidden; } } +/* Current lines */ +.synced-line.current .text-lyrics > span > span, +.synced-emptyline.current .text-lyrics > span > span { + opacity: var(--lyrics-active-opacity); + animation: var(--lyrics-animations); +} + +/* Non current empty lines */ +.synced-emptyline:not(.current) .text-lyrics { + opacity: var(--lyrics-empty-opacity); +} + +/* Hover effects for non-current lines (enhanced only) */ +.enhanced-lyrics .synced-line:not(.current):hover .text-lyrics, +.enhanced-lyrics .synced-emptyline:not(.current):hover .text-lyrics { + opacity: 1 !important; + transform: scale(var(--lyrics-hover-scale)); +} + +.enhanced-lyrics .synced-line:not(.current):hover .text-lyrics > span > span, +.enhanced-lyrics .synced-emptyline:not(.current):hover .text-lyrics > span > span { + opacity: var(--lyrics-hover-opacity, var(--lyrics-hover-empty-opacity)) !important; +} + .synced-lyrics { display: block; - justify-content: left; + /* justify-content: left; */ text-align: left; margin: 0.5rem 20px 0.5rem 0; - transition: all 0.3s ease-in-out; + transition: all 0.3s ease; } .warning-lyrics { @@ -104,10 +165,8 @@ line-height: var(--lyrics-line-height) !important; padding-top: var(--lyrics-padding); padding-bottom: var(--lyrics-padding); - scale: var(--lyrics-inactive-scale); - translate: var(--lyrics-inactive-offset); - transition: scale var(--lyrics-scale-duration), translate 0.3s ease-in-out; - + transform: scale(var(--lyrics-inactive-scale)) translate(var(--lyrics-inactive-offset)); + transition: transform var(--lyrics-scale-duration) ease-in-out; display: block; text-align: left; margin: var(--global-margin) 0; @@ -115,9 +174,9 @@ &.lrc-header { color: var(--ytmusic-color-grey5) !important; - scale: 0.9; + transform: scale(0.9); height: fit-content; - padding: 0; + /* padding: 0; */ padding-block: 0.2em; } @@ -128,22 +187,17 @@ } } -.text-lyrics > span > span { +.text-lyrics > span > span, +.text-lyrics .placeholder { display: inline-block; white-space: pre-wrap; opacity: var(--lyrics-inactive-opacity); transition: opacity var(--lyrics-opacity-transition); } -.text-lyrics .placeholder { - opacity: var(--lyrics-inactive-opacity); - transition: opacity var(--lyrics-opacity-transition); -} - .current .text-lyrics { font-weight: var(--lyrics-active-font-weight) !important; - scale: var(--lyrics-active-scale); - translate: var(--lyrics-active-offset); + transform: scale(var(--lyrics-active-scale)) translate(var(--lyrics-active-offset)); } .current .text-lyrics > span > span { @@ -151,21 +205,6 @@ animation: var(--lyrics-animations); } -.synced-line.final-empty .text-lyrics { - color: transparent; - padding-top: 0 !important; - padding-bottom: 0 !important; - height: 1em; - display: block; -} - -.synced-line.no-padding .text-lyrics { - padding-top: 0 !important; - padding-bottom: 0 !important; - height: 0.3em; - overflow: hidden; -} - .lyrics-renderer { display: flex; flex-direction: column; @@ -218,8 +257,8 @@ cursor: pointer; width: 5px; height: 5px; - margin: 0 4px 0; - border-radius: 200px; + margin: 0 4px; + border-radius: 50%; border: 1px solid #6e7c7c7f; } @@ -252,20 +291,20 @@ div:has(> .lyrics-picker) { .fade { opacity: 0; - transition: var(--lyrics-opacity-transition) ease-in-out; -} + transition: var(--lyrics-opacity-transition) ease; -.fade.show { - opacity: var(--lyrics-active-opacity); -} + &.show { + opacity: var(--lyrics-active-opacity); + } -.fade.dim { - opacity: var(--lyrics-inactive-opacity); -} + &.dim { + opacity: var(--lyrics-inactive-opacity); + } -.fade, -.placeholder { - animation-name: none; + &, + .placeholder { + animation-name: none; + } } /* Animations */ From 5ed00623a7950bee0887d8ff2f8c2917519774cd Mon Sep 17 00:00:00 2001 From: RobRoid Date: Fri, 19 Sep 2025 21:08:36 +0800 Subject: [PATCH 17/54] fix(synced-lyrics): correct millisToTime calculation using modulo for seconds and centiseconds --- src/plugins/synced-lyrics/providers/YTMusic.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/plugins/synced-lyrics/providers/YTMusic.ts b/src/plugins/synced-lyrics/providers/YTMusic.ts index 24926b1ed3..3f3db4dca3 100644 --- a/src/plugins/synced-lyrics/providers/YTMusic.ts +++ b/src/plugins/synced-lyrics/providers/YTMusic.ts @@ -114,11 +114,12 @@ export class YTMusic implements LyricProvider { private millisToTime(millis: number) { const minutes = Math.floor(millis / 60000); - const seconds = Math.floor((millis - minutes * 60 * 1000) / 1000); - const remaining = (millis - minutes * 60 * 1000 - seconds * 1000) / 10; + const seconds = Math.floor((millis % 60000) / 1000); + const centiseconds = Math.floor((millis % 1000) / 10); + return `${minutes.toString().padStart(2, '0')}:${seconds .toString() - .padStart(2, '0')}.${remaining.toString().padStart(2, '0')}`; + .padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`; } // RATE LIMITED (2 req per sec) From 6e68a3fa5bcde5f09f94f0767edd343c58789955 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Fri, 19 Sep 2025 21:09:45 +0800 Subject: [PATCH 18/54] refactor(synced-lyrics): rename EmptyLine class to synced-emptyline and simplify seekTo handler --- .../synced-lyrics/renderer/components/SyncedLine.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx index cc75ac1a03..cb34a8afa8 100644 --- a/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx +++ b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx @@ -105,12 +105,11 @@ const EmptyLine = (props: SyncedLineProps) => { return (
- _ytAPI?.seekTo( - getSeekTime(props.line.timeInMs, config()?.preciseTiming ?? false), - ) - } + class={`synced-emptyline ${props.status} ${isFinalEmpty() ? 'final-empty' : ''} ${shouldRemovePadding() ? 'no-padding' : ''}`} + onClick={() => { + const precise = config()?.preciseTiming ?? false; + _ytAPI?.seekTo(getSeekTime(props.line.timeInMs, precise)); + }} >
Date: Fri, 19 Sep 2025 21:12:25 +0800 Subject: [PATCH 19/54] feat(synced-lyrics): add enhanced scroll animation and hover styles to renderer --- .../synced-lyrics/renderer/renderer.tsx | 319 +++++++++++++++++- 1 file changed, 303 insertions(+), 16 deletions(-) diff --git a/src/plugins/synced-lyrics/renderer/renderer.tsx b/src/plugins/synced-lyrics/renderer/renderer.tsx index be1eb1284e..e174670f47 100644 --- a/src/plugins/synced-lyrics/renderer/renderer.tsx +++ b/src/plugins/synced-lyrics/renderer/renderer.tsx @@ -1,3 +1,4 @@ +/* eslint-disable stylistic/no-mixed-operators */ import { createEffect, createSignal, @@ -28,17 +29,28 @@ export const [isVisible, setIsVisible] = createSignal(false); export const [config, setConfig] = createSignal(null); +export const [fastScrollUntil, setFastScrollUntil] = createSignal(0); +export const requestFastScroll = (windowMs = 700) => + setFastScrollUntil(performance.now() + windowMs); + +export const [suppressFastUntil, setSuppressFastUntil] = + createSignal(0); +export const suppressFastScroll = (windowMs = 1200) => + setSuppressFastUntil(performance.now() + windowMs); + createEffect(() => { if (!config()?.enabled) return; const root = document.documentElement; - - // Set the line effect - switch (config()?.lineEffect) { + const lineEffect = config()?.lineEffect || 'none'; + document.body.classList.toggle('enhanced-lyrics', lineEffect === 'enhanced'); + switch (lineEffect) { case 'enhanced': + root.style.setProperty('--lyrics-font-family', 'Satoshi, sans-serif'); root.style.setProperty('--lyrics-font-size', '3rem'); root.style.setProperty('--lyrics-line-height', '1.333'); root.style.setProperty('--lyrics-width', '100%'); root.style.setProperty('--lyrics-padding', '12.5px'); + root.style.setProperty('--lyrics-will-change', 'transform, opacity'); root.style.setProperty( '--lyrics-animations', @@ -54,8 +66,18 @@ createEffect(() => { root.style.setProperty('--lyrics-active-opacity', '1'); root.style.setProperty('--lyrics-active-scale', '1'); root.style.setProperty('--lyrics-active-offset', '0'); + + root.style.setProperty('--lyrics-hover-scale', '0.975'); + root.style.setProperty('--lyrics-hover-opacity', '0.585'); + root.style.setProperty('--lyrics-hover-empty-opacity', '1'); + + root.style.setProperty('--lyrics-empty-opacity', '0.495'); break; case 'fancy': + root.style.setProperty( + '--lyrics-font-family', + '"Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif', + ); root.style.setProperty('--lyrics-font-size', '3rem'); root.style.setProperty('--lyrics-line-height', '1.333'); root.style.setProperty('--lyrics-width', '100%'); @@ -64,6 +86,7 @@ createEffect(() => { '--lyrics-animations', 'lyrics-glow var(--lyrics-glow-duration) forwards, lyrics-wobble var(--lyrics-wobble-duration) forwards', ); + root.style.setProperty('--lyrics-will-change', 'auto'); root.style.setProperty('--lyrics-inactive-font-weight', '700'); root.style.setProperty('--lyrics-inactive-opacity', '0.33'); @@ -74,8 +97,18 @@ createEffect(() => { root.style.setProperty('--lyrics-active-opacity', '1'); root.style.setProperty('--lyrics-active-scale', '1'); root.style.setProperty('--lyrics-active-offset', '0'); + + root.style.setProperty('--lyrics-hover-scale', '0.95'); + root.style.setProperty('--lyrics-hover-opacity', '0.33'); + root.style.setProperty('--lyrics-hover-empty-opacity', '1'); + + root.style.setProperty('--lyrics-empty-opacity', '1'); break; case 'scale': + root.style.setProperty( + '--lyrics-font-family', + '"Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif', + ); root.style.setProperty( '--lyrics-font-size', 'clamp(1.4rem, 1.1vmax, 3rem)', @@ -87,6 +120,7 @@ createEffect(() => { root.style.setProperty('--lyrics-width', '83%'); root.style.setProperty('--lyrics-padding', '0'); root.style.setProperty('--lyrics-animations', 'none'); + root.style.setProperty('--lyrics-will-change', 'auto'); root.style.setProperty('--lyrics-inactive-font-weight', '400'); root.style.setProperty('--lyrics-inactive-opacity', '0.33'); @@ -97,8 +131,18 @@ createEffect(() => { root.style.setProperty('--lyrics-active-opacity', '1'); root.style.setProperty('--lyrics-active-scale', '1.2'); root.style.setProperty('--lyrics-active-offset', '0'); + + root.style.setProperty('--lyrics-hover-scale', '1'); + root.style.setProperty('--lyrics-hover-opacity', '0.33'); + root.style.setProperty('--lyrics-hover-empty-opacity', '1'); + + root.style.setProperty('--lyrics-empty-opacity', '1'); break; case 'offset': + root.style.setProperty( + '--lyrics-font-family', + '"Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif', + ); root.style.setProperty( '--lyrics-font-size', 'clamp(1.4rem, 1.1vmax, 3rem)', @@ -110,6 +154,7 @@ createEffect(() => { root.style.setProperty('--lyrics-width', '100%'); root.style.setProperty('--lyrics-padding', '0'); root.style.setProperty('--lyrics-animations', 'none'); + root.style.setProperty('--lyrics-will-change', 'auto'); root.style.setProperty('--lyrics-inactive-font-weight', '400'); root.style.setProperty('--lyrics-inactive-opacity', '0.33'); @@ -120,8 +165,18 @@ createEffect(() => { root.style.setProperty('--lyrics-active-opacity', '1'); root.style.setProperty('--lyrics-active-scale', '1'); root.style.setProperty('--lyrics-active-offset', '5%'); + + root.style.setProperty('--lyrics-hover-scale', '1'); + root.style.setProperty('--lyrics-hover-opacity', '0.33'); + root.style.setProperty('--lyrics-hover-empty-opacity', '1'); + + root.style.setProperty('--lyrics-empty-opacity', '1'); break; case 'focus': + root.style.setProperty( + '--lyrics-font-family', + '"Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif', + ); root.style.setProperty( '--lyrics-font-size', 'clamp(1.4rem, 1.1vmax, 3rem)', @@ -133,6 +188,7 @@ createEffect(() => { root.style.setProperty('--lyrics-width', '100%'); root.style.setProperty('--lyrics-padding', '0'); root.style.setProperty('--lyrics-animations', 'none'); + root.style.setProperty('--lyrics-will-change', 'auto'); root.style.setProperty('--lyrics-inactive-font-weight', '400'); root.style.setProperty('--lyrics-inactive-opacity', '0.33'); @@ -143,6 +199,12 @@ createEffect(() => { root.style.setProperty('--lyrics-active-opacity', '1'); root.style.setProperty('--lyrics-active-scale', '1'); root.style.setProperty('--lyrics-active-offset', '0'); + + root.style.setProperty('--lyrics-hover-scale', '1'); + root.style.setProperty('--lyrics-hover-opacity', '0.33'); + root.style.setProperty('--lyrics-hover-empty-opacity', '1'); + + root.style.setProperty('--lyrics-empty-opacity', '1'); break; } }); @@ -164,10 +226,18 @@ type LyricsRendererChild = const lyricsPicker: LyricsRendererChild = { kind: 'LyricsPicker' }; export const [currentTime, setCurrentTime] = createSignal(-1); +export const [scrollTargetIndex, setScrollTargetIndex] = + createSignal(0); export const LyricsRenderer = () => { const [scroller, setScroller] = createSignal(); const [stickyRef, setStickRef] = createSignal(null); + let prevTimeForScroll = -1; + let prevIndexForFast = -1; + + let scrollAnimRaf: number | null = null; + let scrollAnimActive = false; + const tab = document.querySelector(selectors.body.tabRenderer)!; let mouseCoord = 0; @@ -248,7 +318,7 @@ export const LyricsRenderer = () => { if (data?.lyrics) { const rawLines = data.lyrics.split('\n'); - // Preserve a single trailing empty line if provided by the provider + // preserve a single trailing empty line if provided by the provider const hasTrailingEmpty = rawLines.length > 0 && rawLines[rawLines.length - 1].trim() === ''; @@ -299,11 +369,9 @@ export const LyricsRenderer = () => { return; } - if (previous.every((status, idx) => status === current[idx])) { - return; + if (!previous.every((status, idx) => status === current[idx])) { + setStatuses(current); } - - setStatuses(current); }); const [currentIndex, setCurrentIndex] = createSignal(0); @@ -313,20 +381,239 @@ export const LyricsRenderer = () => { setCurrentIndex(index); }); + // scroll effect createEffect(() => { + const visible = isVisible(); const current = currentLyrics(); - const idx = currentIndex(); - const maxIdx = untrack(statuses).length - 1; + const targetIndex = scrollTargetIndex(); + const maxIndex = untrack(statuses).length - 1; + const scrollerInstance = scroller(); - if (!scroller() || !current.data?.lines) return; + if (!visible || !scrollerInstance || !current.data?.lines) return; // hacky way to make the "current" line scroll to the center of the screen - const scrollIndex = Math.min(idx + 1, maxIdx); + const scrollIndex = Math.min(targetIndex + 1, maxIndex); + + // animation duration + const calculateDuration = ( + distance: number, + jumpSize: number, + fast: boolean, + ) => { + // fast scroll for others + if (fast) { + const d = 260 + distance * 0.28; + return Math.min(680, Math.max(240, d)); + } - scroller()!.scrollToIndex(scrollIndex, { - smooth: true, - align: 'center', - }); + let minDuration = 850; + let maxDuration = 1650; + let duration = 550 + distance * 0.7; + + if (jumpSize === 1) { + minDuration = 1000; + maxDuration = 1800; + duration = 700 + distance * 0.8; + } else if (jumpSize > 3) { + minDuration = 600; + maxDuration = 1400; + duration = 400 + distance * 0.6; + } + + return Math.min(maxDuration, Math.max(minDuration, duration)); + }; + + // easing function + const easeInOutCubic = (t: number) => { + if (t < 0.5) { + return 4 * t ** 3; + } + const t1 = -2 * t + 2; + return 1 - t1 ** 3 / 2; + }; + + // target scroll offset + const calculateEnhancedTargetOffset = ( + scrollerInstance: VirtualizerHandle, + scrollIndex: number, + currentIndex: number, + ) => { + const viewportSize = scrollerInstance.viewportSize; + const itemOffset = scrollerInstance.getItemOffset(scrollIndex); + const itemSize = scrollerInstance.getItemSize(scrollIndex); + const maxScroll = scrollerInstance.scrollSize - viewportSize; + + if (currentIndex === 0) return 0; + + const viewportCenter = viewportSize / 2; + const itemCenter = itemSize / 2; + const centerOffset = itemOffset - viewportCenter + itemCenter; + + return Math.max(0, Math.min(centerOffset, maxScroll)); + }; + + // enhanced scroll animation + const performEnhancedScroll = ( + scrollerInstance: VirtualizerHandle, + scrollIndex: number, + currentIndex: number, + fast: boolean, + ) => { + const targetOffset = calculateEnhancedTargetOffset( + scrollerInstance, + scrollIndex, + currentIndex, + ); + const startOffset = scrollerInstance.scrollOffset; + + if (startOffset === targetOffset) return; + + const distance = Math.abs(targetOffset - startOffset); + const jumpSize = Math.abs(scrollIndex - currentIndex); + const duration = calculateDuration(distance, jumpSize, fast); + + // offset start time for responsive feel + const animationStartTimeOffsetMs = fast ? 15 : 170; + const startTime = performance.now() - animationStartTimeOffsetMs; + + scrollAnimActive = false; + if (scrollAnimRaf !== null) cancelAnimationFrame(scrollAnimRaf); + + if (distance < 0.5) { + scrollerInstance.scrollTo(targetOffset); + return; + } + + const animate = (now: number) => { + if (!scrollAnimActive) return; + const elapsed = now - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = easeInOutCubic(progress); + const offsetDiff = (targetOffset - startOffset) * eased; + const currentOffset = startOffset + offsetDiff; + + scrollerInstance.scrollTo(currentOffset); + if (progress < 1 && scrollAnimActive) { + scrollAnimRaf = requestAnimationFrame(animate); + } + }; + + scrollAnimActive = true; + scrollAnimRaf = requestAnimationFrame(animate); + }; + + // validate scroller measurements + const isScrollerReady = ( + scrollerInstance: VirtualizerHandle, + scrollIndex: number, + ) => { + const viewport = scrollerInstance.viewportSize; + const size = scrollerInstance.getItemSize(scrollIndex); + const offset = scrollerInstance.getItemOffset(scrollIndex); + return viewport > 0 && size > 0 && offset >= 0; + }; + + let readyRafId: number | null = null; + + const cleanup = () => { + if (readyRafId !== null) cancelAnimationFrame(readyRafId); + scrollAnimActive = false; + if (scrollAnimRaf !== null) cancelAnimationFrame(scrollAnimRaf); + }; + onCleanup(cleanup); + + // wait for scroller ready + const waitForReady = (tries = 0) => { + const nonEnhanced = config()?.lineEffect !== 'enhanced'; + const scrollerReady = isScrollerReady(scrollerInstance, scrollIndex); + const hasCurrentIndex = !nonEnhanced || currentIndex() >= 0; + + if ((scrollerReady && hasCurrentIndex) || tries >= 20) { + performScroll(); + } else { + readyRafId = requestAnimationFrame(() => waitForReady(tries + 1)); + } + }; + + const performScroll = () => { + const now = performance.now(); + const inFastWindow = now < fastScrollUntil(); + const suppressed = now < suppressFastUntil(); + + if (config()?.lineEffect !== 'enhanced') { + scrollerInstance.scrollToIndex(scrollIndex, { + smooth: true, + align: 'center', + }); + return; + } + + const targetOffset = calculateEnhancedTargetOffset( + scrollerInstance, + scrollIndex, + targetIndex, + ); + const startOffset = scrollerInstance.scrollOffset; + const distance = Math.abs(targetOffset - startOffset); + const viewport = scrollerInstance.viewportSize; + const largeDistance = distance > Math.max(400, viewport * 0.6); + const fast = inFastWindow && !suppressed && largeDistance; + + performEnhancedScroll(scrollerInstance, scrollIndex, targetIndex, fast); + }; + + waitForReady(); + }); + + // handle scroll target updates based on current time + createEffect(() => { + const data = currentLyrics()?.data; + const currentTimeMs = currentTime(); + const idx = currentIndex(); + const lineEffect = config()?.lineEffect; + + if (!data || !data.lines || idx < 0) return; + const jumped = + prevTimeForScroll >= 0 && + Math.abs(currentTimeMs - prevTimeForScroll) > 400; + if ( + jumped && + prevTimeForScroll >= 0 && + performance.now() >= suppressFastUntil() + ) { + const timeDelta = Math.abs(currentTimeMs - prevTimeForScroll); + const lineDelta = + prevIndexForFast >= 0 ? Math.abs(idx - prevIndexForFast) : 0; + if (timeDelta > 1500 || lineDelta >= 5) { + requestFastScroll(1500); + } + } + prevTimeForScroll = currentTimeMs; + + const scrollOffset = scroller()?.scrollOffset ?? 0; + if (idx === 0 && currentTimeMs > 2000 && !jumped && scrollOffset <= 1) { + return; + } + + if (lineEffect === 'enhanced') { + const nextIdx = Math.min(idx + 1, data.lines.length - 1); + const nextLine = data.lines[nextIdx]; + + if (nextLine) { + // start scroll early + const leadInTimeMs = 130; + const timeUntilNextLine = nextLine.timeInMs - currentTimeMs; + + if (timeUntilNextLine <= leadInTimeMs) { + setScrollTargetIndex(nextIdx); + prevIndexForFast = idx; + return; + } + } + } + + prevIndexForFast = idx; + setScrollTargetIndex(idx); }); return ( From e29a72b0c510481fd210b067ceace3fbf81d0c11 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Fri, 19 Sep 2025 21:13:19 +0800 Subject: [PATCH 20/54] feat(synced-lyrics): trigger fast scroll on provider switch and improve transform calc --- .../renderer/components/LyricsPicker.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx index 6be9f484c3..5f24a57cfe 100644 --- a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx +++ b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx @@ -22,7 +22,7 @@ import { } from '../../providers'; import { currentLyrics, lyricsStore, setLyricsStore } from '../store'; import { _ytAPI } from '../index'; -import { config } from '../renderer'; +import { config, requestFastScroll } from '../renderer'; import type { YtIcons } from '@/types/icons'; import type { PlayerAPIEvents } from '@/types/player-api-events'; @@ -141,6 +141,7 @@ export const LyricsPicker = (props: { if (!hasManuallySwitchedProvider()) { const starred = starredProvider(); if (starred !== null) { + requestFastScroll(2500); setLyricsStore('provider', starred); return; } @@ -155,6 +156,7 @@ export const LyricsPicker = (props: { force || providerBias(lyricsStore.provider) < providerBias(provider) ) { + requestFastScroll(2500); setLyricsStore('provider', provider); } } @@ -162,6 +164,7 @@ export const LyricsPicker = (props: { const next = () => { setHasManuallySwitchedProvider(true); + requestFastScroll(2500); setLyricsStore('provider', (prevProvider) => { const idx = providerNames.indexOf(prevProvider); return providerNames[(idx + 1) % providerNames.length]; @@ -170,6 +173,7 @@ export const LyricsPicker = (props: { const previous = () => { setHasManuallySwitchedProvider(true); + requestFastScroll(2500); setLyricsStore('provider', (prevProvider) => { const idx = providerNames.indexOf(prevProvider); return providerNames[ @@ -231,7 +235,7 @@ export const LyricsPicker = (props: {
@@ -311,7 +315,11 @@ export const LyricsPicker = (props: { {(_, idx) => (
  • setLyricsStore('provider', providerNames[idx()])} + onClick={() => { + setHasManuallySwitchedProvider(true); + requestFastScroll(2500); + setLyricsStore('provider', providerNames[idx()]); + }} style={{ background: idx() === providerIdx() ? 'white' : 'black', }} From fd901b976bce567a3862386f6dafdcf2e679504a Mon Sep 17 00:00:00 2001 From: RobRoid Date: Fri, 19 Sep 2025 22:56:26 +0800 Subject: [PATCH 21/54] fix(synced-lyrics): merge consecutive empty lines and improve time parsing clarity --- src/plugins/synced-lyrics/parsers/lrc.ts | 35 +++++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/plugins/synced-lyrics/parsers/lrc.ts b/src/plugins/synced-lyrics/parsers/lrc.ts index 598b46b081..2a76fdd9cd 100644 --- a/src/plugins/synced-lyrics/parsers/lrc.ts +++ b/src/plugins/synced-lyrics/parsers/lrc.ts @@ -55,10 +55,10 @@ export const LRC = { const ms2 = milliseconds.padEnd(2, '0').slice(0, 2); // Convert to ms (xx → xx0) - const timeInMs = - parseInt(minutes) * 60 * 1000 + - parseInt(seconds) * 1000 + - parseInt(ms2) * 10; + const minutesMs = parseInt(minutes) * 60 * 1000; + const secondsMs = parseInt(seconds) * 1000; + const centisMs = parseInt(ms2) * 10; + const timeInMs = minutesMs + secondsMs + centisMs; const currentLine: LRCLine = { time: `${minutes.padStart(2, '0')}:${seconds.padStart(2, '0')}.${ms2}`, @@ -89,6 +89,33 @@ export const LRC = { }); } + // Merge consecutive empty lines into a single empty line + { + const merged: LRCLine[] = []; + for (const line of lrc.lines) { + const isEmpty = !line.text || !line.text.trim(); + if (isEmpty && merged.length > 0) { + const prev = merged[merged.length - 1]; + const prevEmpty = !prev.text || !prev.text.trim(); + if (prevEmpty) { + const prevEnd = Number.isFinite(prev.duration) + ? prev.timeInMs + prev.duration + : Infinity; + const thisEnd = Number.isFinite(line.duration) + ? line.timeInMs + line.duration + : Infinity; + const newEnd = Math.max(prevEnd, thisEnd); + prev.duration = Number.isFinite(newEnd) + ? newEnd - prev.timeInMs + : Infinity; + continue; + } + } + merged.push(line); + } + lrc.lines = merged; + } + return lrc; }, }; From 64947e28636b405b6f8360494c7dfe187a8b06c7 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Fri, 19 Sep 2025 22:58:41 +0800 Subject: [PATCH 22/54] refactor(synced-lyrics): optimize artist matching and merge consecutive empty lines --- src/plugins/synced-lyrics/providers/LRCLib.ts | 113 ++++++++++-------- 1 file changed, 65 insertions(+), 48 deletions(-) diff --git a/src/plugins/synced-lyrics/providers/LRCLib.ts b/src/plugins/synced-lyrics/providers/LRCLib.ts index 6ffba991b1..3d33caf2c0 100644 --- a/src/plugins/synced-lyrics/providers/LRCLib.ts +++ b/src/plugins/synced-lyrics/providers/LRCLib.ts @@ -77,61 +77,59 @@ export class LRCLib implements LyricProvider { } const filteredResults = []; + const SIM_THRESHOLD = 0.9; for (const item of data) { - const { artistName } = item; - - const artists = artist.split(/[&,]/g).map((i) => i.trim()); - const itemArtists = artistName.split(/[&,]/g).map((i) => i.trim()); + // quick duration guard to avoid expensive similarity on far-off matches + if (Math.abs(item.duration - songDuration) > 15) continue; + if (item.instrumental) continue; - // Try to match using artist name first - const permutations = []; - for (const artistA of artists) { - for (const artistB of itemArtists) { - permutations.push([artistA.toLowerCase(), artistB.toLowerCase()]); - } - } + const { artistName } = item; - for (const artistA of itemArtists) { - for (const artistB of artists) { - permutations.push([artistA.toLowerCase(), artistB.toLowerCase()]); + const artists = artist + .split(/[&,]/g) + .map((i) => i.trim().toLowerCase()) + .filter(Boolean); + const itemArtists = artistName + .split(/[&,]/g) + .map((i) => i.trim().toLowerCase()) + .filter(Boolean); + + // fast path: any exact artist match + let ratio = 0; + if (artists.some((a) => itemArtists.includes(a))) { + ratio = 1; + } else { + // compute best pairwise similarity with early exit + outer: for (const a of artists) { + for (const b of itemArtists) { + const r = jaroWinkler(a, b); + if (r > ratio) ratio = r; + if (ratio >= 0.97) break outer; // good enough, stop early + } } } - let ratio = Math.max(...permutations.map(([x, y]) => jaroWinkler(x, y))); - - // If direct artist match is below threshold and we have tags, try matching with tags - if (ratio <= 0.9 && tags && tags.length > 0) { - // Filter out the artist from tags to avoid duplicate comparisons - const filteredTags = tags.filter( - (tag) => tag.toLowerCase() !== artist.toLowerCase(), + // If direct artist match is below threshold and we have tags, compare tags too + if (ratio <= SIM_THRESHOLD && tags && tags.length > 0) { + const artistSet = new Set(artists); + const filteredTags = Array.from( + new Set( + tags + .map((t) => t.trim().toLowerCase()) + .filter((t) => t && !artistSet.has(t)), + ), ); - const tagPermutations = []; - // Compare each tag with each item artist - for (const tag of filteredTags) { - for (const itemArtist of itemArtists) { - tagPermutations.push([tag.toLowerCase(), itemArtist.toLowerCase()]); + outerTags: for (const t of filteredTags) { + for (const b of itemArtists) { + const r = jaroWinkler(t, b); + if (r > ratio) ratio = r; + if (ratio >= 0.97) break outerTags; } } - - // Compare each item artist with each tag - for (const itemArtist of itemArtists) { - for (const tag of filteredTags) { - tagPermutations.push([itemArtist.toLowerCase(), tag.toLowerCase()]); - } - } - - if (tagPermutations.length > 0) { - const tagRatio = Math.max( - ...tagPermutations.map(([x, y]) => jaroWinkler(x, y)), - ); - - // Use the best match ratio between direct artist match and tag match - ratio = Math.max(ratio, tagRatio); - } } - if (ratio <= 0.9) continue; + if (ratio <= SIM_THRESHOLD) continue; filteredResults.push(item); } @@ -165,9 +163,28 @@ export class LRCLib implements LyricProvider { status: 'upcoming' as const, })); - // If the final parsed line is not empty, append a computed empty line - if (parsed.length > 0) { - const last = parsed[parsed.length - 1]; + // Merge consecutive empty lines into a single empty line + const merged: typeof parsed = []; + for (const line of parsed) { + const isEmpty = !line.text || !line.text.trim(); + if (isEmpty && merged.length > 0) { + const prev = merged[merged.length - 1]; + const prevEmpty = !prev.text || !prev.text.trim(); + if (prevEmpty) { + // extend previous duration to cover this line + const prevEnd = prev.timeInMs + prev.duration; + const thisEnd = line.timeInMs + line.duration; + const newEnd = Math.max(prevEnd, thisEnd); + prev.duration = newEnd - prev.timeInMs; + continue; // skip adding this line + } + } + merged.push(line); + } + + // If the final merged line is not empty, append a computed empty line + if (merged.length > 0) { + const last = merged[merged.length - 1]; const lastIsEmpty = !last.text || !last.text.trim(); if (lastIsEmpty) { // last line already empty, don't append another @@ -191,7 +208,7 @@ export class LRCLib implements LyricProvider { .toString() .padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`; - parsed.push({ + merged.push({ timeInMs: midpoint, time: timeStr, duration: songEnd - midpoint, @@ -205,7 +222,7 @@ export class LRCLib implements LyricProvider { return { title: closestResult.trackName, artists: closestResult.artistName.split(/[&,]/g), - lines: parsed, + lines: merged, }; } else if (plain) { // Fallback to plain if no synced From 098f77860256e9e58de4aa8a59e6354f625324b0 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Fri, 19 Sep 2025 23:01:35 +0800 Subject: [PATCH 23/54] fix(synced-lyrics): improve scroll stability with fallback index and fast-scroll on tab visible --- .../synced-lyrics/renderer/renderer.tsx | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/src/plugins/synced-lyrics/renderer/renderer.tsx b/src/plugins/synced-lyrics/renderer/renderer.tsx index e174670f47..1ee191c0be 100644 --- a/src/plugins/synced-lyrics/renderer/renderer.tsx +++ b/src/plugins/synced-lyrics/renderer/renderer.tsx @@ -381,6 +381,13 @@ export const LyricsRenderer = () => { setCurrentIndex(index); }); + // when lyrics tab becomes visible again, open a short fast-scroll window + createEffect(() => { + if (isVisible()) { + requestFastScroll(1500); + } + }); + // scroll effect createEffect(() => { const visible = isVisible(); @@ -572,7 +579,31 @@ export const LyricsRenderer = () => { const idx = currentIndex(); const lineEffect = config()?.lineEffect; - if (!data || !data.lines || idx < 0) return; + if (!data || !data.lines) return; + + // robust fallback if no line is detected as "current" yet + let effIdx = idx; + if (effIdx < 0) { + const lines = data.lines; + const containing = lines.findIndex((l) => { + const start = l.timeInMs; + const end = l.timeInMs + l.duration; + return currentTimeMs >= start && currentTimeMs < end; + }); + if (containing !== -1) { + effIdx = containing; + } else { + let lastBefore = 0; + for (let j = lines.length - 1; j >= 0; j--) { + if (lines[j].timeInMs <= currentTimeMs) { + lastBefore = j; + break; + } + } + effIdx = lastBefore; + } + } + const jumped = prevTimeForScroll >= 0 && Math.abs(currentTimeMs - prevTimeForScroll) > 400; @@ -583,7 +614,7 @@ export const LyricsRenderer = () => { ) { const timeDelta = Math.abs(currentTimeMs - prevTimeForScroll); const lineDelta = - prevIndexForFast >= 0 ? Math.abs(idx - prevIndexForFast) : 0; + prevIndexForFast >= 0 ? Math.abs(effIdx - prevIndexForFast) : 0; if (timeDelta > 1500 || lineDelta >= 5) { requestFastScroll(1500); } @@ -591,12 +622,12 @@ export const LyricsRenderer = () => { prevTimeForScroll = currentTimeMs; const scrollOffset = scroller()?.scrollOffset ?? 0; - if (idx === 0 && currentTimeMs > 2000 && !jumped && scrollOffset <= 1) { + if (effIdx === 0 && currentTimeMs > 2000 && !jumped && scrollOffset <= 1) { return; } if (lineEffect === 'enhanced') { - const nextIdx = Math.min(idx + 1, data.lines.length - 1); + const nextIdx = Math.min(effIdx + 1, data.lines.length - 1); const nextLine = data.lines[nextIdx]; if (nextLine) { @@ -606,14 +637,14 @@ export const LyricsRenderer = () => { if (timeUntilNextLine <= leadInTimeMs) { setScrollTargetIndex(nextIdx); - prevIndexForFast = idx; + prevIndexForFast = effIdx; return; } } } - prevIndexForFast = idx; - setScrollTargetIndex(idx); + prevIndexForFast = effIdx; + setScrollTargetIndex(effIdx); }); return ( From 9c04c958b098b73db8c0f677166cc9d927918e5c Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 00:46:41 +0800 Subject: [PATCH 24/54] feat(i18n): add strings for enhanced line effect and empty line symbol option --- src/i18n/resources/en.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index 85638a0f6d..628c5366cb 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -828,6 +828,10 @@ "line-effect": { "label": "Line effect", "submenu": { + "enhanced": { + "label": "Enhanced", + "tooltip": "A refined lyric effect for smoother, more enjoyable reading." + }, "fancy": { "label": "Fancy", "tooltip": "Use large, app-like effects on the current line" @@ -847,6 +851,10 @@ }, "tooltip": "Choose the effect to apply to the current line" }, + "show-empty-line-symbols": { + "label": "Show character between lyrics", + "tooltip": "Choose whether to always display the character between empty lyric lines." + }, "precise-timing": { "label": "Make the lyrics perfectly synced", "tooltip": "Calculate to the milisecond the display of the next line (can have a small impact on performance)" From f2a5c817578b42c2808495cd4631048a12666c7c Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 00:50:57 +0800 Subject: [PATCH 25/54] feat(synced-lyrics): update defaults and add author --- src/plugins/synced-lyrics/index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/plugins/synced-lyrics/index.ts b/src/plugins/synced-lyrics/index.ts index 533f05bfb1..b5fb3e623d 100644 --- a/src/plugins/synced-lyrics/index.ts +++ b/src/plugins/synced-lyrics/index.ts @@ -11,7 +11,7 @@ import type { SyncedLyricsPluginConfig } from './types'; export default createPlugin({ name: () => t('plugins.synced-lyrics.name'), description: () => t('plugins.synced-lyrics.description'), - authors: ['Non0reo', 'ArjixWasTaken', 'KimJammer', 'Strvm'], + authors: ['Non0reo', 'ArjixWasTaken', 'KimJammer', 'Strvm', 'robroid'], restartNeeded: true, addedVersion: '3.5.X', config: { @@ -19,8 +19,9 @@ export default createPlugin({ preciseTiming: true, showLyricsEvenIfInexact: true, showTimeCodes: false, - defaultTextString: '♪', - lineEffect: 'fancy', + defaultTextString: '•••', + lineEffect: 'enhanced', + showEmptyLineSymbols: false, romanization: true, } satisfies SyncedLyricsPluginConfig as SyncedLyricsPluginConfig, From 79b3be546234f76e60771d58fcc792d241b8cd2d Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 00:54:14 +0800 Subject: [PATCH 26/54] feat(synced-lyrics): add enhanced line effect option and toggle for empty line symbols --- src/plugins/synced-lyrics/menu.ts | 87 +++++++++++++++++++++---------- 1 file changed, 60 insertions(+), 27 deletions(-) diff --git a/src/plugins/synced-lyrics/menu.ts b/src/plugins/synced-lyrics/menu.ts index d1b9e12f41..123b3d4a33 100644 --- a/src/plugins/synced-lyrics/menu.ts +++ b/src/plugins/synced-lyrics/menu.ts @@ -41,22 +41,26 @@ export const menu = async ( ), ], }, - { - label: t('plugins.synced-lyrics.menu.precise-timing.label'), - toolTip: t('plugins.synced-lyrics.menu.precise-timing.tooltip'), - type: 'checkbox', - checked: config.preciseTiming, - click(item) { - ctx.setConfig({ - preciseTiming: item.checked, - }); - }, - }, { label: t('plugins.synced-lyrics.menu.line-effect.label'), toolTip: t('plugins.synced-lyrics.menu.line-effect.tooltip'), type: 'submenu', submenu: [ + { + label: t( + 'plugins.synced-lyrics.menu.line-effect.submenu.enhanced.label', + ), + toolTip: t( + 'plugins.synced-lyrics.menu.line-effect.submenu.enhanced.tooltip', + ), + type: 'radio', + checked: config.lineEffect === 'enhanced', + click() { + ctx.setConfig({ + lineEffect: 'enhanced', + }); + }, + }, { label: t( 'plugins.synced-lyrics.menu.line-effect.submenu.fancy.label', @@ -124,23 +128,52 @@ export const menu = async ( toolTip: t('plugins.synced-lyrics.menu.default-text-string.tooltip'), type: 'submenu', submenu: [ - { label: '♪', value: '♪' }, - { label: '" "', value: ' ' }, - { label: '...', value: ['.', '..', '...'] }, - { label: '•••', value: ['•', '••', '•••'] }, - { label: '———', value: '———' }, - ].map(({ label, value }) => ({ - label, - type: 'radio', - checked: - typeof value === 'string' - ? config.defaultTextString === value - : JSON.stringify(config.defaultTextString) === - JSON.stringify(value), - click() { - ctx.setConfig({ defaultTextString: value }); + ...[ + { label: '•••', value: ['•', '•', '•'] }, + { label: '...', value: ['.', '.', '.'] }, + { label: '♪', value: '♪' }, + { label: '———', value: '———' }, + { label: '(𝑏𝑙𝑎𝑛𝑘)', value: '\u00A0' }, + ].map( + ({ label, value }) => + ({ + label, + type: 'radio', + checked: + JSON.stringify(config.defaultTextString) === + JSON.stringify(value), + enabled: config.showEmptyLineSymbols, + click() { + ctx.setConfig({ defaultTextString: value }); + }, + }) as const, + ), + { type: 'separator' }, + { + label: t('plugins.synced-lyrics.menu.show-empty-line-symbols.label'), + toolTip: t( + 'plugins.synced-lyrics.menu.show-empty-line-symbols.tooltip', + ), + type: 'checkbox', + checked: config.showEmptyLineSymbols ?? false, + click(item) { + ctx.setConfig({ + showEmptyLineSymbols: item.checked, + }); + }, }, - })), + ], + }, + { + label: t('plugins.synced-lyrics.menu.precise-timing.label'), + toolTip: t('plugins.synced-lyrics.menu.precise-timing.tooltip'), + type: 'checkbox', + checked: config.preciseTiming, + click(item) { + ctx.setConfig({ + preciseTiming: item.checked, + }); + }, }, { label: t('plugins.synced-lyrics.menu.romanization.label'), From 402178adaa68252ca8fdf66dfa911ca52aa0aeef Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 00:55:34 +0800 Subject: [PATCH 27/54] style(synced-lyrics): refine font family and improve empty line & fade effects --- src/plugins/synced-lyrics/style.css | 42 ++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/src/plugins/synced-lyrics/style.css b/src/plugins/synced-lyrics/style.css index 19154b4468..d3fac7be42 100644 --- a/src/plugins/synced-lyrics/style.css +++ b/src/plugins/synced-lyrics/style.css @@ -27,9 +27,7 @@ --lyrics-padding: 0; /* Typography */ - --lyrics-font-family: Satoshi, Avenir, -apple-system, BlinkMacSystemFont, - Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue, - sans-serif; + --lyrics-font-family: "Satoshi", sans-serif !important; --lyrics-font-size: clamp(1.4rem, 1.1vmax, 3rem); --lyrics-line-height: var(--ytmusic-body-line-height); --lyrics-width: 100%; @@ -137,6 +135,11 @@ transition: opacity var(--lyrics-opacity-transition); } +.text-lyrics .placeholder { + opacity: var(--lyrics-inactive-opacity); + transition: opacity var(--lyrics-opacity-transition); +} + .current .text-lyrics { font-weight: var(--lyrics-active-font-weight) !important; scale: var(--lyrics-active-scale); @@ -148,6 +151,21 @@ animation: var(--lyrics-animations); } +.synced-line.final-empty .text-lyrics { + color: transparent; + padding-top: 0 !important; + padding-bottom: 0 !important; + height: 1em; + display: block; +} + +.synced-line.no-padding .text-lyrics { + padding-top: 0 !important; + padding-bottom: 0 !important; + height: 0.3em; + overflow: hidden; +} + .lyrics-renderer { display: flex; flex-direction: column; @@ -232,6 +250,24 @@ div:has(> .lyrics-picker) { } } +.fade { + opacity: 0; + transition: var(--lyrics-opacity-transition) ease-in-out; +} + +.fade.show { + opacity: var(--lyrics-active-opacity); +} + +.fade.dim { + opacity: var(--lyrics-inactive-opacity); +} + +.fade, +.placeholder { + animation-name: none; +} + /* Animations */ @keyframes lyrics-wobble { from { From b8b8feaea773ceadd21270b070283754c046a6c8 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 00:55:58 +0800 Subject: [PATCH 28/54] feat(synced-lyrics): add showEmptyLineSymbols config and enhanced line effect type --- src/plugins/synced-lyrics/types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/plugins/synced-lyrics/types.ts b/src/plugins/synced-lyrics/types.ts index dab1edf9ba..db09b0693c 100644 --- a/src/plugins/synced-lyrics/types.ts +++ b/src/plugins/synced-lyrics/types.ts @@ -9,6 +9,7 @@ export type SyncedLyricsPluginConfig = { defaultTextString: string | string[]; showLyricsEvenIfInexact: boolean; lineEffect: LineEffect; + showEmptyLineSymbols: boolean; romanization: boolean; }; @@ -23,7 +24,7 @@ export type LineLyrics = { status: LineLyricsStatus; }; -export type LineEffect = 'fancy' | 'scale' | 'offset' | 'focus'; +export type LineEffect = 'enhanced' | 'fancy' | 'scale' | 'offset' | 'focus'; export interface LyricResult { title: string; From 96a39ab33673bd8b944254deae683b1142ed49fb Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 01:00:22 +0800 Subject: [PATCH 29/54] fix(synced-lyrics): improve LRC parser to handle variable millisecond precision --- src/plugins/synced-lyrics/parsers/lrc.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/plugins/synced-lyrics/parsers/lrc.ts b/src/plugins/synced-lyrics/parsers/lrc.ts index 355c0a4d5c..598b46b081 100644 --- a/src/plugins/synced-lyrics/parsers/lrc.ts +++ b/src/plugins/synced-lyrics/parsers/lrc.ts @@ -17,7 +17,7 @@ interface LRC { const tagRegex = /^\[(?\w+):\s*(?.+?)\s*\]$/; // prettier-ignore -const lyricRegex = /^\[(?\d+):(?\d+)\.(?\d+)\](?.+)$/; +const lyricRegex = /^\[(?\d+):(?\d+)\.(?\d{1,3})\](?.*)$/; export const LRC = { parse: (text: string): LRC => { @@ -50,13 +50,18 @@ export const LRC = { } const { minutes, seconds, milliseconds, text } = lyric; + + // Normalize: take first 2 digits, pad if only 1 digit + const ms2 = milliseconds.padEnd(2, '0').slice(0, 2); + + // Convert to ms (xx → xx0) const timeInMs = parseInt(minutes) * 60 * 1000 + parseInt(seconds) * 1000 + - parseInt(milliseconds); + parseInt(ms2) * 10; const currentLine: LRCLine = { - time: `${minutes}:${seconds}:${milliseconds}`, + time: `${minutes.padStart(2, '0')}:${seconds.padStart(2, '0')}.${ms2}`, timeInMs, text: text.trim(), duration: Infinity, From e2278bf7d31376d8b844de662f440f00d56086a4 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 01:04:08 +0800 Subject: [PATCH 30/54] fix(synced-lyrics): append trailing empty line in synced lyrics when missing --- src/plugins/synced-lyrics/providers/LRCLib.ts | 71 +++++++++++++++---- 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/src/plugins/synced-lyrics/providers/LRCLib.ts b/src/plugins/synced-lyrics/providers/LRCLib.ts index cfdea3ab48..6ffba991b1 100644 --- a/src/plugins/synced-lyrics/providers/LRCLib.ts +++ b/src/plugins/synced-lyrics/providers/LRCLib.ts @@ -157,21 +157,66 @@ export class LRCLib implements LyricProvider { const raw = closestResult.syncedLyrics; const plain = closestResult.plainLyrics; - if (!raw && !plain) { - return null; + + if (raw) { + // Prefer synced + const parsed = LRC.parse(raw).lines.map((l) => ({ + ...l, + status: 'upcoming' as const, + })); + + // If the final parsed line is not empty, append a computed empty line + if (parsed.length > 0) { + const last = parsed[parsed.length - 1]; + const lastIsEmpty = !last.text || !last.text.trim(); + if (lastIsEmpty) { + // last line already empty, don't append another + } else { + // If duration is infinity (no following line), treat end as start for midpoint calculation + const lastEndCandidate = Number.isFinite(last.duration) + ? last.timeInMs + last.duration + : last.timeInMs; + const songEnd = songDuration * 1000; + + if (lastEndCandidate < songEnd) { + const midpoint = Math.floor((lastEndCandidate + songEnd) / 2); + + // update last duration to end at midpoint + last.duration = midpoint - last.timeInMs; + + const minutes = Math.floor(midpoint / 60000); + const seconds = Math.floor((midpoint % 60000) / 1000); + const centiseconds = Math.floor((midpoint % 1000) / 10); + const timeStr = `${minutes.toString().padStart(2, '0')}:${seconds + .toString() + .padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`; + + parsed.push({ + timeInMs: midpoint, + time: timeStr, + duration: songEnd - midpoint, + text: '', + status: 'upcoming' as const, + }); + } + } + } + + return { + title: closestResult.trackName, + artists: closestResult.artistName.split(/[&,]/g), + lines: parsed, + }; + } else if (plain) { + // Fallback to plain if no synced + return { + title: closestResult.trackName, + artists: closestResult.artistName.split(/[&,]/g), + lyrics: plain, + }; } - return { - title: closestResult.trackName, - artists: closestResult.artistName.split(/[&,]/g), - lines: raw - ? LRC.parse(raw).lines.map((l) => ({ - ...l, - status: 'upcoming' as const, - })) - : undefined, - lyrics: plain, - }; + return null; } } From 4b352a551d19c43b3ac5bcdf6cdbe8f5a37a194f Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 01:05:14 +0800 Subject: [PATCH 31/54] fix(synced-lyrics): ensure final empty line is added to Genius lyrics for padding --- src/plugins/synced-lyrics/providers/LyricsGenius.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/plugins/synced-lyrics/providers/LyricsGenius.ts b/src/plugins/synced-lyrics/providers/LyricsGenius.ts index 0ebed6285d..e24aaf516d 100644 --- a/src/plugins/synced-lyrics/providers/LyricsGenius.ts +++ b/src/plugins/synced-lyrics/providers/LyricsGenius.ts @@ -89,10 +89,16 @@ export class LyricsGenius implements LyricProvider { return null; } + // final empty line for padding. + let finalLyrics = lyrics; + if (!finalLyrics.endsWith('\n\n')) { + finalLyrics = finalLyrics.endsWith('\n') ? finalLyrics + '\n' : finalLyrics + '\n\n'; + } + return { title: closestHit.result.title, artists: closestHit.result.primary_artists.map(({ name }) => name), - lyrics, + lyrics: finalLyrics, }; } } From fae95450db549ab3ef74c6c7fc2084be88270b9d Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 01:06:17 +0800 Subject: [PATCH 32/54] fix(synced-lyrics): merge consecutive empty lines in MusixMatch lyrics --- .../synced-lyrics/providers/MusixMatch.ts | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/plugins/synced-lyrics/providers/MusixMatch.ts b/src/plugins/synced-lyrics/providers/MusixMatch.ts index 275c13329c..a0b6441784 100644 --- a/src/plugins/synced-lyrics/providers/MusixMatch.ts +++ b/src/plugins/synced-lyrics/providers/MusixMatch.ts @@ -42,10 +42,32 @@ export class MusixMatch implements LyricProvider { title: track.track_name, artists: [track.artist_name], lines: subtitle - ? LRC.parse(subtitle.subtitle.subtitle_body).lines.map((l) => ({ - ...l, - status: 'upcoming' as const, - })) + ? (() => { + const parsed = LRC.parse(subtitle.subtitle.subtitle_body).lines.map( + (l) => ({ ...l, status: 'upcoming' as const }), + ); + + // Merge consecutive empty lines into a single empty line + const merged: typeof parsed = []; + for (const line of parsed) { + const isEmpty = !line.text || !line.text.trim(); + if (isEmpty && merged.length > 0) { + const prev = merged[merged.length - 1]; + const prevEmpty = !prev.text || !prev.text.trim(); + if (prevEmpty) { + // extend previous duration to cover this line + const prevEnd = prev.timeInMs + prev.duration; + const thisEnd = line.timeInMs + line.duration; + const newEnd = Math.max(prevEnd, thisEnd); + prev.duration = newEnd - prev.timeInMs; + continue; // skip adding this line + } + } + merged.push(line); + } + + return merged; + })() : undefined, lyrics: lyrics, }; From a1dd9fe4a77728599d549d3570204c720d73ed73 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 01:09:52 +0800 Subject: [PATCH 33/54] fix(synced-lyrics): ensure final empty line exists in YTMusic synced lyrics --- .../synced-lyrics/providers/YTMusic.ts | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/plugins/synced-lyrics/providers/YTMusic.ts b/src/plugins/synced-lyrics/providers/YTMusic.ts index a655289a7f..24926b1ed3 100644 --- a/src/plugins/synced-lyrics/providers/YTMusic.ts +++ b/src/plugins/synced-lyrics/providers/YTMusic.ts @@ -51,13 +51,14 @@ export class YTMusic implements LyricProvider { const synced = syncedLines?.length && syncedLines[0]?.cueRange ? syncedLines.map((it) => ({ - time: this.millisToTime(parseInt(it.cueRange.startTimeMilliseconds)), - timeInMs: parseInt(it.cueRange.startTimeMilliseconds), - duration: parseInt(it.cueRange.endTimeMilliseconds) - - parseInt(it.cueRange.startTimeMilliseconds), - text: it.lyricLine.trim() === '♪' ? '' : it.lyricLine.trim(), - status: 'upcoming' as const, - })) + time: this.millisToTime(parseInt(it.cueRange.startTimeMilliseconds)), + timeInMs: parseInt(it.cueRange.startTimeMilliseconds), + duration: + parseInt(it.cueRange.endTimeMilliseconds) - + parseInt(it.cueRange.startTimeMilliseconds), + text: it.lyricLine.trim() === '♪' ? '' : it.lyricLine.trim(), + status: 'upcoming' as const, + })) : undefined; const plain = !synced @@ -66,9 +67,9 @@ export class YTMusic implements LyricProvider { : contents?.messageRenderer ? contents?.messageRenderer?.text?.runs?.map((it) => it.text).join('\n') : contents?.sectionListRenderer?.contents?.[0] - ?.musicDescriptionShelfRenderer?.description?.runs?.map((it) => - it.text - )?.join('\n') + ?.musicDescriptionShelfRenderer?.description?.runs + ?.map((it) => it.text) + ?.join('\n') : undefined; if (typeof plain === 'string' && plain === 'Lyrics not available') { @@ -85,6 +86,23 @@ export class YTMusic implements LyricProvider { }); } + // ensure a final empty line exists + if (synced?.length) { + const last = synced[synced.length - 1]; + const lastEnd = parseInt(last.timeInMs.toString()) + last.duration; + + // youtube sometimes omits trailing silence, add our own + if (last.text !== '') { + synced.push({ + duration: 0, + text: '', + time: this.millisToTime(lastEnd), + timeInMs: lastEnd, + status: 'upcoming' as const, + }); + } + } + return { title, artists: [artist], From da20ab934ce43a9f4956f5f8b1af94ef8c6f2149 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 01:16:26 +0800 Subject: [PATCH 34/54] feat(synced-lyrics): support enhanced effect, precise timing, and final empty line handling --- .../synced-lyrics/renderer/renderer.tsx | 80 ++++++++++++++++--- 1 file changed, 70 insertions(+), 10 deletions(-) diff --git a/src/plugins/synced-lyrics/renderer/renderer.tsx b/src/plugins/synced-lyrics/renderer/renderer.tsx index 24c3d70102..be1eb1284e 100644 --- a/src/plugins/synced-lyrics/renderer/renderer.tsx +++ b/src/plugins/synced-lyrics/renderer/renderer.tsx @@ -10,7 +10,7 @@ import { type VirtualizerHandle, VList } from 'virtua/solid'; import { LyricsPicker } from './components/LyricsPicker'; -import { selectors } from './utils'; +import { selectors, getSeekTime, SFont } from './utils'; import { ErrorDisplay, @@ -34,6 +34,27 @@ createEffect(() => { // Set the line effect switch (config()?.lineEffect) { + case 'enhanced': + root.style.setProperty('--lyrics-font-size', '3rem'); + root.style.setProperty('--lyrics-line-height', '1.333'); + root.style.setProperty('--lyrics-width', '100%'); + root.style.setProperty('--lyrics-padding', '12.5px'); + + root.style.setProperty( + '--lyrics-animations', + 'lyrics-glow var(--lyrics-glow-duration) forwards, lyrics-wobble var(--lyrics-wobble-duration) forwards', + ); + + root.style.setProperty('--lyrics-inactive-font-weight', '700'); + root.style.setProperty('--lyrics-inactive-opacity', '0.33'); + root.style.setProperty('--lyrics-inactive-scale', '0.95'); + root.style.setProperty('--lyrics-inactive-offset', '0'); + + root.style.setProperty('--lyrics-active-font-weight', '700'); + root.style.setProperty('--lyrics-active-opacity', '1'); + root.style.setProperty('--lyrics-active-scale', '1'); + root.style.setProperty('--lyrics-active-offset', '0'); + break; case 'fancy': root.style.setProperty('--lyrics-font-size', '3rem'); root.style.setProperty('--lyrics-line-height', '1.333'); @@ -174,6 +195,7 @@ export const LyricsRenderer = () => { }; onMount(() => { + SFont(); const vList = document.querySelector('.synced-lyrics-vlist'); tab.addEventListener('mousemove', mousemoveListener); @@ -190,6 +212,9 @@ export const LyricsRenderer = () => { const [children, setChildren] = createSignal([ { kind: 'LoadingKaomoji' }, ]); + const [firstEmptyIndex, setFirstEmptyIndex] = createSignal( + null, + ); createEffect(() => { const current = currentLyrics(); @@ -210,20 +235,36 @@ export const LyricsRenderer = () => { } if (data?.lines) { - return data.lines.map((line) => ({ + const lines = data.lines; + const firstEmpty = lines.findIndex((l) => !l.text?.trim()); + setFirstEmptyIndex(firstEmpty === -1 ? null : firstEmpty); + + return lines.map((line) => ({ kind: 'SyncedLine' as const, line, })); } if (data?.lyrics) { - const lines = data.lyrics.split('\n').filter((line) => line.trim()); + const rawLines = data.lyrics.split('\n'); + + // Preserve a single trailing empty line if provided by the provider + const hasTrailingEmpty = + rawLines.length > 0 && rawLines[rawLines.length - 1].trim() === ''; + + const lines = rawLines.filter((line, idx) => { + if (line.trim()) return true; + // keep only the final empty line (for padding) if it exists + return hasTrailingEmpty && idx === rawLines.length - 1; + }); + return lines.map((line) => ({ kind: 'PlainLine' as const, line, })); } + setFirstEmptyIndex(null); return [{ kind: 'NotFoundKaomoji' }]; }); }); @@ -232,23 +273,37 @@ export const LyricsRenderer = () => { ('previous' | 'current' | 'upcoming')[] >([]); createEffect(() => { - const time = currentTime(); + const precise = config()?.preciseTiming ?? false; const data = currentLyrics()?.data; + const currentTimeMs = currentTime(); - if (!data || !data.lines) return setStatuses([]); + if (!data || !data.lines) { + setStatuses([]); + return; + } const previous = untrack(statuses); + const current = data.lines.map((line) => { - if (line.timeInMs >= time) return 'upcoming'; - if (time - line.timeInMs >= line.duration) return 'previous'; + const startTimeMs = getSeekTime(line.timeInMs, precise) * 1000; + const endTimeMs = + getSeekTime(line.timeInMs + line.duration, precise) * 1000; + + if (currentTimeMs < startTimeMs) return 'upcoming'; + if (currentTimeMs >= endTimeMs) return 'previous'; return 'current'; }); - if (previous.length !== current.length) return setStatuses(current); - if (previous.every((status, idx) => status === current[idx])) return; + if (previous.length !== current.length) { + setStatuses(current); + return; + } + + if (previous.every((status, idx) => status === current[idx])) { + return; + } setStatuses(current); - return; }); const [currentIndex, setCurrentIndex] = createSignal(0); @@ -302,6 +357,11 @@ export const LyricsRenderer = () => { From 53b575154f370ec46befe048de4564d66c7b7d85 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 01:17:21 +0800 Subject: [PATCH 35/54] feat(synced-lyrics): add getSeekTime and SFont utils --- src/plugins/synced-lyrics/renderer/utils.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/plugins/synced-lyrics/renderer/utils.tsx b/src/plugins/synced-lyrics/renderer/utils.tsx index 1c6a410bd2..2470c182a6 100644 --- a/src/plugins/synced-lyrics/renderer/utils.tsx +++ b/src/plugins/synced-lyrics/renderer/utils.tsx @@ -213,3 +213,16 @@ export const romanize = async (line: string) => { return line; }; + +// timeInMs to seek time in seconds (precise or rounded to nearest second for preciseTiming) +export const getSeekTime = (timeInMs: number, precise: boolean) => + precise ? timeInMs / 1000 : Math.round(timeInMs / 1000); + +export const SFont = () => { + if (document.getElementById('satoshi-font-link')) return; + const link = document.createElement('link'); + link.id = 'satoshi-font-link'; + link.rel = 'stylesheet'; + link.href = 'https://api.fontshare.com/v2/css?f[]=satoshi@1&display=swap'; + document.head.appendChild(link); +}; From 097cc934a913981f2f0ff7efdc70e789785d8f57 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 14 Sep 2025 01:20:02 +0800 Subject: [PATCH 36/54] feat(synced-lyrics): improve SyncedLine rendering and precise timing --- .../renderer/components/SyncedLine.tsx | 148 ++++++++++++++---- 1 file changed, 115 insertions(+), 33 deletions(-) diff --git a/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx index b34f0982ea..cc75ac1a03 100644 --- a/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx +++ b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx @@ -7,7 +7,7 @@ import { type LineLyrics } from '@/plugins/synced-lyrics/types'; import { config, currentTime } from '../renderer'; import { _ytAPI } from '..'; -import { canonicalize, romanize, simplifyUnicode } from '../utils'; +import { canonicalize, romanize, simplifyUnicode, getSeekTime } from '../utils'; interface SyncedLineProps { scroller: VirtualizerHandle; @@ -15,6 +15,28 @@ interface SyncedLineProps { line: LineLyrics; status: 'upcoming' | 'current' | 'previous'; + isFinalLine?: boolean; + isFirstEmptyLine?: boolean; +} + +function formatTime(timeInMs: number, preciseTiming: boolean): string { + if (!preciseTiming) { + const totalSeconds = Math.round(timeInMs / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + return `${minutes.toString().padStart(2, '0')}:${seconds + .toString() + .padStart(2, '0')}`; + } + + const minutes = Math.floor(timeInMs / 60000); + const seconds = Math.floor((timeInMs % 60000) / 1000); + const ms = Math.floor((timeInMs % 1000) / 10); + + return `${minutes.toString().padStart(2, '0')}:${seconds + .toString() + .padStart(2, '0')}.${ms.toString().padStart(2, '0')}`; } const EmptyLine = (props: SyncedLineProps) => { @@ -26,54 +48,111 @@ const EmptyLine = (props: SyncedLineProps) => { const index = createMemo(() => { const progress = currentTime() - props.line.timeInMs; const total = props.line.duration; + const stepCount = states().length; + const precise = config()?.preciseTiming ?? false; + + if (stepCount === 1) return 0; - const percentage = Math.min(1, progress / total); - return Math.max(0, Math.floor((states().length - 1) * percentage)); + let earlyCut: number; + if (total > 3000) { + earlyCut = 1000; + } else if (total >= 1000) { + const ratio = (total - 1000) / 2000; + const addend = ratio * 500; + earlyCut = 500 + addend; + } else { + earlyCut = Math.min(total * 0.8, total - 150); + } + + const effectiveTotal = + total <= 1000 + ? total - earlyCut + : precise + ? total - earlyCut + : Math.round((total - earlyCut) / 1000) * 1000; + + if (effectiveTotal <= 0) return 0; + + const effectiveProgress = precise + ? progress + : Math.round(progress / 1000) * 1000; + const percentage = Math.min(1, effectiveProgress / effectiveTotal); + + return Math.max(0, Math.floor((stepCount - 1) * percentage)); + }); + + const shouldRenderPlaceholder = createMemo(() => { + const isEmpty = !props.line.text?.trim(); + const showEmptySymbols = config()?.showEmptyLineSymbols ?? false; + + return isEmpty + ? showEmptySymbols || props.status === 'current' + : props.status === 'current'; + }); + + const isHighlighted = createMemo(() => props.status === 'current'); + const isFinalEmpty = createMemo(() => { + return props.isFinalLine && !props.line.text?.trim(); + }); + + const shouldRemovePadding = createMemo(() => { + // remove padding only when this is the first empty line and the configured label is blank (empty string or NBSP) + if (!props.isFirstEmptyLine) return false; + const defaultText = config()?.defaultTextString ?? ''; + const first = Array.isArray(defaultText) ? defaultText[0] : defaultText; + return first === '' || first === '\u00A0'; }); return (
    { - _ytAPI?.seekTo((props.line.timeInMs + 10) / 1000); - }} + class={`synced-line ${props.status} ${isFinalEmpty() ? 'final-empty' : ''} ${shouldRemovePadding() ? 'no-padding' : ''}`} + onClick={() => + _ytAPI?.seekTo( + getSeekTime(props.line.timeInMs, config()?.preciseTiming ?? false), + ) + } >
    -
    - + {props.isFinalLine && !props.line.text?.trim() ? ( - - } - when={states().length > 1} - > - - + + + - + ) : ( + + {(text, i) => ( + + + + )} + + )}
    @@ -98,7 +177,8 @@ export const SyncedLine = (props: SyncedLineProps) => {
    { - _ytAPI?.seekTo((props.line.timeInMs + 10) / 1000); + const precise = config()?.preciseTiming ?? false; + _ytAPI?.seekTo(getSeekTime(props.line.timeInMs, precise)); }} >
    @@ -106,7 +186,9 @@ export const SyncedLine = (props: SyncedLineProps) => { text={{ runs: [ { - text: config()?.showTimeCodes ? `[${props.line.time}] ` : '', + text: config()?.showTimeCodes + ? `[${formatTime(props.line.timeInMs, config()?.preciseTiming ?? false)}] ` + : '', }, ], }} From 0b5e494879f029ea421e9d75e40bb9ee9ac3879b Mon Sep 17 00:00:00 2001 From: RobRoid Date: Mon, 15 Sep 2025 03:39:19 +0800 Subject: [PATCH 37/54] refactor(synced-lyrics): use ProviderNames enum instead of hardcoded string --- .../synced-lyrics/renderer/components/LyricsPicker.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx index 6f6ac92a71..6be9f484c3 100644 --- a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx +++ b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx @@ -15,6 +15,7 @@ import * as z from 'zod'; import { type ProviderName, + ProviderNames, providerNames, ProviderNameSchema, type ProviderState, @@ -47,7 +48,9 @@ const shouldSwitchProvider = (providerData: ProviderState) => { const providerBias = (p: ProviderName) => (lyricsStore.lyrics[p].state === 'done' ? 1 : -1) + (lyricsStore.lyrics[p].data?.lines?.length ? 2 : -1) + - (lyricsStore.lyrics[p].data?.lines?.length && p === 'YTMusic' ? 1 : 0) + + (lyricsStore.lyrics[p].data?.lines?.length && p === ProviderNames.YTMusic + ? 1 + : 0) + (lyricsStore.lyrics[p].data?.lyrics ? 1 : -1); const pickBestProvider = () => { From 5e8a003dadd2245904d1179712b57016c9aa36fd Mon Sep 17 00:00:00 2001 From: RobRoid Date: Fri, 19 Sep 2025 19:54:28 +0800 Subject: [PATCH 38/54] feat(synced-lyrics): enable display of empty line symbols by default --- src/plugins/synced-lyrics/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/synced-lyrics/index.ts b/src/plugins/synced-lyrics/index.ts index b5fb3e623d..53c26afedd 100644 --- a/src/plugins/synced-lyrics/index.ts +++ b/src/plugins/synced-lyrics/index.ts @@ -21,7 +21,7 @@ export default createPlugin({ showTimeCodes: false, defaultTextString: '•••', lineEffect: 'enhanced', - showEmptyLineSymbols: false, + showEmptyLineSymbols: true, romanization: true, } satisfies SyncedLyricsPluginConfig as SyncedLyricsPluginConfig, From 7f0bd97a366cbc0c7c07973391b62824ad85de0e Mon Sep 17 00:00:00 2001 From: RobRoid Date: Fri, 19 Sep 2025 20:33:15 +0800 Subject: [PATCH 39/54] feat(synced-lyrics): enhance styling with hover effects, empty line handling, and transform improvements --- src/plugins/synced-lyrics/style.css | 139 ++++++++++++++++++---------- 1 file changed, 89 insertions(+), 50 deletions(-) diff --git a/src/plugins/synced-lyrics/style.css b/src/plugins/synced-lyrics/style.css index d3fac7be42..cee03cff88 100644 --- a/src/plugins/synced-lyrics/style.css +++ b/src/plugins/synced-lyrics/style.css @@ -27,7 +27,9 @@ --lyrics-padding: 0; /* Typography */ - --lyrics-font-family: "Satoshi", sans-serif !important; + --lyrics-font-family: "Satoshi", Avenir, -apple-system, BlinkMacSystemFont, + Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue, + sans-serif; --lyrics-font-size: clamp(1.4rem, 1.1vmax, 3rem); --lyrics-line-height: var(--ytmusic-body-line-height); --lyrics-width: 100%; @@ -50,12 +52,22 @@ --lyrics-animations: lyrics-glow var(--lyrics-glow-duration) forwards, lyrics-wobble var(--lyrics-wobble-duration) forwards; --lyrics-scale-duration: 0.166s; + --lyrics-scale-hover-duration: 0.3s; --lyrics-opacity-transition: 0.33s; --lyrics-glow-duration: var(--lyrics-duration); --lyrics-wobble-duration: calc(var(--lyrics-duration) / 2); /* Colors */ --glow-color: rgba(255, 255, 255, 0.5); + + /* Other */ + --lyrics-hover-scale: 1; + --lyrics-hover-opacity: 0.33; + --lyrics-hover-empty-opacity: 1; + + --lyrics-empty-opacity: 1; + + --lyrics-will-change: auto; } .lyric-container { @@ -68,28 +80,77 @@ text-align: left !important; } -.synced-line { +.synced-line, +.synced-emptyline { width: var(--lyrics-width, 100%); & .text-lyrics { cursor: pointer; /*fix cuted lyrics-glow and romanized j at line start */ padding-left: 1.5rem; + transform-origin: center left; + transition: transform var(--lyrics-scale-hover-duration) ease-in-out, + opacity var(--lyrics-opacity-transition) ease; + /* will-change fixes jitter but may impact performance, remove if needed */ + will-change: var(--lyrics-will-change); + + & > span > span { + transition: transform var(--lyrics-scale-hover-duration) ease-in-out, + opacity var(--lyrics-opacity-transition) ease; + } + + & > .romaji { + color: var(--ytmusic-text-secondary) !important; + font-size: calc(var(--lyrics-font-size) * 0.7) !important; + font-style: italic !important; + } } - & .text-lyrics > .romaji { - color: var(--ytmusic-text-secondary) !important; - font-size: calc(var(--lyrics-font-size) * 0.7) !important; - font-style: italic !important; + &.final-empty .text-lyrics { + padding-top: 0 !important; + padding-bottom: 0 !important; + height: 1em; + display: block; + } + + &.no-padding .text-lyrics { + padding-top: 0 !important; + padding-bottom: 0 !important; + height: 0.3em; + overflow: hidden; } } +/* Current lines */ +.synced-line.current .text-lyrics > span > span, +.synced-emptyline.current .text-lyrics > span > span { + opacity: var(--lyrics-active-opacity); + animation: var(--lyrics-animations); +} + +/* Non current empty lines */ +.synced-emptyline:not(.current) .text-lyrics { + opacity: var(--lyrics-empty-opacity); +} + +/* Hover effects for non-current lines (enhanced only) */ +.enhanced-lyrics .synced-line:not(.current):hover .text-lyrics, +.enhanced-lyrics .synced-emptyline:not(.current):hover .text-lyrics { + opacity: 1 !important; + transform: scale(var(--lyrics-hover-scale)); +} + +.enhanced-lyrics .synced-line:not(.current):hover .text-lyrics > span > span, +.enhanced-lyrics .synced-emptyline:not(.current):hover .text-lyrics > span > span { + opacity: var(--lyrics-hover-opacity, var(--lyrics-hover-empty-opacity)) !important; +} + .synced-lyrics { display: block; - justify-content: left; + /* justify-content: left; */ text-align: left; margin: 0.5rem 20px 0.5rem 0; - transition: all 0.3s ease-in-out; + transition: all 0.3s ease; } .warning-lyrics { @@ -104,10 +165,8 @@ line-height: var(--lyrics-line-height) !important; padding-top: var(--lyrics-padding); padding-bottom: var(--lyrics-padding); - scale: var(--lyrics-inactive-scale); - translate: var(--lyrics-inactive-offset); - transition: scale var(--lyrics-scale-duration), translate 0.3s ease-in-out; - + transform: scale(var(--lyrics-inactive-scale)) translate(var(--lyrics-inactive-offset)); + transition: transform var(--lyrics-scale-duration) ease-in-out; display: block; text-align: left; margin: var(--global-margin) 0; @@ -115,9 +174,9 @@ &.lrc-header { color: var(--ytmusic-color-grey5) !important; - scale: 0.9; + transform: scale(0.9); height: fit-content; - padding: 0; + /* padding: 0; */ padding-block: 0.2em; } @@ -128,22 +187,17 @@ } } -.text-lyrics > span > span { +.text-lyrics > span > span, +.text-lyrics .placeholder { display: inline-block; white-space: pre-wrap; opacity: var(--lyrics-inactive-opacity); transition: opacity var(--lyrics-opacity-transition); } -.text-lyrics .placeholder { - opacity: var(--lyrics-inactive-opacity); - transition: opacity var(--lyrics-opacity-transition); -} - .current .text-lyrics { font-weight: var(--lyrics-active-font-weight) !important; - scale: var(--lyrics-active-scale); - translate: var(--lyrics-active-offset); + transform: scale(var(--lyrics-active-scale)) translate(var(--lyrics-active-offset)); } .current .text-lyrics > span > span { @@ -151,21 +205,6 @@ animation: var(--lyrics-animations); } -.synced-line.final-empty .text-lyrics { - color: transparent; - padding-top: 0 !important; - padding-bottom: 0 !important; - height: 1em; - display: block; -} - -.synced-line.no-padding .text-lyrics { - padding-top: 0 !important; - padding-bottom: 0 !important; - height: 0.3em; - overflow: hidden; -} - .lyrics-renderer { display: flex; flex-direction: column; @@ -218,8 +257,8 @@ cursor: pointer; width: 5px; height: 5px; - margin: 0 4px 0; - border-radius: 200px; + margin: 0 4px; + border-radius: 50%; border: 1px solid #6e7c7c7f; } @@ -252,20 +291,20 @@ div:has(> .lyrics-picker) { .fade { opacity: 0; - transition: var(--lyrics-opacity-transition) ease-in-out; -} + transition: var(--lyrics-opacity-transition) ease; -.fade.show { - opacity: var(--lyrics-active-opacity); -} + &.show { + opacity: var(--lyrics-active-opacity); + } -.fade.dim { - opacity: var(--lyrics-inactive-opacity); -} + &.dim { + opacity: var(--lyrics-inactive-opacity); + } -.fade, -.placeholder { - animation-name: none; + &, + .placeholder { + animation-name: none; + } } /* Animations */ From 920afd1322e7202c9a1524b3cd086cb09b89f76b Mon Sep 17 00:00:00 2001 From: RobRoid Date: Fri, 19 Sep 2025 21:08:36 +0800 Subject: [PATCH 40/54] fix(synced-lyrics): correct millisToTime calculation using modulo for seconds and centiseconds --- src/plugins/synced-lyrics/providers/YTMusic.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/plugins/synced-lyrics/providers/YTMusic.ts b/src/plugins/synced-lyrics/providers/YTMusic.ts index 24926b1ed3..3f3db4dca3 100644 --- a/src/plugins/synced-lyrics/providers/YTMusic.ts +++ b/src/plugins/synced-lyrics/providers/YTMusic.ts @@ -114,11 +114,12 @@ export class YTMusic implements LyricProvider { private millisToTime(millis: number) { const minutes = Math.floor(millis / 60000); - const seconds = Math.floor((millis - minutes * 60 * 1000) / 1000); - const remaining = (millis - minutes * 60 * 1000 - seconds * 1000) / 10; + const seconds = Math.floor((millis % 60000) / 1000); + const centiseconds = Math.floor((millis % 1000) / 10); + return `${minutes.toString().padStart(2, '0')}:${seconds .toString() - .padStart(2, '0')}.${remaining.toString().padStart(2, '0')}`; + .padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`; } // RATE LIMITED (2 req per sec) From 1c67dfee9a4ce4630db6a063660502e82a5b1a84 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Fri, 19 Sep 2025 21:09:45 +0800 Subject: [PATCH 41/54] refactor(synced-lyrics): rename EmptyLine class to synced-emptyline and simplify seekTo handler --- .../synced-lyrics/renderer/components/SyncedLine.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx index cc75ac1a03..cb34a8afa8 100644 --- a/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx +++ b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx @@ -105,12 +105,11 @@ const EmptyLine = (props: SyncedLineProps) => { return (
    - _ytAPI?.seekTo( - getSeekTime(props.line.timeInMs, config()?.preciseTiming ?? false), - ) - } + class={`synced-emptyline ${props.status} ${isFinalEmpty() ? 'final-empty' : ''} ${shouldRemovePadding() ? 'no-padding' : ''}`} + onClick={() => { + const precise = config()?.preciseTiming ?? false; + _ytAPI?.seekTo(getSeekTime(props.line.timeInMs, precise)); + }} >
    Date: Fri, 19 Sep 2025 21:12:25 +0800 Subject: [PATCH 42/54] feat(synced-lyrics): add enhanced scroll animation and hover styles to renderer --- .../synced-lyrics/renderer/renderer.tsx | 319 +++++++++++++++++- 1 file changed, 303 insertions(+), 16 deletions(-) diff --git a/src/plugins/synced-lyrics/renderer/renderer.tsx b/src/plugins/synced-lyrics/renderer/renderer.tsx index be1eb1284e..e174670f47 100644 --- a/src/plugins/synced-lyrics/renderer/renderer.tsx +++ b/src/plugins/synced-lyrics/renderer/renderer.tsx @@ -1,3 +1,4 @@ +/* eslint-disable stylistic/no-mixed-operators */ import { createEffect, createSignal, @@ -28,17 +29,28 @@ export const [isVisible, setIsVisible] = createSignal(false); export const [config, setConfig] = createSignal(null); +export const [fastScrollUntil, setFastScrollUntil] = createSignal(0); +export const requestFastScroll = (windowMs = 700) => + setFastScrollUntil(performance.now() + windowMs); + +export const [suppressFastUntil, setSuppressFastUntil] = + createSignal(0); +export const suppressFastScroll = (windowMs = 1200) => + setSuppressFastUntil(performance.now() + windowMs); + createEffect(() => { if (!config()?.enabled) return; const root = document.documentElement; - - // Set the line effect - switch (config()?.lineEffect) { + const lineEffect = config()?.lineEffect || 'none'; + document.body.classList.toggle('enhanced-lyrics', lineEffect === 'enhanced'); + switch (lineEffect) { case 'enhanced': + root.style.setProperty('--lyrics-font-family', 'Satoshi, sans-serif'); root.style.setProperty('--lyrics-font-size', '3rem'); root.style.setProperty('--lyrics-line-height', '1.333'); root.style.setProperty('--lyrics-width', '100%'); root.style.setProperty('--lyrics-padding', '12.5px'); + root.style.setProperty('--lyrics-will-change', 'transform, opacity'); root.style.setProperty( '--lyrics-animations', @@ -54,8 +66,18 @@ createEffect(() => { root.style.setProperty('--lyrics-active-opacity', '1'); root.style.setProperty('--lyrics-active-scale', '1'); root.style.setProperty('--lyrics-active-offset', '0'); + + root.style.setProperty('--lyrics-hover-scale', '0.975'); + root.style.setProperty('--lyrics-hover-opacity', '0.585'); + root.style.setProperty('--lyrics-hover-empty-opacity', '1'); + + root.style.setProperty('--lyrics-empty-opacity', '0.495'); break; case 'fancy': + root.style.setProperty( + '--lyrics-font-family', + '"Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif', + ); root.style.setProperty('--lyrics-font-size', '3rem'); root.style.setProperty('--lyrics-line-height', '1.333'); root.style.setProperty('--lyrics-width', '100%'); @@ -64,6 +86,7 @@ createEffect(() => { '--lyrics-animations', 'lyrics-glow var(--lyrics-glow-duration) forwards, lyrics-wobble var(--lyrics-wobble-duration) forwards', ); + root.style.setProperty('--lyrics-will-change', 'auto'); root.style.setProperty('--lyrics-inactive-font-weight', '700'); root.style.setProperty('--lyrics-inactive-opacity', '0.33'); @@ -74,8 +97,18 @@ createEffect(() => { root.style.setProperty('--lyrics-active-opacity', '1'); root.style.setProperty('--lyrics-active-scale', '1'); root.style.setProperty('--lyrics-active-offset', '0'); + + root.style.setProperty('--lyrics-hover-scale', '0.95'); + root.style.setProperty('--lyrics-hover-opacity', '0.33'); + root.style.setProperty('--lyrics-hover-empty-opacity', '1'); + + root.style.setProperty('--lyrics-empty-opacity', '1'); break; case 'scale': + root.style.setProperty( + '--lyrics-font-family', + '"Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif', + ); root.style.setProperty( '--lyrics-font-size', 'clamp(1.4rem, 1.1vmax, 3rem)', @@ -87,6 +120,7 @@ createEffect(() => { root.style.setProperty('--lyrics-width', '83%'); root.style.setProperty('--lyrics-padding', '0'); root.style.setProperty('--lyrics-animations', 'none'); + root.style.setProperty('--lyrics-will-change', 'auto'); root.style.setProperty('--lyrics-inactive-font-weight', '400'); root.style.setProperty('--lyrics-inactive-opacity', '0.33'); @@ -97,8 +131,18 @@ createEffect(() => { root.style.setProperty('--lyrics-active-opacity', '1'); root.style.setProperty('--lyrics-active-scale', '1.2'); root.style.setProperty('--lyrics-active-offset', '0'); + + root.style.setProperty('--lyrics-hover-scale', '1'); + root.style.setProperty('--lyrics-hover-opacity', '0.33'); + root.style.setProperty('--lyrics-hover-empty-opacity', '1'); + + root.style.setProperty('--lyrics-empty-opacity', '1'); break; case 'offset': + root.style.setProperty( + '--lyrics-font-family', + '"Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif', + ); root.style.setProperty( '--lyrics-font-size', 'clamp(1.4rem, 1.1vmax, 3rem)', @@ -110,6 +154,7 @@ createEffect(() => { root.style.setProperty('--lyrics-width', '100%'); root.style.setProperty('--lyrics-padding', '0'); root.style.setProperty('--lyrics-animations', 'none'); + root.style.setProperty('--lyrics-will-change', 'auto'); root.style.setProperty('--lyrics-inactive-font-weight', '400'); root.style.setProperty('--lyrics-inactive-opacity', '0.33'); @@ -120,8 +165,18 @@ createEffect(() => { root.style.setProperty('--lyrics-active-opacity', '1'); root.style.setProperty('--lyrics-active-scale', '1'); root.style.setProperty('--lyrics-active-offset', '5%'); + + root.style.setProperty('--lyrics-hover-scale', '1'); + root.style.setProperty('--lyrics-hover-opacity', '0.33'); + root.style.setProperty('--lyrics-hover-empty-opacity', '1'); + + root.style.setProperty('--lyrics-empty-opacity', '1'); break; case 'focus': + root.style.setProperty( + '--lyrics-font-family', + '"Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif', + ); root.style.setProperty( '--lyrics-font-size', 'clamp(1.4rem, 1.1vmax, 3rem)', @@ -133,6 +188,7 @@ createEffect(() => { root.style.setProperty('--lyrics-width', '100%'); root.style.setProperty('--lyrics-padding', '0'); root.style.setProperty('--lyrics-animations', 'none'); + root.style.setProperty('--lyrics-will-change', 'auto'); root.style.setProperty('--lyrics-inactive-font-weight', '400'); root.style.setProperty('--lyrics-inactive-opacity', '0.33'); @@ -143,6 +199,12 @@ createEffect(() => { root.style.setProperty('--lyrics-active-opacity', '1'); root.style.setProperty('--lyrics-active-scale', '1'); root.style.setProperty('--lyrics-active-offset', '0'); + + root.style.setProperty('--lyrics-hover-scale', '1'); + root.style.setProperty('--lyrics-hover-opacity', '0.33'); + root.style.setProperty('--lyrics-hover-empty-opacity', '1'); + + root.style.setProperty('--lyrics-empty-opacity', '1'); break; } }); @@ -164,10 +226,18 @@ type LyricsRendererChild = const lyricsPicker: LyricsRendererChild = { kind: 'LyricsPicker' }; export const [currentTime, setCurrentTime] = createSignal(-1); +export const [scrollTargetIndex, setScrollTargetIndex] = + createSignal(0); export const LyricsRenderer = () => { const [scroller, setScroller] = createSignal(); const [stickyRef, setStickRef] = createSignal(null); + let prevTimeForScroll = -1; + let prevIndexForFast = -1; + + let scrollAnimRaf: number | null = null; + let scrollAnimActive = false; + const tab = document.querySelector(selectors.body.tabRenderer)!; let mouseCoord = 0; @@ -248,7 +318,7 @@ export const LyricsRenderer = () => { if (data?.lyrics) { const rawLines = data.lyrics.split('\n'); - // Preserve a single trailing empty line if provided by the provider + // preserve a single trailing empty line if provided by the provider const hasTrailingEmpty = rawLines.length > 0 && rawLines[rawLines.length - 1].trim() === ''; @@ -299,11 +369,9 @@ export const LyricsRenderer = () => { return; } - if (previous.every((status, idx) => status === current[idx])) { - return; + if (!previous.every((status, idx) => status === current[idx])) { + setStatuses(current); } - - setStatuses(current); }); const [currentIndex, setCurrentIndex] = createSignal(0); @@ -313,20 +381,239 @@ export const LyricsRenderer = () => { setCurrentIndex(index); }); + // scroll effect createEffect(() => { + const visible = isVisible(); const current = currentLyrics(); - const idx = currentIndex(); - const maxIdx = untrack(statuses).length - 1; + const targetIndex = scrollTargetIndex(); + const maxIndex = untrack(statuses).length - 1; + const scrollerInstance = scroller(); - if (!scroller() || !current.data?.lines) return; + if (!visible || !scrollerInstance || !current.data?.lines) return; // hacky way to make the "current" line scroll to the center of the screen - const scrollIndex = Math.min(idx + 1, maxIdx); + const scrollIndex = Math.min(targetIndex + 1, maxIndex); + + // animation duration + const calculateDuration = ( + distance: number, + jumpSize: number, + fast: boolean, + ) => { + // fast scroll for others + if (fast) { + const d = 260 + distance * 0.28; + return Math.min(680, Math.max(240, d)); + } - scroller()!.scrollToIndex(scrollIndex, { - smooth: true, - align: 'center', - }); + let minDuration = 850; + let maxDuration = 1650; + let duration = 550 + distance * 0.7; + + if (jumpSize === 1) { + minDuration = 1000; + maxDuration = 1800; + duration = 700 + distance * 0.8; + } else if (jumpSize > 3) { + minDuration = 600; + maxDuration = 1400; + duration = 400 + distance * 0.6; + } + + return Math.min(maxDuration, Math.max(minDuration, duration)); + }; + + // easing function + const easeInOutCubic = (t: number) => { + if (t < 0.5) { + return 4 * t ** 3; + } + const t1 = -2 * t + 2; + return 1 - t1 ** 3 / 2; + }; + + // target scroll offset + const calculateEnhancedTargetOffset = ( + scrollerInstance: VirtualizerHandle, + scrollIndex: number, + currentIndex: number, + ) => { + const viewportSize = scrollerInstance.viewportSize; + const itemOffset = scrollerInstance.getItemOffset(scrollIndex); + const itemSize = scrollerInstance.getItemSize(scrollIndex); + const maxScroll = scrollerInstance.scrollSize - viewportSize; + + if (currentIndex === 0) return 0; + + const viewportCenter = viewportSize / 2; + const itemCenter = itemSize / 2; + const centerOffset = itemOffset - viewportCenter + itemCenter; + + return Math.max(0, Math.min(centerOffset, maxScroll)); + }; + + // enhanced scroll animation + const performEnhancedScroll = ( + scrollerInstance: VirtualizerHandle, + scrollIndex: number, + currentIndex: number, + fast: boolean, + ) => { + const targetOffset = calculateEnhancedTargetOffset( + scrollerInstance, + scrollIndex, + currentIndex, + ); + const startOffset = scrollerInstance.scrollOffset; + + if (startOffset === targetOffset) return; + + const distance = Math.abs(targetOffset - startOffset); + const jumpSize = Math.abs(scrollIndex - currentIndex); + const duration = calculateDuration(distance, jumpSize, fast); + + // offset start time for responsive feel + const animationStartTimeOffsetMs = fast ? 15 : 170; + const startTime = performance.now() - animationStartTimeOffsetMs; + + scrollAnimActive = false; + if (scrollAnimRaf !== null) cancelAnimationFrame(scrollAnimRaf); + + if (distance < 0.5) { + scrollerInstance.scrollTo(targetOffset); + return; + } + + const animate = (now: number) => { + if (!scrollAnimActive) return; + const elapsed = now - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = easeInOutCubic(progress); + const offsetDiff = (targetOffset - startOffset) * eased; + const currentOffset = startOffset + offsetDiff; + + scrollerInstance.scrollTo(currentOffset); + if (progress < 1 && scrollAnimActive) { + scrollAnimRaf = requestAnimationFrame(animate); + } + }; + + scrollAnimActive = true; + scrollAnimRaf = requestAnimationFrame(animate); + }; + + // validate scroller measurements + const isScrollerReady = ( + scrollerInstance: VirtualizerHandle, + scrollIndex: number, + ) => { + const viewport = scrollerInstance.viewportSize; + const size = scrollerInstance.getItemSize(scrollIndex); + const offset = scrollerInstance.getItemOffset(scrollIndex); + return viewport > 0 && size > 0 && offset >= 0; + }; + + let readyRafId: number | null = null; + + const cleanup = () => { + if (readyRafId !== null) cancelAnimationFrame(readyRafId); + scrollAnimActive = false; + if (scrollAnimRaf !== null) cancelAnimationFrame(scrollAnimRaf); + }; + onCleanup(cleanup); + + // wait for scroller ready + const waitForReady = (tries = 0) => { + const nonEnhanced = config()?.lineEffect !== 'enhanced'; + const scrollerReady = isScrollerReady(scrollerInstance, scrollIndex); + const hasCurrentIndex = !nonEnhanced || currentIndex() >= 0; + + if ((scrollerReady && hasCurrentIndex) || tries >= 20) { + performScroll(); + } else { + readyRafId = requestAnimationFrame(() => waitForReady(tries + 1)); + } + }; + + const performScroll = () => { + const now = performance.now(); + const inFastWindow = now < fastScrollUntil(); + const suppressed = now < suppressFastUntil(); + + if (config()?.lineEffect !== 'enhanced') { + scrollerInstance.scrollToIndex(scrollIndex, { + smooth: true, + align: 'center', + }); + return; + } + + const targetOffset = calculateEnhancedTargetOffset( + scrollerInstance, + scrollIndex, + targetIndex, + ); + const startOffset = scrollerInstance.scrollOffset; + const distance = Math.abs(targetOffset - startOffset); + const viewport = scrollerInstance.viewportSize; + const largeDistance = distance > Math.max(400, viewport * 0.6); + const fast = inFastWindow && !suppressed && largeDistance; + + performEnhancedScroll(scrollerInstance, scrollIndex, targetIndex, fast); + }; + + waitForReady(); + }); + + // handle scroll target updates based on current time + createEffect(() => { + const data = currentLyrics()?.data; + const currentTimeMs = currentTime(); + const idx = currentIndex(); + const lineEffect = config()?.lineEffect; + + if (!data || !data.lines || idx < 0) return; + const jumped = + prevTimeForScroll >= 0 && + Math.abs(currentTimeMs - prevTimeForScroll) > 400; + if ( + jumped && + prevTimeForScroll >= 0 && + performance.now() >= suppressFastUntil() + ) { + const timeDelta = Math.abs(currentTimeMs - prevTimeForScroll); + const lineDelta = + prevIndexForFast >= 0 ? Math.abs(idx - prevIndexForFast) : 0; + if (timeDelta > 1500 || lineDelta >= 5) { + requestFastScroll(1500); + } + } + prevTimeForScroll = currentTimeMs; + + const scrollOffset = scroller()?.scrollOffset ?? 0; + if (idx === 0 && currentTimeMs > 2000 && !jumped && scrollOffset <= 1) { + return; + } + + if (lineEffect === 'enhanced') { + const nextIdx = Math.min(idx + 1, data.lines.length - 1); + const nextLine = data.lines[nextIdx]; + + if (nextLine) { + // start scroll early + const leadInTimeMs = 130; + const timeUntilNextLine = nextLine.timeInMs - currentTimeMs; + + if (timeUntilNextLine <= leadInTimeMs) { + setScrollTargetIndex(nextIdx); + prevIndexForFast = idx; + return; + } + } + } + + prevIndexForFast = idx; + setScrollTargetIndex(idx); }); return ( From e17b82bb1f9ca42239b169e32223afcfa8772d3d Mon Sep 17 00:00:00 2001 From: RobRoid Date: Fri, 19 Sep 2025 21:13:19 +0800 Subject: [PATCH 43/54] feat(synced-lyrics): trigger fast scroll on provider switch and improve transform calc --- .../renderer/components/LyricsPicker.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx index 6be9f484c3..5f24a57cfe 100644 --- a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx +++ b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx @@ -22,7 +22,7 @@ import { } from '../../providers'; import { currentLyrics, lyricsStore, setLyricsStore } from '../store'; import { _ytAPI } from '../index'; -import { config } from '../renderer'; +import { config, requestFastScroll } from '../renderer'; import type { YtIcons } from '@/types/icons'; import type { PlayerAPIEvents } from '@/types/player-api-events'; @@ -141,6 +141,7 @@ export const LyricsPicker = (props: { if (!hasManuallySwitchedProvider()) { const starred = starredProvider(); if (starred !== null) { + requestFastScroll(2500); setLyricsStore('provider', starred); return; } @@ -155,6 +156,7 @@ export const LyricsPicker = (props: { force || providerBias(lyricsStore.provider) < providerBias(provider) ) { + requestFastScroll(2500); setLyricsStore('provider', provider); } } @@ -162,6 +164,7 @@ export const LyricsPicker = (props: { const next = () => { setHasManuallySwitchedProvider(true); + requestFastScroll(2500); setLyricsStore('provider', (prevProvider) => { const idx = providerNames.indexOf(prevProvider); return providerNames[(idx + 1) % providerNames.length]; @@ -170,6 +173,7 @@ export const LyricsPicker = (props: { const previous = () => { setHasManuallySwitchedProvider(true); + requestFastScroll(2500); setLyricsStore('provider', (prevProvider) => { const idx = providerNames.indexOf(prevProvider); return providerNames[ @@ -231,7 +235,7 @@ export const LyricsPicker = (props: {
    @@ -311,7 +315,11 @@ export const LyricsPicker = (props: { {(_, idx) => (
  • setLyricsStore('provider', providerNames[idx()])} + onClick={() => { + setHasManuallySwitchedProvider(true); + requestFastScroll(2500); + setLyricsStore('provider', providerNames[idx()]); + }} style={{ background: idx() === providerIdx() ? 'white' : 'black', }} From d9170217e49fd5c7312d2e7ed9d36729fe63847e Mon Sep 17 00:00:00 2001 From: RobRoid Date: Fri, 19 Sep 2025 22:56:26 +0800 Subject: [PATCH 44/54] fix(synced-lyrics): merge consecutive empty lines and improve time parsing clarity --- src/plugins/synced-lyrics/parsers/lrc.ts | 35 +++++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/plugins/synced-lyrics/parsers/lrc.ts b/src/plugins/synced-lyrics/parsers/lrc.ts index 598b46b081..2a76fdd9cd 100644 --- a/src/plugins/synced-lyrics/parsers/lrc.ts +++ b/src/plugins/synced-lyrics/parsers/lrc.ts @@ -55,10 +55,10 @@ export const LRC = { const ms2 = milliseconds.padEnd(2, '0').slice(0, 2); // Convert to ms (xx → xx0) - const timeInMs = - parseInt(minutes) * 60 * 1000 + - parseInt(seconds) * 1000 + - parseInt(ms2) * 10; + const minutesMs = parseInt(minutes) * 60 * 1000; + const secondsMs = parseInt(seconds) * 1000; + const centisMs = parseInt(ms2) * 10; + const timeInMs = minutesMs + secondsMs + centisMs; const currentLine: LRCLine = { time: `${minutes.padStart(2, '0')}:${seconds.padStart(2, '0')}.${ms2}`, @@ -89,6 +89,33 @@ export const LRC = { }); } + // Merge consecutive empty lines into a single empty line + { + const merged: LRCLine[] = []; + for (const line of lrc.lines) { + const isEmpty = !line.text || !line.text.trim(); + if (isEmpty && merged.length > 0) { + const prev = merged[merged.length - 1]; + const prevEmpty = !prev.text || !prev.text.trim(); + if (prevEmpty) { + const prevEnd = Number.isFinite(prev.duration) + ? prev.timeInMs + prev.duration + : Infinity; + const thisEnd = Number.isFinite(line.duration) + ? line.timeInMs + line.duration + : Infinity; + const newEnd = Math.max(prevEnd, thisEnd); + prev.duration = Number.isFinite(newEnd) + ? newEnd - prev.timeInMs + : Infinity; + continue; + } + } + merged.push(line); + } + lrc.lines = merged; + } + return lrc; }, }; From aedf1f1ed3e1e0cef1cd855b996ad4e808e3f099 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Fri, 19 Sep 2025 22:58:41 +0800 Subject: [PATCH 45/54] refactor(synced-lyrics): optimize artist matching and merge consecutive empty lines --- src/plugins/synced-lyrics/providers/LRCLib.ts | 113 ++++++++++-------- 1 file changed, 65 insertions(+), 48 deletions(-) diff --git a/src/plugins/synced-lyrics/providers/LRCLib.ts b/src/plugins/synced-lyrics/providers/LRCLib.ts index 6ffba991b1..3d33caf2c0 100644 --- a/src/plugins/synced-lyrics/providers/LRCLib.ts +++ b/src/plugins/synced-lyrics/providers/LRCLib.ts @@ -77,61 +77,59 @@ export class LRCLib implements LyricProvider { } const filteredResults = []; + const SIM_THRESHOLD = 0.9; for (const item of data) { - const { artistName } = item; - - const artists = artist.split(/[&,]/g).map((i) => i.trim()); - const itemArtists = artistName.split(/[&,]/g).map((i) => i.trim()); + // quick duration guard to avoid expensive similarity on far-off matches + if (Math.abs(item.duration - songDuration) > 15) continue; + if (item.instrumental) continue; - // Try to match using artist name first - const permutations = []; - for (const artistA of artists) { - for (const artistB of itemArtists) { - permutations.push([artistA.toLowerCase(), artistB.toLowerCase()]); - } - } + const { artistName } = item; - for (const artistA of itemArtists) { - for (const artistB of artists) { - permutations.push([artistA.toLowerCase(), artistB.toLowerCase()]); + const artists = artist + .split(/[&,]/g) + .map((i) => i.trim().toLowerCase()) + .filter(Boolean); + const itemArtists = artistName + .split(/[&,]/g) + .map((i) => i.trim().toLowerCase()) + .filter(Boolean); + + // fast path: any exact artist match + let ratio = 0; + if (artists.some((a) => itemArtists.includes(a))) { + ratio = 1; + } else { + // compute best pairwise similarity with early exit + outer: for (const a of artists) { + for (const b of itemArtists) { + const r = jaroWinkler(a, b); + if (r > ratio) ratio = r; + if (ratio >= 0.97) break outer; // good enough, stop early + } } } - let ratio = Math.max(...permutations.map(([x, y]) => jaroWinkler(x, y))); - - // If direct artist match is below threshold and we have tags, try matching with tags - if (ratio <= 0.9 && tags && tags.length > 0) { - // Filter out the artist from tags to avoid duplicate comparisons - const filteredTags = tags.filter( - (tag) => tag.toLowerCase() !== artist.toLowerCase(), + // If direct artist match is below threshold and we have tags, compare tags too + if (ratio <= SIM_THRESHOLD && tags && tags.length > 0) { + const artistSet = new Set(artists); + const filteredTags = Array.from( + new Set( + tags + .map((t) => t.trim().toLowerCase()) + .filter((t) => t && !artistSet.has(t)), + ), ); - const tagPermutations = []; - // Compare each tag with each item artist - for (const tag of filteredTags) { - for (const itemArtist of itemArtists) { - tagPermutations.push([tag.toLowerCase(), itemArtist.toLowerCase()]); + outerTags: for (const t of filteredTags) { + for (const b of itemArtists) { + const r = jaroWinkler(t, b); + if (r > ratio) ratio = r; + if (ratio >= 0.97) break outerTags; } } - - // Compare each item artist with each tag - for (const itemArtist of itemArtists) { - for (const tag of filteredTags) { - tagPermutations.push([itemArtist.toLowerCase(), tag.toLowerCase()]); - } - } - - if (tagPermutations.length > 0) { - const tagRatio = Math.max( - ...tagPermutations.map(([x, y]) => jaroWinkler(x, y)), - ); - - // Use the best match ratio between direct artist match and tag match - ratio = Math.max(ratio, tagRatio); - } } - if (ratio <= 0.9) continue; + if (ratio <= SIM_THRESHOLD) continue; filteredResults.push(item); } @@ -165,9 +163,28 @@ export class LRCLib implements LyricProvider { status: 'upcoming' as const, })); - // If the final parsed line is not empty, append a computed empty line - if (parsed.length > 0) { - const last = parsed[parsed.length - 1]; + // Merge consecutive empty lines into a single empty line + const merged: typeof parsed = []; + for (const line of parsed) { + const isEmpty = !line.text || !line.text.trim(); + if (isEmpty && merged.length > 0) { + const prev = merged[merged.length - 1]; + const prevEmpty = !prev.text || !prev.text.trim(); + if (prevEmpty) { + // extend previous duration to cover this line + const prevEnd = prev.timeInMs + prev.duration; + const thisEnd = line.timeInMs + line.duration; + const newEnd = Math.max(prevEnd, thisEnd); + prev.duration = newEnd - prev.timeInMs; + continue; // skip adding this line + } + } + merged.push(line); + } + + // If the final merged line is not empty, append a computed empty line + if (merged.length > 0) { + const last = merged[merged.length - 1]; const lastIsEmpty = !last.text || !last.text.trim(); if (lastIsEmpty) { // last line already empty, don't append another @@ -191,7 +208,7 @@ export class LRCLib implements LyricProvider { .toString() .padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`; - parsed.push({ + merged.push({ timeInMs: midpoint, time: timeStr, duration: songEnd - midpoint, @@ -205,7 +222,7 @@ export class LRCLib implements LyricProvider { return { title: closestResult.trackName, artists: closestResult.artistName.split(/[&,]/g), - lines: parsed, + lines: merged, }; } else if (plain) { // Fallback to plain if no synced From 5de5cf786f05ea290400387daf68cdbfd056f970 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Fri, 19 Sep 2025 23:01:35 +0800 Subject: [PATCH 46/54] fix(synced-lyrics): improve scroll stability with fallback index and fast-scroll on tab visible --- .../synced-lyrics/renderer/renderer.tsx | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/src/plugins/synced-lyrics/renderer/renderer.tsx b/src/plugins/synced-lyrics/renderer/renderer.tsx index e174670f47..1ee191c0be 100644 --- a/src/plugins/synced-lyrics/renderer/renderer.tsx +++ b/src/plugins/synced-lyrics/renderer/renderer.tsx @@ -381,6 +381,13 @@ export const LyricsRenderer = () => { setCurrentIndex(index); }); + // when lyrics tab becomes visible again, open a short fast-scroll window + createEffect(() => { + if (isVisible()) { + requestFastScroll(1500); + } + }); + // scroll effect createEffect(() => { const visible = isVisible(); @@ -572,7 +579,31 @@ export const LyricsRenderer = () => { const idx = currentIndex(); const lineEffect = config()?.lineEffect; - if (!data || !data.lines || idx < 0) return; + if (!data || !data.lines) return; + + // robust fallback if no line is detected as "current" yet + let effIdx = idx; + if (effIdx < 0) { + const lines = data.lines; + const containing = lines.findIndex((l) => { + const start = l.timeInMs; + const end = l.timeInMs + l.duration; + return currentTimeMs >= start && currentTimeMs < end; + }); + if (containing !== -1) { + effIdx = containing; + } else { + let lastBefore = 0; + for (let j = lines.length - 1; j >= 0; j--) { + if (lines[j].timeInMs <= currentTimeMs) { + lastBefore = j; + break; + } + } + effIdx = lastBefore; + } + } + const jumped = prevTimeForScroll >= 0 && Math.abs(currentTimeMs - prevTimeForScroll) > 400; @@ -583,7 +614,7 @@ export const LyricsRenderer = () => { ) { const timeDelta = Math.abs(currentTimeMs - prevTimeForScroll); const lineDelta = - prevIndexForFast >= 0 ? Math.abs(idx - prevIndexForFast) : 0; + prevIndexForFast >= 0 ? Math.abs(effIdx - prevIndexForFast) : 0; if (timeDelta > 1500 || lineDelta >= 5) { requestFastScroll(1500); } @@ -591,12 +622,12 @@ export const LyricsRenderer = () => { prevTimeForScroll = currentTimeMs; const scrollOffset = scroller()?.scrollOffset ?? 0; - if (idx === 0 && currentTimeMs > 2000 && !jumped && scrollOffset <= 1) { + if (effIdx === 0 && currentTimeMs > 2000 && !jumped && scrollOffset <= 1) { return; } if (lineEffect === 'enhanced') { - const nextIdx = Math.min(idx + 1, data.lines.length - 1); + const nextIdx = Math.min(effIdx + 1, data.lines.length - 1); const nextLine = data.lines[nextIdx]; if (nextLine) { @@ -606,14 +637,14 @@ export const LyricsRenderer = () => { if (timeUntilNextLine <= leadInTimeMs) { setScrollTargetIndex(nextIdx); - prevIndexForFast = idx; + prevIndexForFast = effIdx; return; } } } - prevIndexForFast = idx; - setScrollTargetIndex(idx); + prevIndexForFast = effIdx; + setScrollTargetIndex(effIdx); }); return ( From a151439f1c2dfa44231c2b376d12e97cbc5e7f1f Mon Sep 17 00:00:00 2001 From: RobRoid Date: Fri, 3 Oct 2025 01:48:08 +0800 Subject: [PATCH 47/54] refactor(synced-lyrics): extract empty line handling into shared utilities --- src/plugins/synced-lyrics/shared/lines.ts | 120 ++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 src/plugins/synced-lyrics/shared/lines.ts diff --git a/src/plugins/synced-lyrics/shared/lines.ts b/src/plugins/synced-lyrics/shared/lines.ts new file mode 100644 index 0000000000..55e9817677 --- /dev/null +++ b/src/plugins/synced-lyrics/shared/lines.ts @@ -0,0 +1,120 @@ +export interface SyncedLineCore { + text: string; + timeInMs: number; + duration: number; +} + +export function mergeConsecutiveEmptySyncedLines( + input: T[], +): T[] { + const merged: T[] = []; + for (const line of input) { + const isEmpty = !line.text || !line.text.trim(); + if (isEmpty && merged.length > 0) { + const prev = merged[merged.length - 1]; + const prevEmpty = !prev.text || !prev.text.trim(); + if (prevEmpty) { + // extend previous duration to cover this line + const prevEnd = prev.timeInMs + prev.duration; + const thisEnd = line.timeInMs + line.duration; + const newEnd = Math.max(prevEnd, thisEnd); + prev.duration = newEnd - prev.timeInMs; + continue; // skip adding this line + } + } + merged.push(line); + } + return merged; +} + +// adds a leading empty line if the first line starts after the threshold +// - 'span': spans the initial silence (duration = first.timeInMs) +// - 'zero': creates a zero-duration line at the start +export function ensureLeadingPaddingEmptyLine( + input: T[], + thresholdMs = 300, + mode: 'span' | 'zero' = 'span', +): T[] { + if (input.length === 0) return input; + const first = input[0]; + if (first.timeInMs <= thresholdMs) return input; + + const leading: T = Object.assign({}, first, { + timeInMs: 0, + duration: mode === 'span' ? first.timeInMs : 0, + text: '', + }); + + // update the time string if it exists in the object + if ((leading as unknown as { time?: unknown }).time !== undefined) { + (leading as unknown as { time: string }).time = toLrcTime(leading.timeInMs); + } + + return [leading, ...input]; +} + +// ensures a trailing empty line with two strategies: +// - 'lastEnd': adds a zero-duration line at the last end time +// - 'midpoint': adds a line at the midpoint between the last line and song end +export function ensureTrailingEmptyLine( + input: T[], + strategy: 'lastEnd' | 'midpoint', + songEndMs?: number, +): T[] { + if (input.length === 0) return input; + const out = input.slice(); + const last = out[out.length - 1]; + + const isLastEmpty = !last.text || !last.text.trim(); + if (isLastEmpty) return out; // already has an empty line at the end + + const lastEndCandidate = Number.isFinite(last.duration) + ? last.timeInMs + last.duration + : last.timeInMs; + + if (strategy === 'lastEnd') { + const trailing: T = Object.assign({}, last, { + timeInMs: lastEndCandidate, + duration: 0, + text: '', + }); + if ((trailing as unknown as { time?: unknown }).time !== undefined) { + (trailing as unknown as { time: string }).time = toLrcTime( + trailing.timeInMs, + ); + } + out.push(trailing); + return out; + } + + // handle the midpoint strategy + if (typeof songEndMs !== 'number') return out; + if (lastEndCandidate >= songEndMs) return out; + + const midpoint = Math.floor((lastEndCandidate + songEndMs) / 2); + + // adjust the last line to end at the calculated midpoint + last.duration = midpoint - last.timeInMs; + + const trailing: T = Object.assign({}, last, { + timeInMs: midpoint, + duration: songEndMs - midpoint, + text: '', + }); + if ((trailing as unknown as { time?: unknown }).time !== undefined) { + (trailing as unknown as { time: string }).time = toLrcTime( + trailing.timeInMs, + ); + } + out.push(trailing); + return out; +} + +function toLrcTime(ms: number): string { + const minutes = Math.floor(ms / 60000); + const seconds = Math.floor((ms % 60000) / 1000); + const centiseconds = Math.floor((ms % 1000) / 10); + return `${minutes.toString().padStart(2, '0')}:${seconds + .toString() + .padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`; +} From 8501f03c4cafb7b1c20e8f1aece312d8890c1d18 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Fri, 3 Oct 2025 01:53:48 +0800 Subject: [PATCH 48/54] refactor(synced-lyrics): unify empty line handling --- src/plugins/synced-lyrics/parsers/lrc.ts | 42 +++-------- src/plugins/synced-lyrics/providers/LRCLib.ts | 70 ++++--------------- .../synced-lyrics/providers/MusixMatch.ts | 32 ++++----- .../synced-lyrics/providers/YTMusic.ts | 41 ++++------- 4 files changed, 47 insertions(+), 138 deletions(-) diff --git a/src/plugins/synced-lyrics/parsers/lrc.ts b/src/plugins/synced-lyrics/parsers/lrc.ts index 2a76fdd9cd..c6a149dd8c 100644 --- a/src/plugins/synced-lyrics/parsers/lrc.ts +++ b/src/plugins/synced-lyrics/parsers/lrc.ts @@ -1,3 +1,8 @@ +import { + ensureLeadingPaddingEmptyLine, + mergeConsecutiveEmptySyncedLines, +} from '../shared/lines'; + interface LRCTag { tag: string; value: string; @@ -79,42 +84,11 @@ export const LRC = { line.timeInMs += offset; } - const first = lrc.lines.at(0); - if (first && first.timeInMs > 300) { - lrc.lines.unshift({ - time: '0:0:0', - timeInMs: 0, - duration: first.timeInMs, - text: '', - }); - } + // leading padding line if the first line starts late + lrc.lines = ensureLeadingPaddingEmptyLine(lrc.lines, 300, 'span'); // Merge consecutive empty lines into a single empty line - { - const merged: LRCLine[] = []; - for (const line of lrc.lines) { - const isEmpty = !line.text || !line.text.trim(); - if (isEmpty && merged.length > 0) { - const prev = merged[merged.length - 1]; - const prevEmpty = !prev.text || !prev.text.trim(); - if (prevEmpty) { - const prevEnd = Number.isFinite(prev.duration) - ? prev.timeInMs + prev.duration - : Infinity; - const thisEnd = Number.isFinite(line.duration) - ? line.timeInMs + line.duration - : Infinity; - const newEnd = Math.max(prevEnd, thisEnd); - prev.duration = Number.isFinite(newEnd) - ? newEnd - prev.timeInMs - : Infinity; - continue; - } - } - merged.push(line); - } - lrc.lines = merged; - } + lrc.lines = mergeConsecutiveEmptySyncedLines(lrc.lines); return lrc; }, diff --git a/src/plugins/synced-lyrics/providers/LRCLib.ts b/src/plugins/synced-lyrics/providers/LRCLib.ts index 3d33caf2c0..14eca592da 100644 --- a/src/plugins/synced-lyrics/providers/LRCLib.ts +++ b/src/plugins/synced-lyrics/providers/LRCLib.ts @@ -2,6 +2,11 @@ import { jaroWinkler } from '@skyra/jaro-winkler'; import { config } from '../renderer/renderer'; import { LRC } from '../parsers/lrc'; +import { + ensureLeadingPaddingEmptyLine, + ensureTrailingEmptyLine, + mergeConsecutiveEmptySyncedLines, +} from '../shared/lines'; import type { LyricProvider, LyricResult, SearchSongInfo } from '../types'; @@ -162,67 +167,18 @@ export class LRCLib implements LyricProvider { ...l, status: 'upcoming' as const, })); - - // Merge consecutive empty lines into a single empty line - const merged: typeof parsed = []; - for (const line of parsed) { - const isEmpty = !line.text || !line.text.trim(); - if (isEmpty && merged.length > 0) { - const prev = merged[merged.length - 1]; - const prevEmpty = !prev.text || !prev.text.trim(); - if (prevEmpty) { - // extend previous duration to cover this line - const prevEnd = prev.timeInMs + prev.duration; - const thisEnd = line.timeInMs + line.duration; - const newEnd = Math.max(prevEnd, thisEnd); - prev.duration = newEnd - prev.timeInMs; - continue; // skip adding this line - } - } - merged.push(line); - } - - // If the final merged line is not empty, append a computed empty line - if (merged.length > 0) { - const last = merged[merged.length - 1]; - const lastIsEmpty = !last.text || !last.text.trim(); - if (lastIsEmpty) { - // last line already empty, don't append another - } else { - // If duration is infinity (no following line), treat end as start for midpoint calculation - const lastEndCandidate = Number.isFinite(last.duration) - ? last.timeInMs + last.duration - : last.timeInMs; - const songEnd = songDuration * 1000; - - if (lastEndCandidate < songEnd) { - const midpoint = Math.floor((lastEndCandidate + songEnd) / 2); - - // update last duration to end at midpoint - last.duration = midpoint - last.timeInMs; - - const minutes = Math.floor(midpoint / 60000); - const seconds = Math.floor((midpoint % 60000) / 1000); - const centiseconds = Math.floor((midpoint % 1000) / 10); - const timeStr = `${minutes.toString().padStart(2, '0')}:${seconds - .toString() - .padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`; - - merged.push({ - timeInMs: midpoint, - time: timeStr, - duration: songEnd - midpoint, - text: '', - status: 'upcoming' as const, - }); - } - } - } + const merged = mergeConsecutiveEmptySyncedLines(parsed); + const withLeading = ensureLeadingPaddingEmptyLine(merged, 300, 'span'); + const finalLines = ensureTrailingEmptyLine( + withLeading, + 'midpoint', + songDuration * 1000, + ); return { title: closestResult.trackName, artists: closestResult.artistName.split(/[&,]/g), - lines: merged, + lines: finalLines, }; } else if (plain) { // Fallback to plain if no synced diff --git a/src/plugins/synced-lyrics/providers/MusixMatch.ts b/src/plugins/synced-lyrics/providers/MusixMatch.ts index a0b6441784..ffb5b004ce 100644 --- a/src/plugins/synced-lyrics/providers/MusixMatch.ts +++ b/src/plugins/synced-lyrics/providers/MusixMatch.ts @@ -1,6 +1,11 @@ import * as z from 'zod'; import { LRC } from '../parsers/lrc'; +import { + ensureLeadingPaddingEmptyLine, + ensureTrailingEmptyLine, + mergeConsecutiveEmptySyncedLines, +} from '../shared/lines'; import { netFetch } from '../renderer'; import type { LyricProvider, LyricResult, SearchSongInfo } from '../types'; @@ -47,26 +52,13 @@ export class MusixMatch implements LyricProvider { (l) => ({ ...l, status: 'upcoming' as const }), ); - // Merge consecutive empty lines into a single empty line - const merged: typeof parsed = []; - for (const line of parsed) { - const isEmpty = !line.text || !line.text.trim(); - if (isEmpty && merged.length > 0) { - const prev = merged[merged.length - 1]; - const prevEmpty = !prev.text || !prev.text.trim(); - if (prevEmpty) { - // extend previous duration to cover this line - const prevEnd = prev.timeInMs + prev.duration; - const thisEnd = line.timeInMs + line.duration; - const newEnd = Math.max(prevEnd, thisEnd); - prev.duration = newEnd - prev.timeInMs; - continue; // skip adding this line - } - } - merged.push(line); - } - - return merged; + const merged = mergeConsecutiveEmptySyncedLines(parsed); + const withLeading = ensureLeadingPaddingEmptyLine( + merged, + 300, + 'span', + ); + return ensureTrailingEmptyLine(withLeading, 'lastEnd'); })() : undefined, lyrics: lyrics, diff --git a/src/plugins/synced-lyrics/providers/YTMusic.ts b/src/plugins/synced-lyrics/providers/YTMusic.ts index 3f3db4dca3..6a4f2cb511 100644 --- a/src/plugins/synced-lyrics/providers/YTMusic.ts +++ b/src/plugins/synced-lyrics/providers/YTMusic.ts @@ -1,3 +1,9 @@ +import { + ensureLeadingPaddingEmptyLine, + ensureTrailingEmptyLine, + mergeConsecutiveEmptySyncedLines, +} from '../shared/lines'; + import type { LyricProvider, LyricResult, SearchSongInfo } from '../types'; import type { YouTubeMusicAppElement } from '@/types/youtube-music-app-element'; @@ -76,39 +82,20 @@ export class YTMusic implements LyricProvider { return null; } - if (synced?.length && synced[0].timeInMs > 300) { - synced.unshift({ - duration: 0, - text: '', - time: '00:00.00', - timeInMs: 0, - status: 'upcoming' as const, - }); - } - - // ensure a final empty line exists - if (synced?.length) { - const last = synced[synced.length - 1]; - const lastEnd = parseInt(last.timeInMs.toString()) + last.duration; - - // youtube sometimes omits trailing silence, add our own - if (last.text !== '') { - synced.push({ - duration: 0, - text: '', - time: this.millisToTime(lastEnd), - timeInMs: lastEnd, - status: 'upcoming' as const, - }); - } - } + const processed = synced + ? (() => { + const merged = mergeConsecutiveEmptySyncedLines(synced); + const withLeading = ensureLeadingPaddingEmptyLine(merged, 300, 'span'); + return ensureTrailingEmptyLine(withLeading, 'lastEnd'); + })() + : undefined; return { title, artists: [artist], lyrics: plain, - lines: synced, + lines: processed, }; } From db0f24c0451c131b812e59fc19c3eeade606bdbc Mon Sep 17 00:00:00 2001 From: RobRoid Date: Fri, 3 Oct 2025 01:59:26 +0800 Subject: [PATCH 49/54] refactor(synced-lyrics): unify provider switching, add utilities, and clean rendering logic --- .../renderer/components/LyricsPicker.tsx | 32 ++++++----- .../renderer/components/SyncedLine.tsx | 54 ++++++++----------- .../synced-lyrics/renderer/renderer.tsx | 22 ++++---- src/plugins/synced-lyrics/renderer/utils.tsx | 49 +++++++++++++++++ 4 files changed, 97 insertions(+), 60 deletions(-) diff --git a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx index 5f24a57cfe..f6b2905f46 100644 --- a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx +++ b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx @@ -79,6 +79,12 @@ export const LyricsPicker = (props: { const [starredProvider, setStarredProvider] = createSignal(null); + const favoriteProviderKey = (id: string) => `ytmd-sl-starred-${id}`; + const switchProvider = (provider: ProviderName, fastMs = 2500) => { + requestFastScroll(fastMs); + setLyricsStore('provider', provider); + }; + createEffect(() => { const id = videoId(); if (id === null) { @@ -86,7 +92,7 @@ export const LyricsPicker = (props: { return; } - const key = `ytmd-sl-starred-${id}`; + const key = favoriteProviderKey(id); const value = localStorage.getItem(key); if (!value) { setStarredProvider(null); @@ -106,7 +112,7 @@ export const LyricsPicker = (props: { const id = videoId(); if (id === null) return; - const key = `ytmd-sl-starred-${id}`; + const key = favoriteProviderKey(id); setStarredProvider((starredProvider) => { if (lyricsStore.provider === starredProvider) { @@ -141,8 +147,7 @@ export const LyricsPicker = (props: { if (!hasManuallySwitchedProvider()) { const starred = starredProvider(); if (starred !== null) { - requestFastScroll(2500); - setLyricsStore('provider', starred); + switchProvider(starred, 2500); return; } @@ -156,29 +161,29 @@ export const LyricsPicker = (props: { force || providerBias(lyricsStore.provider) < providerBias(provider) ) { - requestFastScroll(2500); - setLyricsStore('provider', provider); + switchProvider(provider, 2500); } } }); const next = () => { setHasManuallySwitchedProvider(true); - requestFastScroll(2500); setLyricsStore('provider', (prevProvider) => { const idx = providerNames.indexOf(prevProvider); - return providerNames[(idx + 1) % providerNames.length]; + const nextProvider = providerNames[(idx + 1) % providerNames.length]; + switchProvider(nextProvider, 2500); + return nextProvider; }); }; const previous = () => { setHasManuallySwitchedProvider(true); - requestFastScroll(2500); setLyricsStore('provider', (prevProvider) => { const idx = providerNames.indexOf(prevProvider); - return providerNames[ - (idx + providerNames.length - 1) % providerNames.length - ]; + const prev = + providerNames[(idx + providerNames.length - 1) % providerNames.length]; + switchProvider(prev, 2500); + return prev; }); }; @@ -317,8 +322,7 @@ export const LyricsPicker = (props: { class="lyrics-picker-dot" onClick={() => { setHasManuallySwitchedProvider(true); - requestFastScroll(2500); - setLyricsStore('provider', providerNames[idx()]); + switchProvider(providerNames[idx()], 2500); }} style={{ background: idx() === providerIdx() ? 'white' : 'black', diff --git a/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx index cb34a8afa8..ca38ee6c76 100644 --- a/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx +++ b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx @@ -7,7 +7,14 @@ import { type LineLyrics } from '@/plugins/synced-lyrics/types'; import { config, currentTime } from '../renderer'; import { _ytAPI } from '..'; -import { canonicalize, romanize, simplifyUnicode, getSeekTime } from '../utils'; +import { + canonicalize, + romanize, + simplifyUnicode, + getSeekTime, + isBlank, + timeCodeText, +} from '../utils'; interface SyncedLineProps { scroller: VirtualizerHandle; @@ -19,26 +26,6 @@ interface SyncedLineProps { isFirstEmptyLine?: boolean; } -function formatTime(timeInMs: number, preciseTiming: boolean): string { - if (!preciseTiming) { - const totalSeconds = Math.round(timeInMs / 1000); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - - return `${minutes.toString().padStart(2, '0')}:${seconds - .toString() - .padStart(2, '0')}`; - } - - const minutes = Math.floor(timeInMs / 60000); - const seconds = Math.floor((timeInMs % 60000) / 1000); - const ms = Math.floor((timeInMs % 1000) / 10); - - return `${minutes.toString().padStart(2, '0')}:${seconds - .toString() - .padStart(2, '0')}.${ms.toString().padStart(2, '0')}`; -} - const EmptyLine = (props: SyncedLineProps) => { const states = createMemo(() => { const defaultText = config()?.defaultTextString ?? ''; @@ -82,7 +69,7 @@ const EmptyLine = (props: SyncedLineProps) => { }); const shouldRenderPlaceholder = createMemo(() => { - const isEmpty = !props.line.text?.trim(); + const isEmpty = isBlank(props.line.text); const showEmptySymbols = config()?.showEmptyLineSymbols ?? false; return isEmpty @@ -92,7 +79,7 @@ const EmptyLine = (props: SyncedLineProps) => { const isHighlighted = createMemo(() => props.status === 'current'); const isFinalEmpty = createMemo(() => { - return props.isFinalLine && !props.line.text?.trim(); + return props.isFinalLine && isBlank(props.line.text); }); const shouldRemovePadding = createMemo(() => { @@ -116,18 +103,17 @@ const EmptyLine = (props: SyncedLineProps) => { text={{ runs: [ { - text: config()?.showTimeCodes - ? `[${formatTime( - props.line.timeInMs, - config()?.preciseTiming ?? false, - )}] ` - : '', + text: timeCodeText( + props.line.timeInMs, + config()?.preciseTiming ?? false, + config()?.showTimeCodes ?? false, + ), }, ], }} />
    - {props.isFinalLine && !props.line.text?.trim() ? ( + {props.isFinalLine && isBlank(props.line.text) ? ( @@ -185,9 +171,11 @@ export const SyncedLine = (props: SyncedLineProps) => { text={{ runs: [ { - text: config()?.showTimeCodes - ? `[${formatTime(props.line.timeInMs, config()?.preciseTiming ?? false)}] ` - : '', + text: timeCodeText( + props.line.timeInMs, + config()?.preciseTiming ?? false, + config()?.showTimeCodes ?? false, + ), }, ], }} diff --git a/src/plugins/synced-lyrics/renderer/renderer.tsx b/src/plugins/synced-lyrics/renderer/renderer.tsx index 1ee191c0be..d6d03106a3 100644 --- a/src/plugins/synced-lyrics/renderer/renderer.tsx +++ b/src/plugins/synced-lyrics/renderer/renderer.tsx @@ -11,7 +11,13 @@ import { type VirtualizerHandle, VList } from 'virtua/solid'; import { LyricsPicker } from './components/LyricsPicker'; -import { selectors, getSeekTime, SFont } from './utils'; +import { + selectors, + getSeekTime, + SFont, + normalizePlainLyrics, + isBlank, +} from './utils'; import { ErrorDisplay, @@ -306,7 +312,7 @@ export const LyricsRenderer = () => { if (data?.lines) { const lines = data.lines; - const firstEmpty = lines.findIndex((l) => !l.text?.trim()); + const firstEmpty = lines.findIndex((l) => isBlank(l.text)); setFirstEmptyIndex(firstEmpty === -1 ? null : firstEmpty); return lines.map((line) => ({ @@ -316,17 +322,7 @@ export const LyricsRenderer = () => { } if (data?.lyrics) { - const rawLines = data.lyrics.split('\n'); - - // preserve a single trailing empty line if provided by the provider - const hasTrailingEmpty = - rawLines.length > 0 && rawLines[rawLines.length - 1].trim() === ''; - - const lines = rawLines.filter((line, idx) => { - if (line.trim()) return true; - // keep only the final empty line (for padding) if it exists - return hasTrailingEmpty && idx === rawLines.length - 1; - }); + const lines = normalizePlainLyrics(data.lyrics); return lines.map((line) => ({ kind: 'PlainLine' as const, diff --git a/src/plugins/synced-lyrics/renderer/utils.tsx b/src/plugins/synced-lyrics/renderer/utils.tsx index 2470c182a6..f170fcbbf5 100644 --- a/src/plugins/synced-lyrics/renderer/utils.tsx +++ b/src/plugins/synced-lyrics/renderer/utils.tsx @@ -91,6 +91,11 @@ export const simplifyUnicode = (text?: string) => .trim() : text; +export const isBlank = (text?: string) => { + const simplified = simplifyUnicode(text); + return simplified === undefined || simplified === ''; +}; + // Japanese Shinjitai const shinjitai = [ 20055, 20081, 20120, 20124, 20175, 26469, 20341, 20206, 20253, 20605, 20385, @@ -218,6 +223,50 @@ export const romanize = async (line: string) => { export const getSeekTime = (timeInMs: number, precise: boolean) => precise ? timeInMs / 1000 : Math.round(timeInMs / 1000); +// Format a time value in ms into mm:ss or mm:ss.xx depending on preciseTiming +export function formatTime(timeInMs: number, preciseTiming: boolean): string { + if (!preciseTiming) { + const totalSeconds = Math.round(timeInMs / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + return `${minutes.toString().padStart(2, '0')}:${seconds + .toString() + .padStart(2, '0')}`; + } + + const minutes = Math.floor(timeInMs / 60000); + const seconds = Math.floor((timeInMs % 60000) / 1000); + const ms = Math.floor((timeInMs % 1000) / 10); + + return `${minutes.toString().padStart(2, '0')}:${seconds + .toString() + .padStart(2, '0')}.${ms.toString().padStart(2, '0')}`; +} + +// Returns the time code prefix text to embed in yt-formatted-string runs +export const timeCodeText = ( + timeInMs: number, + preciseTiming: boolean, + show: boolean, +) => (show ? `[${formatTime(timeInMs, preciseTiming)}] ` : ''); + +// Normalizes plain-lyrics text into displayable lines, removing empty lines +// while preserving a single trailing empty line if the original text ends with one. +export const normalizePlainLyrics = (raw: string): string[] => { + const rawLines = raw.split('\n'); + const hasTrailingEmpty = + rawLines.length > 0 && isBlank(rawLines[rawLines.length - 1]); + + const lines = rawLines.filter((line, idx) => { + if (!isBlank(line)) return true; + // keep only the final empty line (for padding) if it exists + return hasTrailingEmpty && idx === rawLines.length - 1; + }); + + return lines; +}; + export const SFont = () => { if (document.getElementById('satoshi-font-link')) return; const link = document.createElement('link'); From 749d65a2feff3a50446a4641c00311c564fc62cc Mon Sep 17 00:00:00 2001 From: RobRoid Date: Fri, 3 Oct 2025 17:56:39 +0800 Subject: [PATCH 50/54] refactor(synced-lyrics): extract scrolling constants and helpers --- .../synced-lyrics/renderer/renderer.tsx | 44 +++++++++++-------- .../synced-lyrics/renderer/scrolling.ts | 23 ++++++++++ 2 files changed, 49 insertions(+), 18 deletions(-) create mode 100644 src/plugins/synced-lyrics/renderer/scrolling.ts diff --git a/src/plugins/synced-lyrics/renderer/renderer.tsx b/src/plugins/synced-lyrics/renderer/renderer.tsx index d6d03106a3..e5147183ea 100644 --- a/src/plugins/synced-lyrics/renderer/renderer.tsx +++ b/src/plugins/synced-lyrics/renderer/renderer.tsx @@ -28,6 +28,7 @@ import { } from './components'; import { currentLyrics } from './store'; +import { clamp, SCROLL_DURATION, LEAD_IN_TIME_MS } from './scrolling'; import type { LineLyrics, SyncedLyricsPluginConfig } from '../types'; @@ -391,6 +392,8 @@ export const LyricsRenderer = () => { const targetIndex = scrollTargetIndex(); const maxIndex = untrack(statuses).length - 1; const scrollerInstance = scroller(); + const lineEffect = config()?.lineEffect; + const isEnhanced = lineEffect === 'enhanced'; if (!visible || !scrollerInstance || !current.data?.lines) return; @@ -403,27 +406,33 @@ export const LyricsRenderer = () => { jumpSize: number, fast: boolean, ) => { - // fast scroll for others if (fast) { - const d = 260 + distance * 0.28; - return Math.min(680, Math.max(240, d)); + return clamp( + SCROLL_DURATION.FAST_BASE + distance * SCROLL_DURATION.FAST_MULT, + SCROLL_DURATION.FAST_MIN, + SCROLL_DURATION.FAST_MAX, + ); } - let minDuration = 850; - let maxDuration = 1650; - let duration = 550 + distance * 0.7; + let base: number = SCROLL_DURATION.NORMAL_BASE; + let mult: number = SCROLL_DURATION.NORMAL_MULT; + let min: number = SCROLL_DURATION.NORMAL_MIN; + let max: number = SCROLL_DURATION.NORMAL_MAX; if (jumpSize === 1) { - minDuration = 1000; - maxDuration = 1800; - duration = 700 + distance * 0.8; + base = SCROLL_DURATION.JUMP1_BASE; + mult = SCROLL_DURATION.JUMP1_MULT; + min = SCROLL_DURATION.JUMP1_MIN; + max = SCROLL_DURATION.JUMP1_MAX; } else if (jumpSize > 3) { - minDuration = 600; - maxDuration = 1400; - duration = 400 + distance * 0.6; + base = SCROLL_DURATION.JUMP4_BASE; + mult = SCROLL_DURATION.JUMP4_MULT; + min = SCROLL_DURATION.JUMP4_MIN; + max = SCROLL_DURATION.JUMP4_MAX; } - return Math.min(maxDuration, Math.max(minDuration, duration)); + const duration = base + distance * mult; + return clamp(duration, min, max); }; // easing function @@ -452,7 +461,7 @@ export const LyricsRenderer = () => { const itemCenter = itemSize / 2; const centerOffset = itemOffset - viewportCenter + itemCenter; - return Math.max(0, Math.min(centerOffset, maxScroll)); + return clamp(centerOffset, 0, maxScroll); }; // enhanced scroll animation @@ -527,7 +536,7 @@ export const LyricsRenderer = () => { // wait for scroller ready const waitForReady = (tries = 0) => { - const nonEnhanced = config()?.lineEffect !== 'enhanced'; + const nonEnhanced = !isEnhanced; const scrollerReady = isScrollerReady(scrollerInstance, scrollIndex); const hasCurrentIndex = !nonEnhanced || currentIndex() >= 0; @@ -543,7 +552,7 @@ export const LyricsRenderer = () => { const inFastWindow = now < fastScrollUntil(); const suppressed = now < suppressFastUntil(); - if (config()?.lineEffect !== 'enhanced') { + if (!isEnhanced) { scrollerInstance.scrollToIndex(scrollIndex, { smooth: true, align: 'center', @@ -628,10 +637,9 @@ export const LyricsRenderer = () => { if (nextLine) { // start scroll early - const leadInTimeMs = 130; const timeUntilNextLine = nextLine.timeInMs - currentTimeMs; - if (timeUntilNextLine <= leadInTimeMs) { + if (timeUntilNextLine <= LEAD_IN_TIME_MS) { setScrollTargetIndex(nextIdx); prevIndexForFast = effIdx; return; diff --git a/src/plugins/synced-lyrics/renderer/scrolling.ts b/src/plugins/synced-lyrics/renderer/scrolling.ts new file mode 100644 index 0000000000..5768e5fd10 --- /dev/null +++ b/src/plugins/synced-lyrics/renderer/scrolling.ts @@ -0,0 +1,23 @@ +export const clamp = (n: number, min: number, max: number) => + Math.max(min, Math.min(max, n)); + +export const SCROLL_DURATION = { + FAST_BASE: 260, + FAST_MULT: 0.28, + FAST_MIN: 240, + FAST_MAX: 680, + NORMAL_BASE: 550, + NORMAL_MULT: 0.7, + NORMAL_MIN: 850, + NORMAL_MAX: 1650, + JUMP1_BASE: 700, + JUMP1_MULT: 0.8, + JUMP1_MIN: 1000, + JUMP1_MAX: 1800, + JUMP4_BASE: 400, + JUMP4_MULT: 0.6, + JUMP4_MIN: 600, + JUMP4_MAX: 1400, +} as const; + +export const LEAD_IN_TIME_MS = 130; From b11c0ab7a6ffde6b47104c3f2c1f5f3a8430ea21 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Fri, 3 Oct 2025 17:58:06 +0800 Subject: [PATCH 51/54] refactor(synced-lyrics): extract helpers for provider switching and word rendering --- .../renderer/components/LyricsPicker.tsx | 39 ++++---- .../renderer/components/SyncedLine.tsx | 93 ++++++++----------- 2 files changed, 60 insertions(+), 72 deletions(-) diff --git a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx index f6b2905f46..a44b160ad1 100644 --- a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx +++ b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx @@ -35,6 +35,8 @@ export const providerIdx = createMemo(() => providerNames.indexOf(lyricsStore.provider), ); +const FAST_SWITCH_MS = 2500; + const shouldSwitchProvider = (providerData: ProviderState) => { if (providerData.state === 'error') return true; if (providerData.state === 'fetching') return true; @@ -80,7 +82,7 @@ export const LyricsPicker = (props: { createSignal(null); const favoriteProviderKey = (id: string) => `ytmd-sl-starred-${id}`; - const switchProvider = (provider: ProviderName, fastMs = 2500) => { + const switchProvider = (provider: ProviderName, fastMs = FAST_SWITCH_MS) => { requestFastScroll(fastMs); setLyricsStore('provider', provider); }; @@ -99,7 +101,13 @@ export const LyricsPicker = (props: { return; } - const parseResult = LocalStorageSchema.safeParse(JSON.parse(value)); + let parsed: unknown = null; + try { + parsed = JSON.parse(value); + } catch { + parsed = null; + } + const parseResult = LocalStorageSchema.safeParse(parsed); if (parseResult.success) { setLyricsStore('provider', parseResult.data.provider); setStarredProvider(parseResult.data.provider); @@ -147,7 +155,7 @@ export const LyricsPicker = (props: { if (!hasManuallySwitchedProvider()) { const starred = starredProvider(); if (starred !== null) { - switchProvider(starred, 2500); + switchProvider(starred); return; } @@ -161,30 +169,25 @@ export const LyricsPicker = (props: { force || providerBias(lyricsStore.provider) < providerBias(provider) ) { - switchProvider(provider, 2500); + switchProvider(provider); } } }); const next = () => { setHasManuallySwitchedProvider(true); - setLyricsStore('provider', (prevProvider) => { - const idx = providerNames.indexOf(prevProvider); - const nextProvider = providerNames[(idx + 1) % providerNames.length]; - switchProvider(nextProvider, 2500); - return nextProvider; - }); + const nextProvider = + providerNames[(providerIdx() + 1) % providerNames.length]; + switchProvider(nextProvider); }; const previous = () => { setHasManuallySwitchedProvider(true); - setLyricsStore('provider', (prevProvider) => { - const idx = providerNames.indexOf(prevProvider); - const prev = - providerNames[(idx + providerNames.length - 1) % providerNames.length]; - switchProvider(prev, 2500); - return prev; - }); + const prev = + providerNames[ + (providerIdx() + providerNames.length - 1) % providerNames.length + ]; + switchProvider(prev); }; const chevronLeft: YtIcons = 'yt-icons:chevron_left'; @@ -322,7 +325,7 @@ export const LyricsPicker = (props: { class="lyrics-picker-dot" onClick={() => { setHasManuallySwitchedProvider(true); - switchProvider(providerNames[idx()], 2500); + switchProvider(providerNames[idx()]); }} style={{ background: idx() === providerIdx() ? 'white' : 'black', diff --git a/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx index ca38ee6c76..da9bd1e1f8 100644 --- a/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx +++ b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx @@ -26,6 +26,31 @@ interface SyncedLineProps { isFirstEmptyLine?: boolean; } +// small helpers +const WORD_ANIM_DELAY_STEP = 0.05; // seconds per word index + +const seekToMs = (ms: number) => { + const precise = config()?.preciseTiming ?? false; + _ytAPI?.seekTo(getSeekTime(ms, precise)); +}; + +const renderWordSpans = (input: string) => ( + + + {(word, index) => ( + + + + )} + + +); + const EmptyLine = (props: SyncedLineProps) => { const states = createMemo(() => { const defaultText = config()?.defaultTextString ?? ''; @@ -40,16 +65,16 @@ const EmptyLine = (props: SyncedLineProps) => { if (stepCount === 1) return 0; - let earlyCut: number; - if (total > 3000) { - earlyCut = 1000; - } else if (total >= 1000) { - const ratio = (total - 1000) / 2000; - const addend = ratio * 500; - earlyCut = 500 + addend; - } else { - earlyCut = Math.min(total * 0.8, total - 150); - } + const computeEarlyCut = (t: number) => { + if (t > 3000) return 1000; + if (t >= 1000) { + const ratio = (t - 1000) / 2000; + const addend = ratio * 500; + return 500 + addend; + } + return Math.min(t * 0.8, t - 150); + }; + const earlyCut = computeEarlyCut(total); const effectiveTotal = total <= 1000 @@ -94,8 +119,7 @@ const EmptyLine = (props: SyncedLineProps) => {
    { - const precise = config()?.preciseTiming ?? false; - _ytAPI?.seekTo(getSeekTime(props.line.timeInMs, precise)); + seekToMs(props.line.timeInMs); }} >
    @@ -162,8 +186,7 @@ export const SyncedLine = (props: SyncedLineProps) => {
    { - const precise = config()?.preciseTiming ?? false; - _ytAPI?.seekTo(getSeekTime(props.line.timeInMs, precise)); + seekToMs(props.line.timeInMs); }} >
    @@ -193,26 +216,7 @@ export const SyncedLine = (props: SyncedLineProps) => { }} style={{ 'display': 'flex', 'flex-direction': 'column' }} > - - - {(word, index) => { - return ( - - - - ); - }} - - + {renderWordSpans(text())} { simplifyUnicode(text()) !== simplifyUnicode(romanization()) } > - - - {(word, index) => { - return ( - - - - ); - }} - - + {renderWordSpans(romanization())}
    From f22b718e19562d80e8d64d9088ae30a09d35bbf3 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sat, 4 Oct 2025 22:55:43 +0800 Subject: [PATCH 52/54] fix(synced-lyrics): migrate old placeholder arrays and unify cumulative animation behavior --- src/config/store.ts | 36 +++++++++++++++ .../renderer/components/SyncedLine.tsx | 45 ++++++++++++++----- 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/src/config/store.ts b/src/config/store.ts index 253b298ab7..248047ca1d 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -11,6 +11,42 @@ export type IStore = InstanceType< >; const migrations = { + '>=3.12.0'(store: IStore) { + // Synced Lyrics: migrate placeholder defaults + const syncedLyricsConfig = store.get('plugins.synced-lyrics') as + | SyncedLyricsPluginConfig + | undefined; + + if (!syncedLyricsConfig) return; + + const current = syncedLyricsConfig.defaultTextString as + | string + | string[] + | undefined; + let updatedValue: string | string[] | undefined; + + if (Array.isArray(current)) { + const asJson = JSON.stringify(current); + // Migrate progressive sequences to non-cumulative variants + if (asJson === JSON.stringify(['•', '••', '•••'])) { + updatedValue = ['•', '•', '•']; + } else if (asJson === JSON.stringify(['.', '..', '...'])) { + updatedValue = ['.', '.', '.']; + } + } else if (typeof current === 'string') { + // Replace regular space placeholder with NBSP to preserve layout + if (current === ' ') { + updatedValue = '\u00A0'; + } + } + + if (updatedValue !== undefined) { + store.set('plugins.synced-lyrics', { + ...syncedLyricsConfig, + defaultTextString: updatedValue, + } as SyncedLyricsPluginConfig); + } + }, '>=3.10.0'(store: IStore) { const lyricGeniusConfig = store.get('plugins.lyrics-genius') as | { diff --git a/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx index da9bd1e1f8..45319dae66 100644 --- a/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx +++ b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx @@ -57,6 +57,12 @@ const EmptyLine = (props: SyncedLineProps) => { return Array.isArray(defaultText) ? defaultText : [defaultText]; }); + const isCumulative = createMemo(() => { + const arr = states(); + if (arr.length <= 1) return false; + return arr.every((value) => value === arr[0]); + }); + const index = createMemo(() => { const progress = currentTime() - props.line.timeInMs; const total = props.line.duration; @@ -144,23 +150,42 @@ const EmptyLine = (props: SyncedLineProps) => { ) : ( - - {(text, i) => ( + - + - )} - + } + when={isCumulative()} + > + + {(text, i) => ( + + + + )} + + )}
    From ec2ebd9de7130b9ca217d72a9d78aa79692a1de3 Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sat, 4 Oct 2025 23:59:57 +0800 Subject: [PATCH 53/54] refactor(synced-lyrics): add helper to simplify end delay logic --- .../renderer/components/SyncedLine.tsx | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx index 45319dae66..23fdf3b274 100644 --- a/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx +++ b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx @@ -27,8 +27,31 @@ interface SyncedLineProps { } // small helpers +const END_DELAY_SECONDS = 1.0; // end delay at line end const WORD_ANIM_DELAY_STEP = 0.05; // seconds per word index +const computeEndDelayMs = (totalMs: number): number => { + const LONG_MS = 3000; + const SHORT_MS = 1000; + const SHORT_SECONDS = END_DELAY_SECONDS / 2; + const SHORT_FRACTION = 0.8; + const SHORT_MIN_GAP_MS = Math.round(SHORT_SECONDS * 1000 * 0.3); + + if (totalMs > LONG_MS) { + return Math.round(END_DELAY_SECONDS * 1000); + } + if (totalMs >= SHORT_MS) { + const ratio = (totalMs - SHORT_MS) / (LONG_MS - SHORT_MS); + const endDelayDelta = (END_DELAY_SECONDS - SHORT_SECONDS) * ratio; + const endDelaySeconds = SHORT_SECONDS + endDelayDelta; + return Math.round(endDelaySeconds * 1000); + } + return Math.min( + Math.round(totalMs * SHORT_FRACTION), + totalMs - SHORT_MIN_GAP_MS, + ); +}; + const seekToMs = (ms: number) => { const precise = config()?.preciseTiming ?? false; _ytAPI?.seekTo(getSeekTime(ms, precise)); @@ -71,23 +94,14 @@ const EmptyLine = (props: SyncedLineProps) => { if (stepCount === 1) return 0; - const computeEarlyCut = (t: number) => { - if (t > 3000) return 1000; - if (t >= 1000) { - const ratio = (t - 1000) / 2000; - const addend = ratio * 500; - return 500 + addend; - } - return Math.min(t * 0.8, t - 150); - }; - const earlyCut = computeEarlyCut(total); + const endDelayMs = computeEndDelayMs(total); const effectiveTotal = total <= 1000 - ? total - earlyCut + ? total - endDelayMs : precise - ? total - earlyCut - : Math.round((total - earlyCut) / 1000) * 1000; + ? total - endDelayMs + : Math.round((total - endDelayMs) / 1000) * 1000; if (effectiveTotal <= 0) return 0; From 1cf34b7806106d9e02517416e8aba1d4f20ee10f Mon Sep 17 00:00:00 2001 From: RobRoid Date: Sun, 5 Oct 2025 10:30:01 +0800 Subject: [PATCH 54/54] refactor(synced-lyrics): optimize end delay calculation using memoization --- .../synced-lyrics/renderer/components/SyncedLine.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx index 23fdf3b274..591cf7b419 100644 --- a/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx +++ b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx @@ -86,6 +86,10 @@ const EmptyLine = (props: SyncedLineProps) => { return arr.every((value) => value === arr[0]); }); + const endDelayMsValue = createMemo(() => + computeEndDelayMs(props.line.duration), + ); + const index = createMemo(() => { const progress = currentTime() - props.line.timeInMs; const total = props.line.duration; @@ -94,7 +98,7 @@ const EmptyLine = (props: SyncedLineProps) => { if (stepCount === 1) return 0; - const endDelayMs = computeEndDelayMs(total); + const endDelayMs = endDelayMsValue(); const effectiveTotal = total <= 1000