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/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)" diff --git a/src/plugins/synced-lyrics/index.ts b/src/plugins/synced-lyrics/index.ts index 533f05bfb1..53c26afedd 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: true, romanization: true, } satisfies SyncedLyricsPluginConfig as SyncedLyricsPluginConfig, 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'), diff --git a/src/plugins/synced-lyrics/parsers/lrc.ts b/src/plugins/synced-lyrics/parsers/lrc.ts index 355c0a4d5c..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; @@ -17,7 +22,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 +55,18 @@ export const LRC = { } const { minutes, seconds, milliseconds, text } = lyric; - const timeInMs = - parseInt(minutes) * 60 * 1000 + - parseInt(seconds) * 1000 + - parseInt(milliseconds); + + // 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 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}:${seconds}:${milliseconds}`, + time: `${minutes.padStart(2, '0')}:${seconds.padStart(2, '0')}.${ms2}`, timeInMs, text: text.trim(), duration: Infinity, @@ -74,15 +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 + 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 cfdea3ab48..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'; @@ -77,61 +82,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); } @@ -157,21 +160,36 @@ 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, + })); + 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: finalLines, + }; + } 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; } } 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, }; } } diff --git a/src/plugins/synced-lyrics/providers/MusixMatch.ts b/src/plugins/synced-lyrics/providers/MusixMatch.ts index 275c13329c..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'; @@ -42,10 +47,19 @@ 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 }), + ); + + 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 a655289a7f..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'; @@ -51,13 +57,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,41 +73,40 @@ 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') { return null; } - if (synced?.length && synced[0].timeInMs > 300) { - synced.unshift({ - duration: 0, - text: '', - time: '00:00.00', - timeInMs: 0, - 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, }; } 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) diff --git a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx index 6f6ac92a71..a44b160ad1 100644 --- a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx +++ b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx @@ -15,13 +15,14 @@ import * as z from 'zod'; import { type ProviderName, + ProviderNames, providerNames, ProviderNameSchema, type ProviderState, } 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'; @@ -34,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; @@ -47,7 +50,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 = () => { @@ -76,6 +81,12 @@ export const LyricsPicker = (props: { const [starredProvider, setStarredProvider] = createSignal(null); + const favoriteProviderKey = (id: string) => `ytmd-sl-starred-${id}`; + const switchProvider = (provider: ProviderName, fastMs = FAST_SWITCH_MS) => { + requestFastScroll(fastMs); + setLyricsStore('provider', provider); + }; + createEffect(() => { const id = videoId(); if (id === null) { @@ -83,14 +94,20 @@ export const LyricsPicker = (props: { return; } - const key = `ytmd-sl-starred-${id}`; + const key = favoriteProviderKey(id); const value = localStorage.getItem(key); if (!value) { setStarredProvider(null); 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); @@ -103,7 +120,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) { @@ -138,7 +155,7 @@ export const LyricsPicker = (props: { if (!hasManuallySwitchedProvider()) { const starred = starredProvider(); if (starred !== null) { - setLyricsStore('provider', starred); + switchProvider(starred); return; } @@ -152,27 +169,25 @@ export const LyricsPicker = (props: { force || providerBias(lyricsStore.provider) < providerBias(provider) ) { - setLyricsStore('provider', provider); + switchProvider(provider); } } }); const next = () => { setHasManuallySwitchedProvider(true); - setLyricsStore('provider', (prevProvider) => { - const idx = providerNames.indexOf(prevProvider); - return providerNames[(idx + 1) % providerNames.length]; - }); + const nextProvider = + providerNames[(providerIdx() + 1) % providerNames.length]; + switchProvider(nextProvider); }; const previous = () => { setHasManuallySwitchedProvider(true); - setLyricsStore('provider', (prevProvider) => { - const idx = providerNames.indexOf(prevProvider); - return providerNames[ - (idx + providerNames.length - 1) % providerNames.length + const prev = + providerNames[ + (providerIdx() + providerNames.length - 1) % providerNames.length ]; - }); + switchProvider(prev); }; const chevronLeft: YtIcons = 'yt-icons:chevron_left'; @@ -228,7 +243,7 @@ export const LyricsPicker = (props: {
@@ -308,7 +323,10 @@ export const LyricsPicker = (props: { {(_, idx) => (
  • setLyricsStore('provider', providerNames[idx()])} + onClick={() => { + setHasManuallySwitchedProvider(true); + 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 b34f0982ea..591cf7b419 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 } from '../utils'; +import { + canonicalize, + romanize, + simplifyUnicode, + getSeekTime, + isBlank, + timeCodeText, +} from '../utils'; interface SyncedLineProps { scroller: VirtualizerHandle; @@ -15,27 +22,128 @@ interface SyncedLineProps { line: LineLyrics; status: 'upcoming' | 'current' | 'previous'; + isFinalLine?: boolean; + isFirstEmptyLine?: boolean; } +// 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)); +}; + +const renderWordSpans = (input: string) => ( + + + {(word, index) => ( + + + + )} + + +); + const EmptyLine = (props: SyncedLineProps) => { const states = createMemo(() => { const defaultText = config()?.defaultTextString ?? ''; 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 endDelayMsValue = createMemo(() => + computeEndDelayMs(props.line.duration), + ); + 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 endDelayMs = endDelayMsValue(); - const percentage = Math.min(1, progress / total); - return Math.max(0, Math.floor((states().length - 1) * percentage)); + const effectiveTotal = + total <= 1000 + ? total - endDelayMs + : precise + ? total - endDelayMs + : Math.round((total - endDelayMs) / 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 = isBlank(props.line.text); + 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 && isBlank(props.line.text); + }); + + 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); + seekToMs(props.line.timeInMs); }} >
    @@ -43,37 +151,60 @@ const EmptyLine = (props: SyncedLineProps) => { text={{ runs: [ { - text: config()?.showTimeCodes ? `[${props.line.time}] ` : '', + text: timeCodeText( + props.line.timeInMs, + config()?.preciseTiming ?? false, + config()?.showTimeCodes ?? false, + ), }, ], }} /> -
    - + {props.isFinalLine && isBlank(props.line.text) ? ( - + + + + ) : ( + - } - when={states().length > 1} - > - - - - + + } + when={isCumulative()} + > + + {(text, i) => ( + + + + )} + + + )}
    @@ -98,7 +229,7 @@ export const SyncedLine = (props: SyncedLineProps) => {
    { - _ytAPI?.seekTo((props.line.timeInMs + 10) / 1000); + seekToMs(props.line.timeInMs); }} >
    @@ -106,7 +237,11 @@ export const SyncedLine = (props: SyncedLineProps) => { text={{ runs: [ { - text: config()?.showTimeCodes ? `[${props.line.time}] ` : '', + text: timeCodeText( + props.line.timeInMs, + config()?.preciseTiming ?? false, + config()?.showTimeCodes ?? false, + ), }, ], }} @@ -124,26 +259,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())}
    diff --git a/src/plugins/synced-lyrics/renderer/renderer.tsx b/src/plugins/synced-lyrics/renderer/renderer.tsx index 24c3d70102..e5147183ea 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, @@ -10,7 +11,13 @@ import { type VirtualizerHandle, VList } from 'virtua/solid'; import { LyricsPicker } from './components/LyricsPicker'; -import { selectors } from './utils'; +import { + selectors, + getSeekTime, + SFont, + normalizePlainLyrics, + isBlank, +} from './utils'; import { ErrorDisplay, @@ -21,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'; @@ -28,13 +36,55 @@ 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; + 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'); - // Set the line effect - switch (config()?.lineEffect) { + 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'); + + 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%'); @@ -43,6 +93,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'); @@ -53,8 +104,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)', @@ -66,6 +127,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'); @@ -76,8 +138,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)', @@ -89,6 +161,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'); @@ -99,8 +172,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)', @@ -112,6 +195,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'); @@ -122,6 +206,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; } }); @@ -143,10 +233,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; @@ -174,6 +272,7 @@ export const LyricsRenderer = () => { }; onMount(() => { + SFont(); const vList = document.querySelector('.synced-lyrics-vlist'); tab.addEventListener('mousemove', mousemoveListener); @@ -190,6 +289,9 @@ export const LyricsRenderer = () => { const [children, setChildren] = createSignal([ { kind: 'LoadingKaomoji' }, ]); + const [firstEmptyIndex, setFirstEmptyIndex] = createSignal( + null, + ); createEffect(() => { const current = currentLyrics(); @@ -210,20 +312,26 @@ export const LyricsRenderer = () => { } if (data?.lines) { - return data.lines.map((line) => ({ + const lines = data.lines; + const firstEmpty = lines.findIndex((l) => isBlank(l.text)); + 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 lines = normalizePlainLyrics(data.lyrics); + return lines.map((line) => ({ kind: 'PlainLine' as const, line, })); } + setFirstEmptyIndex(null); return [{ kind: 'NotFoundKaomoji' }]; }); }); @@ -232,23 +340,35 @@ 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; + } - setStatuses(current); - return; + if (!previous.every((status, idx) => status === current[idx])) { + setStatuses(current); + } }); const [currentIndex, setCurrentIndex] = createSignal(0); @@ -258,20 +378,277 @@ 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(); const current = currentLyrics(); - const idx = currentIndex(); - const maxIdx = untrack(statuses).length - 1; + const targetIndex = scrollTargetIndex(); + const maxIndex = untrack(statuses).length - 1; + const scrollerInstance = scroller(); + const lineEffect = config()?.lineEffect; + const isEnhanced = lineEffect === 'enhanced'; - 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, + ) => { + if (fast) { + return clamp( + SCROLL_DURATION.FAST_BASE + distance * SCROLL_DURATION.FAST_MULT, + SCROLL_DURATION.FAST_MIN, + SCROLL_DURATION.FAST_MAX, + ); + } - scroller()!.scrollToIndex(scrollIndex, { - smooth: true, - align: 'center', - }); + 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) { + base = SCROLL_DURATION.JUMP1_BASE; + mult = SCROLL_DURATION.JUMP1_MULT; + min = SCROLL_DURATION.JUMP1_MIN; + max = SCROLL_DURATION.JUMP1_MAX; + } else if (jumpSize > 3) { + base = SCROLL_DURATION.JUMP4_BASE; + mult = SCROLL_DURATION.JUMP4_MULT; + min = SCROLL_DURATION.JUMP4_MIN; + max = SCROLL_DURATION.JUMP4_MAX; + } + + const duration = base + distance * mult; + return clamp(duration, min, max); + }; + + // 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 clamp(centerOffset, 0, 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 = !isEnhanced; + 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 (!isEnhanced) { + 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) 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; + if ( + jumped && + prevTimeForScroll >= 0 && + performance.now() >= suppressFastUntil() + ) { + const timeDelta = Math.abs(currentTimeMs - prevTimeForScroll); + const lineDelta = + prevIndexForFast >= 0 ? Math.abs(effIdx - prevIndexForFast) : 0; + if (timeDelta > 1500 || lineDelta >= 5) { + requestFastScroll(1500); + } + } + prevTimeForScroll = currentTimeMs; + + const scrollOffset = scroller()?.scrollOffset ?? 0; + if (effIdx === 0 && currentTimeMs > 2000 && !jumped && scrollOffset <= 1) { + return; + } + + if (lineEffect === 'enhanced') { + const nextIdx = Math.min(effIdx + 1, data.lines.length - 1); + const nextLine = data.lines[nextIdx]; + + if (nextLine) { + // start scroll early + const timeUntilNextLine = nextLine.timeInMs - currentTimeMs; + + if (timeUntilNextLine <= LEAD_IN_TIME_MS) { + setScrollTargetIndex(nextIdx); + prevIndexForFast = effIdx; + return; + } + } + } + + prevIndexForFast = effIdx; + setScrollTargetIndex(effIdx); }); return ( @@ -302,6 +679,11 @@ export const LyricsRenderer = () => { 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; diff --git a/src/plugins/synced-lyrics/renderer/utils.tsx b/src/plugins/synced-lyrics/renderer/utils.tsx index 1c6a410bd2..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, @@ -213,3 +218,60 @@ 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); + +// 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'); + 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); +}; 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')}`; +} diff --git a/src/plugins/synced-lyrics/style.css b/src/plugins/synced-lyrics/style.css index 19154b4468..cee03cff88 100644 --- a/src/plugins/synced-lyrics/style.css +++ b/src/plugins/synced-lyrics/style.css @@ -27,7 +27,7 @@ --lyrics-padding: 0; /* Typography */ - --lyrics-font-family: Satoshi, Avenir, -apple-system, BlinkMacSystemFont, + --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); @@ -52,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 { @@ -70,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 { @@ -106,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; @@ -117,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; } @@ -130,7 +187,8 @@ } } -.text-lyrics > span > span { +.text-lyrics > span > span, +.text-lyrics .placeholder { display: inline-block; white-space: pre-wrap; opacity: var(--lyrics-inactive-opacity); @@ -139,8 +197,7 @@ .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 { @@ -200,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; } @@ -232,6 +289,24 @@ div:has(> .lyrics-picker) { } } +.fade { + opacity: 0; + transition: var(--lyrics-opacity-transition) ease; + + &.show { + opacity: var(--lyrics-active-opacity); + } + + &.dim { + opacity: var(--lyrics-inactive-opacity); + } + + &, + .placeholder { + animation-name: none; + } +} + /* Animations */ @keyframes lyrics-wobble { from { 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;