mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-13 03:11:46 +00:00
feat(synced-lyrics): multiple lyric sources (#2383)
Co-authored-by: JellyBrick <shlee1503@naver.com>
This commit is contained in:
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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] }],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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: '\(〇_o)/' }],
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
198
src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx
Normal file
198
src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user