feat(synced-lyrics): multiple lyric sources (#2383)

Co-authored-by: JellyBrick <shlee1503@naver.com>
This commit is contained in:
Angelos Bouklis
2024-12-25 00:44:29 +02:00
committed by GitHub
parent 5c9ded8779
commit 533b96d1f6
28 changed files with 1527 additions and 447 deletions

View File

@ -0,0 +1,64 @@
import { t } from '@/i18n';
import { getSongInfo } from '@/providers/song-info-front';
import { lyricsStore, retrySearch } from '../../providers';
interface ErrorDisplayProps {
error: Error;
}
// prettier-ignore
export const ErrorDisplay = (props: ErrorDisplayProps) => {
return (
<div style={{ 'margin-bottom': '5%' }}>
<pre
style={{
'background-color': 'var(--ytmusic-color-black1)',
'border-radius': '8px',
'color': '#58f000',
'max-width': '100%',
'margin-top': '1em',
'margin-bottom': '0',
'padding': '0.5em',
'font-family': 'serif',
'font-size': 'large',
}}
>
{t('plugins.synced-lyrics.errors.fetch')}
</pre>
<pre
style={{
'background-color': 'var(--ytmusic-color-black1)',
'border-radius': '8px',
'color': '#f0a500',
'white-space': 'pre',
'overflow-x': 'auto',
'max-width': '100%',
'margin-top': '0.5em',
'padding': '0.5em',
'font-family': 'monospace',
'font-size': 'large',
}}
>
{props.error.stack}
</pre>
<yt-button-renderer
onClick={() => retrySearch(lyricsStore.provider, getSongInfo())}
data={{
icon: { iconType: 'REFRESH' },
isDisabled: false,
style: 'STYLE_DEFAULT',
text: {
simpleText: t('plugins.synced-lyrics.refetch-btn.normal')
},
}}
style={{
'margin-top': '1em',
'width': '100%'
}}
/>
</div>
);
};

View File

@ -0,0 +1,33 @@
import { createSignal, onMount } from 'solid-js';
const states = [
'(>_<)',
'{ (>_<) }',
'{{ (>_<) }}',
'{{{ (>_<) }}}',
'{{ (>_<) }}',
'{ (>_<) }',
];
export const LoadingKaomoji = () => {
const [counter, setCounter] = createSignal(0);
onMount(() => {
const interval = setInterval(() => setCounter((old) => old + 1), 500);
return () => clearInterval(interval);
});
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: states[counter() % states.length] }],
}}
/>
);
};

View File

