mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-08 09:11:46 +00:00
perf(synced-lyrics): virtual scrolling (#3162)
Co-authored-by: JellyBrick <shlee1503@naver.com>
This commit is contained in:
@ -2,5 +2,6 @@
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"quoteProps": "consistent"
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
37
pnpm-lock.yaml
generated
37
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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 };
|
||||
|
||||
|
||||
@ -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: '\(〇_o)/' }],
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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: '\(〇_o)/' }],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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">
|
||||
|
||||
6
src/plugins/synced-lyrics/renderer/components/index.ts
Normal file
6
src/plugins/synced-lyrics/renderer/components/index.ts
Normal 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';
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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<string, (line: string) => Promise<string> | 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),
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"baseUrl": ".",
|
||||
|
||||
Reference in New Issue
Block a user