mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-19 06:02:06 +00:00
perf(synced-lyrics): virtual scrolling (#3162)
Co-authored-by: JellyBrick <shlee1503@naver.com>
This commit is contained in:
@ -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<boolean>(false);
|
||||
export const [config, setConfig] =
|
||||
@ -109,44 +125,192 @@ createEffect(() => {
|
||||
}
|
||||
});
|
||||
|
||||
export const LyricsRenderer = () => {
|
||||
const [stickyRef, setStickRef] = createSignal<HTMLElement | null>(null);
|
||||
|
||||
// prettier-ignore
|
||||
onMount(() => {
|
||||
const tab = document.querySelector<HTMLElement>(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<number>(-1);
|
||||
export const LyricsRenderer = () => {
|
||||
const [scroller, setScroller] = createSignal<VirtualizerHandle>();
|
||||
const [stickyRef, setStickRef] = createSignal<HTMLElement | null>(null);
|
||||
|
||||
const tab = document.querySelector<HTMLElement>(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<HTMLElement>(`.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<LyricsRendererChild[]>([
|
||||
{ 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 (
|
||||
<Show when={isVisible()}>
|
||||
<div class="lyrics-renderer">
|
||||
<div class="lyrics-renderer-sticky" ref={setStickRef}>
|
||||
<LyricsPicker />
|
||||
<div
|
||||
id="divider"
|
||||
class="style-scope ytmusic-guide-section-renderer"
|
||||
style={{ width: '100%', margin: '0' }}
|
||||
></div>
|
||||
</div>
|
||||
<LyricsContainer />
|
||||
</div>
|
||||
<VList
|
||||
{...{
|
||||
ref: setScroller,
|
||||
style: { 'scrollbar-width': 'none' },
|
||||
class: 'synced-lyrics-vlist',
|
||||
keepMounted: [0],
|
||||
overscan: 4,
|
||||
}}
|
||||
data={[lyricsPicker, ...children()]}
|
||||
>
|
||||
{(props, idx) => {
|
||||
if (typeof props === 'undefined') return null;
|
||||
switch (props.kind) {
|
||||
case 'LyricsPicker':
|
||||
return <LyricsPicker setStickRef={setStickRef} />;
|
||||
case 'Error':
|
||||
return <ErrorDisplay {...props} />;
|
||||
case 'LoadingKaomoji':
|
||||
return <LoadingKaomoji />;
|
||||
case 'NotFoundKaomoji':
|
||||
return <NotFoundKaomoji />;
|
||||
case 'SyncedLine': {
|
||||
return (
|
||||
<SyncedLine
|
||||
{...props}
|
||||
scroller={scroller()!}
|
||||
index={idx()}
|
||||
status={statuses()[idx() - 1]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'PlainLine': {
|
||||
return <PlainLyrics {...props} />;
|
||||
}
|
||||
}
|
||||
}}
|
||||
</VList>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user