@ -2,145 +2,54 @@ import { createSignal, For, Match, Show, Switch } from 'solid-js';
import { SyncedLine } from './SyncedLine';
import { t } from '@/i18n';
import { getSongInfo } from '@/providers/song-info-front';
import { ErrorDisplay } from './ErrorDisplay';
import { LoadingKaomoji } from './LoadingKaomoji';
import { PlainLyrics } from './PlainLyrics';
import {
differentDuration,
hadSecondAttempt,
isFetching,
isInstrumental,
makeLyricsRequest,
} from '../lyrics/fetch';
import type { LineLyrics } from '../../types';
import { currentLyrics, lyricsStore } from '../../providers';
export const [debugInfo, setDebugInfo] = createSignal<string>();
export const [lineLyrics, setLineLyrics] = createSignal<LineLyrics[]>([]);
export const [currentTime, setCurrentTime] = createSignal<number>(-1);
// prettier-ignore
export const LyricsContainer = () => {
const [error, setError] = createSignal('');
const onRefetch = async () => {
if (isFetching()) return;
setError('');
const info = getSongInfo();
await makeLyricsRequest(info).catch((err) => {
setError(String(err));
});
};
return (
<div class={'lyric-container'}>
<div class="lyric-container">
<Switch>
<Match when={isFetching()}>
<div style="margin-bottom: 8px;">
<tp-yt-paper-spinner-lite
active
class="loading-indicator style-scope"
/>
</div>
<Match when={currentLyrics()?.state === 'fetching'}>
<LoadingKaomoji />
</Match>
<Match when={error()}>
<Match when={!currentLyrics().data?.lines && !currentLyrics().data?.lyrics}>
<yt-formatted-string
class="warning-lyrics description ytmusic-description-shelf-renderer"
class="text-lyrics description ytmusic-description-shelf-renderer"
style={{
'display': 'inline-flex',
'justify-content': 'center',
'width': '100%',
'user-select': 'none',
}}
text={{
runs: [
{
text: t('plugins.synced-lyrics.errors.fetch'),
},
],
runs: [{ text: '(_)' }],
}}
/>
</Match>
</Switch>
<Show when={lyricsStore.current.error}>
<ErrorDisplay error={lyricsStore.current.error!} />
</Show>
<Switch>
<Match when={!lineLyrics().length}>
<Show
when={isInstrumental()}
fallback={
<>
<yt-formatted-string
class="warning-lyrics description ytmusic-description-shelf-renderer"
text={{
runs: [
{
text: t('plugins.synced-lyrics.errors.not-found'),
},
],
}}
style={'margin-bottom: 16px;'}
/>
<yt-button-renderer
disabled={isFetching()}
data={{
icon: { iconType: 'REFRESH' },
isDisabled: false,
style: 'STYLE_DEFAULT',
text: {
simpleText: isFetching()
? t('plugins.synced-lyrics.refetch-btn.fetching')
: t('plugins.synced-lyrics.refetch-btn.normal'),
},
}}
onClick={onRefetch}
/>
</>
}
>
<yt-formatted-string
class="warning-lyrics description ytmusic-description-shelf-renderer"
text={{
runs: [
{
text: t('plugins.synced-lyrics.warnings.instrumental'),
},
],
}}
/>
</Show>
<Match when={currentLyrics().data?.lines}>
<For each={currentLyrics().data?.lines}>
{(item) => <SyncedLine line={item} />}
</For>
</Match>
<Match when={lineLyrics().length && !hadSecondAttempt()}>
<yt-formatted-string
class="warning-lyrics description ytmusic-description-shelf-renderer"
text={{
runs: [
{
text: t('plugins.synced-lyrics.warnings.inexact'),
},
],
}}
/>
</Match>
<Match when={lineLyrics().length && !differentDuration()}>
<yt-formatted-string
class="warning-lyrics description ytmusic-description-shelf-renderer"
text={{
runs: [
{
text: t('plugins.synced-lyrics.warnings.duration-mismatch'),
},
],
}}
/>
<Match when={currentLyrics().data?.lyrics}>
<PlainLyrics lyrics={currentLyrics().data?.lyrics!} />
</Match>
</Switch>
<For each={lineLyrics()}>{(item) => <SyncedLine line={item} />}</For>
<yt-formatted-string
class="footer style-scope ytmusic-description-shelf-renderer"
text={{
runs: [
{
text: 'Source: LRCLIB',
},
],
}}
/>
</div>
);
};

View File

