perf(synced-lyrics): virtual scrolling (#3162)

Co-authored-by: JellyBrick <shlee1503@naver.com>
This commit is contained in:
Angelos Bouklis
2025-07-01 10:21:09 +03:00
committed by GitHub
parent 40ab32cbe7
commit 8fc9692ae4
15 changed files with 377 additions and 279 deletions

View File

@ -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<string>();
export const [currentTime, setCurrentTime] = createSignal<number>(-1);
// prettier-ignore
export const LyricsContainer = () => {
const [hasJapanese, setHasJapanese] = createSignal<boolean>(false);
const [hasKorean, setHasKorean] = createSignal<boolean>(false);
createEffect(() => {
const data = currentLyrics()?.data;
if (data) {
setHasKorean(hasKoreanInString(data));
setHasJapanese(hasJapaneseInString(data));
} else {
setHasKorean(false);
setHasJapanese(false);
}
});
return (
<div class="lyric-container">
<Switch>
<Match when={currentLyrics()?.state === 'fetching'}>
<LoadingKaomoji />
</Match>
<Match when={!currentLyrics().data?.lines && !currentLyrics().data?.lyrics}>
<yt-formatted-string
class="text-lyrics description ytmusic-description-shelf-renderer"
style={{
'display': 'inline-flex',
'justify-content': 'center',
'width': '100%',
'user-select': 'none',
}}
text={{
runs: [{ text: '(_)' }],
}}
/>
</Match>
</Switch>
<Show when={lyricsStore.current.error}>
<ErrorDisplay error={lyricsStore.current.error!} />
</Show>
<Switch>
<Match when={currentLyrics().data?.lines}>
<For each={currentLyrics().data?.lines}>
{(item) => <SyncedLine line={item} hasJapanese={hasJapanese()} hasKorean={hasKorean()} />}
</For>
</Match>
<Match when={currentLyrics().data?.lyrics}>
<PlainLyrics lyrics={currentLyrics().data!.lyrics!} hasJapanese={hasJapanese()} hasKorean={hasKorean()} />
</Match>
</Switch>
</div>
);
};

View File

@ -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<HTMLElement | null> }) => {
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 (
<div class="lyrics-picker">
<div class="lyrics-picker" ref={props.setStickRef}>
<div class="lyrics-picker-left">
<tp-yt-paper-icon-button icon={chevronLeft} onClick={() => previous()} />
<tp-yt-paper-icon-button icon={chevronLeft} onClick={previous} />
</div>
<div class="lyrics-picker-content">
@ -191,7 +194,7 @@ export const LyricsPicker = () => {
</div>
<div class="lyrics-picker-right">
<tp-yt-paper-icon-button icon={chevronRight} onClick={() => next()} />
<tp-yt-paper-icon-button icon={chevronRight} onClick={next} />
</div>
</div>
);

View File

@ -0,0 +1,16 @@
export const NotFoundKaomoji = () => {
return (
<yt-formatted-string
class="text-lyrics description ytmusic-description-shelf-renderer"
style={{
'display': 'inline-flex',
'justify-content': 'center',
'width': '100%',
'user-select': 'none',
}}
text={{
runs: [{ text: '(_)' }],
}}
/>
);
};

View File

@ -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<string, string>
>({});
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 (
<div class="plain-lyrics">
<For each={combinedLines()}>
{([line, romanized]) => {
return (
<div
class={`${
line.match(/^\[.+\]$/s) ? 'lrc-header' : ''
} text-lyrics description ytmusic-description-shelf-renderer`}
style={{
'display': 'flex',
'flex-direction': 'column',
}}
>
<yt-formatted-string
text={{
runs: [{ text: line }],
}}
/>
<Show
when={
config()?.romanization &&
simplifyUnicode(line) !== simplifyUnicode(romanized)
}
>
<yt-formatted-string
class="romaji"
text={{
runs: [{ text: romanized }],
}}
/>
</Show>
</div>
);
<div
class={`${
props.line.match(/^\[.+\]$/s) ? 'lrc-header' : ''
} text-lyrics description ytmusic-description-shelf-renderer`}
style={{
'display': 'flex',
'flex-direction': 'column',
}}
>
<yt-formatted-string
text={{
runs: [{ text: props.line }],
}}
</For>
/>
<Show
when={
config()?.romanization &&
simplifyUnicode(props.line) !== simplifyUnicode(romanization())
}
>
<yt-formatted-string
class="romaji"
text={{
runs: [{ text: romanization() }],
}}
/>
</Show>
</div>
);
};

View File

@ -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 (
<div
ref={ref}
class={`synced-line ${status()}`}
class={`synced-line ${props.status}`}
onClick={() => {
_ytAPI?.seekTo(props.line.timeInMs / 1000);
_ytAPI?.seekTo((props.line.timeInMs + 10) / 1000);
}}
>
<div dir="auto" class="description ytmusic-description-shelf-renderer">

View File

@ -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';