From 8fc9692ae4812c053dcd173a9c4210ee1a17bbe5 Mon Sep 17 00:00:00 2001 From: Angelos Bouklis Date: Tue, 1 Jul 2025 10:21:09 +0300 Subject: [PATCH] perf(synced-lyrics): virtual scrolling (#3162) Co-authored-by: JellyBrick --- .prettierrc | 1 + package.json | 2 + pnpm-lock.yaml | 37 +++ src/plugins/downloader/main/index.ts | 9 +- .../renderer/components/LyricsContainer.tsx | 70 ------ .../renderer/components/LyricsPicker.tsx | 29 ++- .../renderer/components/NotFoundKaomoji.tsx | 16 ++ .../renderer/components/PlainLyrics.tsx | 107 +++----- .../renderer/components/SyncedLine.tsx | 50 +--- .../renderer/components/index.ts | 6 + src/plugins/synced-lyrics/renderer/index.ts | 3 +- .../synced-lyrics/renderer/renderer.tsx | 232 +++++++++++++++--- src/plugins/synced-lyrics/renderer/utils.tsx | 46 ++-- src/plugins/synced-lyrics/style.css | 46 ++-- tsconfig.json | 2 +- 15 files changed, 377 insertions(+), 279 deletions(-) delete mode 100644 src/plugins/synced-lyrics/renderer/components/LyricsContainer.tsx create mode 100644 src/plugins/synced-lyrics/renderer/components/NotFoundKaomoji.tsx create mode 100644 src/plugins/synced-lyrics/renderer/components/index.ts diff --git a/.prettierrc b/.prettierrc index a4e22569..c6ce9c54 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,5 +2,6 @@ "tabWidth": 2, "useTabs": false, "singleQuote": true, + "trailingComma": "all", "quoteProps": "consistent" } diff --git a/package.json b/package.json index 1768898c..80b9b402 100644 --- a/package.json +++ b/package.json @@ -302,7 +302,9 @@ "solid-styled-components": "0.28.5", "solid-transition-group": "0.3.0", "tiny-pinyin": "1.3.2", + "tinyld": "1.3.4", "ts-morph": "26.0.0", + "virtua": "0.41.1", "vudio": "2.1.1", "x11": "2.3.0", "youtubei.js": "14.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e907e25..ad15d048 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -220,9 +220,15 @@ importers: tiny-pinyin: specifier: 1.3.2 version: 1.3.2 + tinyld: + specifier: 1.3.4 + version: 1.3.4 ts-morph: specifier: 26.0.0 version: 26.0.0 + virtua: + specifier: 0.41.1 + version: 0.41.1(solid-js@1.9.7) vudio: specifier: 2.1.1 version: 2.1.1(patch_hash=0e06c2ed11c02bdc490c209fa80070e98517c2735c641f5738b6e15d7dc1959d) @@ -4515,6 +4521,11 @@ packages: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} engines: {node: '>=12.0.0'} + tinyld@1.3.4: + resolution: {integrity: sha512-u26CNoaInA4XpDU+8s/6Cq8xHc2T5M4fXB3ICfXPokUQoLzmPgSZU02TAkFwFMJCWTjk53gtkS8pETTreZwCqw==} + engines: {node: '>= 12.10.0', npm: '>= 6.12.0', yarn: '>= 1.20.0'} + hasBin: true + tldts-core@7.0.9: resolution: {integrity: sha512-/FGY1+CryHsxF9SFiPZlMOcwQsfABkAvOJO5VEKE8TNifVEqgMF7+UVXHGhm1z4gPUfvVS/EYcwhiRU3vUa1ag==} @@ -4706,6 +4717,26 @@ packages: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} + virtua@0.41.1: + resolution: {integrity: sha512-QL57UVmXPxpnJnKEn/DPrRzmw0kL5mD8svz3X98WfcliDV9FMAv4b6FaHMMPUYPFqdHZCPnC+hh9aFNKs3UaIg==} + peerDependencies: + react: '>=16.14.0' + react-dom: '>=16.14.0' + solid-js: '>=1.0' + svelte: '>=5.0' + vue: '>=3.2' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vue: + optional: true + vite-dev-rpc@1.1.0: resolution: {integrity: sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==} peerDependencies: @@ -9549,6 +9580,8 @@ snapshots: fdir: 6.4.6(picomatch@4.0.2) picomatch: 4.0.2 + tinyld@1.3.4: {} + tldts-core@7.0.9: {} tldts-experimental@7.0.9: @@ -9766,6 +9799,10 @@ snapshots: extsprintf: 1.4.1 optional: true + virtua@0.41.1(solid-js@1.9.7): + optionalDependencies: + solid-js: 1.9.7 + vite-dev-rpc@1.1.0(rolldown-vite@7.0.3(@types/node@22.13.5)(esbuild@0.25.5)(yaml@2.8.0)): dependencies: birpc: 2.4.0 diff --git a/src/plugins/downloader/main/index.ts b/src/plugins/downloader/main/index.ts index ad76b96f..92e1e967 100644 --- a/src/plugins/downloader/main/index.ts +++ b/src/plugins/downloader/main/index.ts @@ -35,12 +35,11 @@ import { DefaultPresetList, type Preset, YoutubeFormatList } from '../types'; import type { DownloaderPluginConfig } from '../index'; import type { BackendContext } from '@/types/contexts'; -import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils'; -import type PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage'; -import type { Playlist } from 'youtubei.js/dist/src/parser/ytmusic'; -import type { VideoInfo } from 'youtubei.js/dist/src/parser/youtube'; -import type TrackInfo from 'youtubei.js/dist/src/parser/ytmusic/TrackInfo'; import type { GetPlayerResponse } from '@/types/get-player-response'; +import type { FormatOptions } from 'node_modules/youtubei.js/dist/src/types'; +import type { VideoInfo } from 'node_modules/youtubei.js/dist/src/parser/youtube'; +import type { PlayerErrorMessage } from 'node_modules/youtubei.js/dist/src/parser/nodes'; +import type { TrackInfo, Playlist } from 'node_modules/youtubei.js/dist/src/parser/ytmusic'; type CustomSongInfo = SongInfo & { trackId?: string }; diff --git a/src/plugins/synced-lyrics/renderer/components/LyricsContainer.tsx b/src/plugins/synced-lyrics/renderer/components/LyricsContainer.tsx deleted file mode 100644 index c8b2dc0d..00000000 --- a/src/plugins/synced-lyrics/renderer/components/LyricsContainer.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { createEffect, createSignal, For, Match, Show, Switch } from 'solid-js'; - -import { SyncedLine } from './SyncedLine'; - -import { ErrorDisplay } from './ErrorDisplay'; -import { LoadingKaomoji } from './LoadingKaomoji'; -import { PlainLyrics } from './PlainLyrics'; - -import { hasJapaneseInString, hasKoreanInString } from '../utils'; -import { currentLyrics, lyricsStore } from '../../providers'; - -export const [debugInfo, setDebugInfo] = createSignal(); -export const [currentTime, setCurrentTime] = createSignal(-1); - -// prettier-ignore -export const LyricsContainer = () => { - const [hasJapanese, setHasJapanese] = createSignal(false); - const [hasKorean, setHasKorean] = createSignal(false); - - createEffect(() => { - const data = currentLyrics()?.data; - if (data) { - setHasKorean(hasKoreanInString(data)); - setHasJapanese(hasJapaneseInString(data)); - } else { - setHasKorean(false); - setHasJapanese(false); - } - }); - - return ( -
- - - - - - - - - - - - - - - - - {(item) => } - - - - - - - -
- ); -}; diff --git a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx index e29f53a8..5044a04a 100644 --- a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx +++ b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx @@ -5,7 +5,9 @@ import { For, Index, Match, + onCleanup, onMount, + Setter, Switch, } from 'solid-js'; @@ -51,9 +53,11 @@ const pickBestProvider = () => { return providers[0]; }; +const [hasManuallySwitchedProvider, setHasManuallySwitchedProvider] = + createSignal(false); + // prettier-ignore -export const LyricsPicker = () => { - const [hasManuallySwitchedProvider, setHasManuallySwitchedProvider] = createSignal(false); +export const LyricsPicker = (props: { setStickRef: Setter }) => { createEffect(() => { // fallback to the next source, if the current one has an error if (!hasManuallySwitchedProvider() @@ -70,25 +74,25 @@ export const LyricsPicker = () => { }); onMount(() => { - const listener = (name: string) => { + const videoDataChangeHandler = (name: string) => { if (name !== 'dataloaded') return; setHasManuallySwitchedProvider(false); }; - _ytAPI?.addEventListener('videodatachange', listener); - return () => _ytAPI?.removeEventListener('videodatachange', listener); + _ytAPI?.addEventListener('videodatachange', videoDataChangeHandler); + onCleanup(() => _ytAPI?.removeEventListener('videodatachange', videoDataChangeHandler)); }); - const next = (automatic: boolean = false) => { - if (!automatic) setHasManuallySwitchedProvider(true); + const next = () => { + setHasManuallySwitchedProvider(true); setLyricsStore('provider', (prevProvider) => { const idx = providerNames.indexOf(prevProvider); return providerNames[(idx + 1) % providerNames.length]; }); }; - const previous = (automatic: boolean = false) => { - if (!automatic) setHasManuallySwitchedProvider(true); + const previous = () => { + setHasManuallySwitchedProvider(true); setLyricsStore('provider', (prevProvider) => { const idx = providerNames.indexOf(prevProvider); return providerNames[(idx + providerNames.length - 1) % providerNames.length]; @@ -102,11 +106,10 @@ export const LyricsPicker = () => { const errorIcon: YtIcons = 'yt-icons:error'; const notFoundIcon: YtIcons = 'yt-icons:warning'; - return ( -
+
- previous()} /> +
@@ -191,7 +194,7 @@ export const LyricsPicker = () => {
- next()} /> +
); diff --git a/src/plugins/synced-lyrics/renderer/components/NotFoundKaomoji.tsx b/src/plugins/synced-lyrics/renderer/components/NotFoundKaomoji.tsx new file mode 100644 index 00000000..6556404d --- /dev/null +++ b/src/plugins/synced-lyrics/renderer/components/NotFoundKaomoji.tsx @@ -0,0 +1,16 @@ +export const NotFoundKaomoji = () => { + return ( + + ); +}; diff --git a/src/plugins/synced-lyrics/renderer/components/PlainLyrics.tsx b/src/plugins/synced-lyrics/renderer/components/PlainLyrics.tsx index 9284450f..90d29e46 100644 --- a/src/plugins/synced-lyrics/renderer/components/PlainLyrics.tsx +++ b/src/plugins/synced-lyrics/renderer/components/PlainLyrics.tsx @@ -1,93 +1,50 @@ -import { createEffect, createMemo, createSignal, For, Show } from 'solid-js'; +import { createEffect, createSignal, Show } from 'solid-js'; -import { - canonicalize, - romanizeChinese, - romanizeHangul, - romanizeJapanese, - romanizeJapaneseOrHangul, - simplifyUnicode, -} from '../utils'; +import { canonicalize, romanize, simplifyUnicode } from '../utils'; import { config } from '../renderer'; interface PlainLyricsProps { - lyrics: string; - hasJapanese: boolean; - hasKorean: boolean; + line: string; } export const PlainLyrics = (props: PlainLyricsProps) => { - const lines = props.lyrics.split('\n').filter((line) => line.trim()); - const [romanizedLines, setRomanizedLines] = createSignal< - Record - >({}); - - const combinedLines = createMemo(() => { - const out = []; - - for (let i = 0; i < lines.length; i++) { - out.push([lines[i], romanizedLines()[i]]); - } - - return out; - }); + const [romanization, setRomanization] = createSignal(''); createEffect(async () => { if (!config()?.romanization) return; - for (let i = 0; i < lines.length; i++) { - let romanized: string; - - if (props.hasJapanese) { - if (props.hasKorean) - romanized = await romanizeJapaneseOrHangul(lines[i]); - else romanized = await romanizeJapanese(lines[i]); - } else if (props.hasKorean) romanized = romanizeHangul(lines[i]); - else romanized = romanizeChinese(lines[i]); - - setRomanizedLines((prev) => ({ - ...prev, - [i]: canonicalize(romanized), - })); - } + const input = canonicalize(props.line); + setRomanization(canonicalize(await romanize(input))); }); return ( -
- - {([line, romanized]) => { - return ( -
- - - - -
- ); +
+ + /> + + +
); }; diff --git a/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx index abca8fc0..a9bffda7 100644 --- a/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx +++ b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx @@ -1,43 +1,22 @@ import { createEffect, createMemo, For, Show, createSignal } from 'solid-js'; -import { currentTime } from './LyricsContainer'; - import { config } from '../renderer'; import { _ytAPI } from '..'; -import { - canonicalize, - romanizeChinese, - romanizeHangul, - romanizeJapanese, - romanizeJapaneseOrHangul, - simplifyUnicode, -} from '../utils'; +import { canonicalize, romanize, simplifyUnicode } from '../utils'; -import type { LineLyrics } from '../../types'; +import { VirtualizerHandle } from 'virtua/solid'; +import { LineLyrics } from '@/plugins/synced-lyrics/types'; interface SyncedLineProps { + scroller: VirtualizerHandle; + index: number; + line: LineLyrics; - hasJapanese: boolean; - hasKorean: boolean; + status: 'upcoming' | 'current' | 'previous'; } export const SyncedLine = (props: SyncedLineProps) => { - const status = createMemo(() => { - const current = currentTime(); - - if (props.line.timeInMs >= current) return 'upcoming'; - if (current - props.line.timeInMs >= props.line.duration) return 'previous'; - return 'current'; - }); - - let ref: HTMLDivElement | undefined; - createEffect(() => { - if (status() === 'current') { - ref?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - }); - const text = createMemo(() => { if (!props.line.text.trim()) { return config()?.defaultTextString ?? ''; @@ -52,15 +31,7 @@ export const SyncedLine = (props: SyncedLineProps) => { if (!config()?.romanization) return; const input = canonicalize(text()); - - let result: string; - if (props.hasJapanese) { - if (props.hasKorean) result = await romanizeJapaneseOrHangul(input); - else result = await romanizeJapanese(input); - } else if (props.hasKorean) result = romanizeHangul(input); - else result = romanizeChinese(input); - - setRomanization(canonicalize(result)); + setRomanization(canonicalize(await romanize(input))); }); if (!text()) { @@ -75,10 +46,9 @@ export const SyncedLine = (props: SyncedLineProps) => { return (
{ - _ytAPI?.seekTo(props.line.timeInMs / 1000); + _ytAPI?.seekTo((props.line.timeInMs + 10) / 1000); }} >
diff --git a/src/plugins/synced-lyrics/renderer/components/index.ts b/src/plugins/synced-lyrics/renderer/components/index.ts new file mode 100644 index 00000000..af966d3b --- /dev/null +++ b/src/plugins/synced-lyrics/renderer/components/index.ts @@ -0,0 +1,6 @@ +export { ErrorDisplay } from './ErrorDisplay'; +export { LoadingKaomoji } from './LoadingKaomoji'; +export { NotFoundKaomoji } from './NotFoundKaomoji'; +export { LyricsPicker } from './LyricsPicker'; +export { SyncedLine } from './SyncedLine'; +export { PlainLyrics } from './PlainLyrics'; diff --git a/src/plugins/synced-lyrics/renderer/index.ts b/src/plugins/synced-lyrics/renderer/index.ts index bfcd249b..a985df97 100644 --- a/src/plugins/synced-lyrics/renderer/index.ts +++ b/src/plugins/synced-lyrics/renderer/index.ts @@ -2,8 +2,7 @@ import { createRenderer } from '@/utils'; import { waitForElement } from '@/utils/wait-for-element'; import { selectors, tabStates } from './utils'; -import { setConfig } from './renderer'; -import { setCurrentTime } from './components/LyricsContainer'; +import { setConfig, setCurrentTime } from './renderer'; import { fetchLyrics } from '../providers'; diff --git a/src/plugins/synced-lyrics/renderer/renderer.tsx b/src/plugins/synced-lyrics/renderer/renderer.tsx index 7c905065..c21878aa 100644 --- a/src/plugins/synced-lyrics/renderer/renderer.tsx +++ b/src/plugins/synced-lyrics/renderer/renderer.tsx @@ -1,11 +1,27 @@ -import { createEffect, createSignal, onMount, Show } from 'solid-js'; +import { + createEffect, + createMemo, + createSignal, + onCleanup, + onMount, + Show, + untrack, +} from 'solid-js'; +import { VirtualizerHandle, VList } from 'virtua/solid'; -import { LyricsContainer } from './components/LyricsContainer'; import { LyricsPicker } from './components/LyricsPicker'; import { selectors } from './utils'; -import type { SyncedLyricsPluginConfig } from '../types'; +import type { LineLyrics, SyncedLyricsPluginConfig } from '../types'; +import { currentLyrics } from '../providers'; +import { + ErrorDisplay, + LoadingKaomoji, + NotFoundKaomoji, + SyncedLine, + PlainLyrics, +} from './components'; export const [isVisible, setIsVisible] = createSignal(false); export const [config, setConfig] = @@ -109,44 +125,192 @@ createEffect(() => { } }); -export const LyricsRenderer = () => { - const [stickyRef, setStickRef] = createSignal(null); - - // prettier-ignore - onMount(() => { - const tab = document.querySelector(selectors.body.tabRenderer)!; - - const mousemoveListener = (e: MouseEvent) => { - const { top } = tab.getBoundingClientRect(); - const { clientHeight: height } = stickyRef()!; - - const showPicker = (e.clientY - top - 5) <= height; - if (showPicker) { - // picker visible - stickyRef()!.style.setProperty('--top', '0'); - } else { - // picker hidden - stickyRef()!.style.setProperty('--top', '-50%'); - } +type LyricsRendererChild = + | { kind: 'LyricsPicker' } + | { kind: 'LoadingKaomoji' } + | { kind: 'NotFoundKaomoji' } + | { kind: 'Error'; error: Error } + | { + kind: 'SyncedLine'; + line: LineLyrics; + } + | { + kind: 'PlainLine'; + line: string; }; +const lyricsPicker: LyricsRendererChild = { kind: 'LyricsPicker' }; + +export const [currentTime, setCurrentTime] = createSignal(-1); +export const LyricsRenderer = () => { + const [scroller, setScroller] = createSignal(); + const [stickyRef, setStickRef] = createSignal(null); + + const tab = document.querySelector(selectors.body.tabRenderer)!; + + let mouseCoord = 0; + const mousemoveListener = (e: Event) => { + if ('clientY' in e) { + mouseCoord = (e as MouseEvent).clientY; + } + + const { top } = tab.getBoundingClientRect(); + const { clientHeight: height } = stickyRef()!; + const scrollOffset = scroller()!.scrollOffset; + + const isInView = scrollOffset <= height; + const isMouseOver = mouseCoord - top - 5 <= height; + + const showPicker = isInView || isMouseOver; + + if (showPicker) { + // picker visible + stickyRef()!.style.setProperty('--lyrics-picker-top', '0'); + } else { + // picker hidden + stickyRef()!.style.setProperty('--lyrics-picker-top', `-${height}px`); + } + }; + + onMount(() => { + const vList = document.querySelector(`.synced-lyrics-vlist`); + tab.addEventListener('mousemove', mousemoveListener); - return () => tab.removeEventListener('mousemove', mousemoveListener); + vList?.addEventListener('scroll', mousemoveListener); + vList?.addEventListener('scrollend', mousemoveListener); + + onCleanup(() => { + tab.removeEventListener('mousemove', mousemoveListener); + vList?.removeEventListener('scroll', mousemoveListener); + vList?.removeEventListener('scrollend', mousemoveListener); + }); + }); + + const [children, setChildren] = createSignal([ + { kind: 'LoadingKaomoji' }, + ]); + + createEffect(() => { + const current = currentLyrics(); + if (!current) { + setChildren(() => [{ kind: 'NotFoundKaomoji' }]); + return; + } + + const { state, data, error } = current; + + setChildren(() => { + if (state === 'fetching') { + return [{ kind: 'LoadingKaomoji' }]; + } + + if (state === 'error') { + return [{ kind: 'Error', error: error! }]; + } + + if (data?.lines) { + return data.lines!.map((line) => ({ + kind: 'SyncedLine' as const, + line, + })); + } + + if (data?.lyrics) { + const lines = data.lyrics.split('\n').filter((line) => line.trim()); + return lines.map((line) => ({ + kind: 'PlainLine' as const, + line, + })); + } + + return [{ kind: 'NotFoundKaomoji' }]; + }); + }); + + const [statuses, setStatuses] = createSignal< + ('previous' | 'current' | 'upcoming')[] + >([]); + createEffect(() => { + const time = currentTime(); + const data = currentLyrics()?.data; + + if (!data || !data.lines) return setStatuses([]), void 0; + + const previous = untrack(statuses); + const current = data.lines.map((line) => { + if (line.timeInMs >= time) return 'upcoming'; + if (time - line.timeInMs >= line.duration) return 'previous'; + return 'current'; + }); + + if (previous.length !== current.length) return setStatuses(current), void 0; + if (previous.every((status, idx) => status === current[idx])) return; + + setStatuses(current); + }); + + const [currentIndex, setCurrentIndex] = createSignal(0); + createEffect(() => { + const index = statuses().findIndex((status) => status === 'current'); + if (index === -1) return; + setCurrentIndex(index); + }); + + createEffect(() => { + const current = currentLyrics(); + const idx = currentIndex(); + const maxIdx = untrack(statuses).length - 1; + + if (!scroller() || !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); + + scroller()!.scrollToIndex(scrollIndex, { + smooth: true, + align: 'center', + }); }); return ( -
-
- -
-
- -
+ + {(props, idx) => { + if (typeof props === 'undefined') return null; + switch (props.kind) { + case 'LyricsPicker': + return ; + case 'Error': + return ; + case 'LoadingKaomoji': + return ; + case 'NotFoundKaomoji': + return ; + case 'SyncedLine': { + return ( + + ); + } + case 'PlainLine': { + return ; + } + } + }} +
); }; diff --git a/src/plugins/synced-lyrics/renderer/utils.tsx b/src/plugins/synced-lyrics/renderer/utils.tsx index 0195b45e..0340b49b 100644 --- a/src/plugins/synced-lyrics/renderer/utils.tsx +++ b/src/plugins/synced-lyrics/renderer/utils.tsx @@ -13,7 +13,7 @@ import { lazy } from 'lazy-var'; import { waitForElement } from '@/utils/wait-for-element'; import { LyricsRenderer, setIsVisible } from './renderer'; -import type { LyricResult } from '@/plugins/synced-lyrics/types'; +import { detect } from 'tinyld'; export const selectors = { head: '#tabsContent > .tab-header:nth-of-type(2)', @@ -152,35 +152,41 @@ const hasJapanese = (lines: string[]) => const hasKorean = (lines: string[]) => lines.some((line) => /[ㄱ-ㅎㅏ-ㅣ가-힣]+/.test(line)); -export const hasJapaneseInString = (lyric: LyricResult) => { - if (!lyric || (!lyric.lines && !lyric.lyrics)) return false; - const lines = Array.isArray(lyric.lines) - ? lyric.lines.map(({ text }) => text) - : lyric.lyrics!.split('\n'); - return hasJapanese(lines); -}; +const hasChinese = (lines: string[]) => + lines.some((line) => /[\u4E00-\u9FFF]+/.test(line)); -export const hasKoreanInString = (lyric: LyricResult) => { - if (!lyric || (!lyric.lines && !lyric.lyrics)) return false; +export const romanize = async (line: string) => { + const lang = detect(line); - const lines = Array.isArray(lyric.lines) - ? lyric.lines.map(({ text }) => text) - : lyric.lyrics!.split('\n'); + const handlers: Record Promise | string> = { + ja: romanizeJapanese, + ko: romanizeHangul, + zh: romanizeChinese, + }; - return hasKorean(lines); + const NO_OP = (l: string) => l; + const handler = handlers[lang] ?? NO_OP; + + line = await handler(line); + + if (hasJapanese([line])) line = await romanizeJapanese(line); + if (hasKorean([line])) line = romanizeHangul(line); + if (hasChinese([line])) line = romanizeChinese(line); + + return line; }; export const romanizeJapanese = async (line: string) => (await kuroshiro.get()).convert(line, { to: 'romaji', mode: 'spaced', - }) ?? ''; + }) ?? line; export const romanizeHangul = (line: string) => esHangulRomanize(hanja.translate(line, 'SUBSTITUTION')); -export const romanizeJapaneseOrHangul = async (line: string) => - romanizeHangul(await romanizeJapanese(line)); - -export const romanizeChinese = (line: string) => - pinyin.convertToPinyin(line, ' ', true); +export const romanizeChinese = (line: string) => { + return line.replaceAll(/[\u4E00-\u9FFF]+/g, (match) => + pinyin.convertToPinyin(match, ' ', true), + ); +}; diff --git a/src/plugins/synced-lyrics/style.css b/src/plugins/synced-lyrics/style.css index a6edc34a..19154b44 100644 --- a/src/plugins/synced-lyrics/style.css +++ b/src/plugins/synced-lyrics/style.css @@ -6,6 +6,7 @@ #tab-renderer[page-type='MUSIC_PAGE_TYPE_TRACK_LYRICS'] > #synced-lyrics-container { display: block !important; + height: 100%; } /* Hide the scrollbar in the lyrics-tab */ @@ -61,6 +62,7 @@ .lyric-container { padding-top: 16px; + height: 100%; } .description { @@ -84,20 +86,6 @@ } } -.plain-lyrics .romaji { - color: var(--ytmusic-text-secondary) !important; - font-size: calc(var(--lyrics-font-size) * 0.7) !important; - font-style: italic !important; -} - -.plain-lyrics .lrc-header { - color: var(--ytmusic-color-grey5) !important; - scale: 0.9; - height: fit-content; - padding: 0; - padding-block: 0.2em; -} - .synced-lyrics { display: block; justify-content: left; @@ -126,6 +114,20 @@ text-align: left; margin: var(--global-margin) 0; transform-origin: 0 50%; + + &.lrc-header { + color: var(--ytmusic-color-grey5) !important; + scale: 0.9; + height: fit-content; + padding: 0; + padding-block: 0.2em; + } + + & > .romaji { + color: var(--ytmusic-text-secondary) !important; + font-size: calc(var(--lyrics-font-size) * 0.7) !important; + font-style: italic !important; + } } .text-lyrics > span > span { @@ -149,6 +151,7 @@ .lyrics-renderer { display: flex; flex-direction: column; + height: 100%; } .lyrics-picker { @@ -215,13 +218,18 @@ } } -.lyrics-renderer-sticky { - position: sticky; - top: var(--top, 0); +div:has(> .lyrics-picker) { z-index: 100; - backdrop-filter: blur(5px); + position: sticky !important; - transition: top 325ms ease-in-out; + & > .lyrics-picker { + position: relative; + + top: var(--lyrics-picker-top, 0) !important; + transition: top 325ms ease-in-out; + + backdrop-filter: blur(5px); + } } /* Animations */ diff --git a/tsconfig.json b/tsconfig.json index 05af1636..5c7737eb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,7 @@ "allowSyntheticDefaultImports": true, "esModuleInterop": true, "resolveJsonModule": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "jsx": "preserve", "jsxImportSource": "solid-js", "baseUrl": ".",