@ -0,0 +1,198 @@
import {
createEffect,
createMemo,
createSignal,
For,
Index,
Match,
onMount,
Switch,
} from 'solid-js';
import {
currentLyrics,
lyricsStore,
ProviderName,
providerNames,
ProviderState,
setLyricsStore,
} from '../../providers';
import { _ytAPI } from '../index';
import type { YtIcons } from '@/types/icons';
export const providerIdx = createMemo(() =>
providerNames.indexOf(lyricsStore.provider),
);
const shouldSwitchProvider = (providerData: ProviderState) => {
if (providerData.state === 'error') return true;
if (providerData.state === 'fetching') return true;
return (
providerData.state === 'done' &&
!providerData.data?.lines &&
!providerData.data?.lyrics
);
};
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?.lyrics ? 1 : -1);
// prettier-ignore
const pickBestProvider = () => {
const providers = Array.from(providerNames);
providers.sort((a, b) => providerBias(b) - providerBias(a));
return providers[0];
};
// prettier-ignore
export const LyricsPicker = () => {
const [hasManuallySwitchedProvider, setHasManuallySwitchedProvider] = createSignal(false);
createEffect(() => {
// fallback to the next source, if the current one has an error
if (!hasManuallySwitchedProvider()
) {
const bestProvider = pickBestProvider();
const allProvidersFailed = providerNames.every((p) => shouldSwitchProvider(lyricsStore.lyrics[p]));
if (allProvidersFailed) return;
if (providerBias(lyricsStore.provider) < providerBias(bestProvider)) {
setLyricsStore('provider', bestProvider);
}
}
});
onMount(() => {
const listener = (name: string) => {
if (name !== 'dataloaded') return;
setHasManuallySwitchedProvider(false);
};
_ytAPI?.addEventListener('videodatachange', listener);
return () => _ytAPI?.removeEventListener('videodatachange', listener);
});
const next = (automatic: boolean = false) => {
if (!automatic) 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);
setLyricsStore('provider', (prevProvider) => {
const idx = providerNames.indexOf(prevProvider);
return providerNames[(idx + providerNames.length - 1) % providerNames.length];
});
};
const chevronLeft: YtIcons = 'yt-icons:chevron_left';
const chevronRight: YtIcons = 'yt-icons:chevron_right';
const successIcon: YtIcons = 'yt-icons:check-circle';
const errorIcon: YtIcons = 'yt-icons:error';
const notFoundIcon: YtIcons = 'yt-icons:warning';
return (
<div class="lyrics-picker">
<div class="lyrics-picker-left">
<tp-yt-paper-icon-button icon={chevronLeft} onClick={() => previous()} />
</div>
<div class="lyrics-picker-content">
<div class="lyrics-picker-content-label">
<Index each={providerNames}>
{(provider) => (
<div
class="lyrics-picker-item"
tabindex="-1"
style={{
transform: `translateX(${providerIdx() * -100 - 5}%)`,
}}
>
<Switch>
<Match
when={
// prettier-ignore
currentLyrics().state === 'fetching'
}
>
<tp-yt-paper-spinner-lite
active
tabindex="-1"
class="loading-indicator style-scope"
style={{ padding: '5px', transform: 'scale(0.5)' }}
/>
</Match>
<Match when={currentLyrics().state === 'error'}>
<tp-yt-paper-icon-button
icon={errorIcon}
tabindex="-1"
style={{ padding: '5px', transform: 'scale(0.5)' }}
/>
</Match>
<Match
when={
currentLyrics().state === 'done' &&
(currentLyrics().data?.lines ||
currentLyrics().data?.lyrics)
}
>
<tp-yt-paper-icon-button
icon={successIcon}
tabindex="-1"
style={{ padding: '5px', transform: 'scale(0.5)' }}
/>
</Match>
<Match when={
currentLyrics().state === 'done'
&& !currentLyrics().data?.lines
&& !currentLyrics().data?.lyrics
}>
<tp-yt-paper-icon-button
icon={notFoundIcon}
tabindex="-1"
style={{ padding: '5px', transform: 'scale(0.5)' }}
/>
</Match>
</Switch>
<yt-formatted-string
class="description ytmusic-description-shelf-renderer"
text={{ runs: [{ text: provider() }] }}
/>
</div>
)}
</Index>
</div>
<ul class="lyrics-picker-content-dots">
<For each={providerNames}>
{(_, idx) => (
<li
class="lyrics-picker-dot"
onClick={() => setLyricsStore('provider', providerNames[idx()])}
style={{
background: idx() === providerIdx() ? 'white' : 'black',
}}
/>
)}
</For>
</ul>
</div>
<div class="lyrics-picker-right">
<tp-yt-paper-icon-button icon={chevronRight} onClick={() => next()} />
</div>
</div>
);
};

View File

@ -0,0 +1,30 @@
import { createMemo, For } from 'solid-js';
interface PlainLyricsProps {
lyrics: string;
}
export const PlainLyrics = (props: PlainLyricsProps) => {
const lines = createMemo(() => props.lyrics.split('\n'));
return (
<div class="plain-lyrics">
<For each={lines()}>
{(line) => {
if (line.trim() === '') {
return <br />;
} else {
return (
<yt-formatted-string
class="text-lyrics description ytmusic-description-shelf-renderer"
text={{
runs: [{ text: line }],
}}
/>
);
}
}}
</For>
</div>
);
};

View File

@ -20,16 +20,22 @@ export const SyncedLine = ({ line }: SyncedLineProps) => {
return 'current';
});
let ref: HTMLDivElement;
let ref: HTMLDivElement | undefined;
createEffect(() => {
if (status() === 'current') {
ref.scrollIntoView({ behavior: 'smooth', block: 'center' });
ref?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
const text = createMemo(() => {
if (line.text.trim()) return line.text;
return config()?.defaultTextString ?? '';
});
// prettier-ignore
return (
<div
ref={ref!}
ref={ref}
class={`synced-line ${status()}`}
onClick={() => {
_ytAPI?.seekTo(line.timeInMs / 1000);
@ -39,13 +45,8 @@ export const SyncedLine = ({ line }: SyncedLineProps) => {
class="text-lyrics description ytmusic-description-shelf-renderer"
text={{
runs: [
{
text: '',
},
{
text: `${config()?.showTimeCodes ? `[${line.time}] ` : ''}${line.text}`,
},
],
{ text: config()?.showTimeCodes ? `[${line.time}]` : '' },
{ text: text() }],
}}
/>
</div>