Files
youtube-music/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx
2025-09-06 04:51:31 +09:00

275 lines
8.2 KiB
TypeScript

import {
createEffect,
createMemo,
createSignal,
For,
Index,
Match,
onCleanup,
onMount,
type Setter,
Switch,
} from 'solid-js';
import {
currentLyrics,
lyricsStore,
type ProviderName,
providerNames,
type 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);
const pickBestProvider = () => {
const providers = Array.from(providerNames);
providers.sort((a, b) => providerBias(b) - providerBias(a));
return providers[0];
};
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()) {
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 videoDataChangeHandler = (name: string) => {
if (name !== 'dataloaded') return;
setHasManuallySwitchedProvider(false);
};
_ytAPI?.addEventListener('videodatachange', videoDataChangeHandler);
onCleanup(() =>
_ytAPI?.removeEventListener('videodatachange', videoDataChangeHandler),
);
});
const next = () => {
setHasManuallySwitchedProvider(true);
setLyricsStore('provider', (prevProvider) => {
const idx = providerNames.indexOf(prevProvider);
return providerNames[(idx + 1) % providerNames.length];
});
};
const previous = () => {
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" ref={props.setStickRef}>
<div class="lyrics-picker-left">
<yt-icon-button
class="style-scope ytmusic-player-bar"
icon={chevronLeft}
onClick={previous}
role={'button'}
>
<span class="yt-icon-shape style-scope yt-icon yt-spec-icon-shape">
<div
style={{
'width': '100%',
'height': '100%',
'display': 'flex',
'align-items': 'center',
'fill': 'currentcolor',
}}
>
<svg
class="style-scope yt-icon"
fill="#FFFFFF"
height={'40px'}
preserveAspectRatio="xMidYMid meet"
viewBox="0 -960 960 960"
width={'40px'}
>
<g class="style-scope yt-icon">
<path
class="style-scope yt-icon"
d="M560.67-240 320-480.67l240.67-240.66L608-674 414.67-480.67 608-287.33 560.67-240Z"
/>
</g>
</svg>
</div>
</span>
</yt-icon-button>
</div>
<div class="lyrics-picker-content">
<div class="lyrics-picker-content-label">
<Index each={providerNames}>
{(provider) => (
<div
class="lyrics-picker-item"
style={{
transform: `translateX(${providerIdx() * -100 - 5}%)`,
}}
tabindex="-1"
>
<Switch>
<Match
when={
// prettier-ignore
currentLyrics().state === 'fetching'
}
>
<tp-yt-paper-spinner-lite
active
class="loading-indicator style-scope"
style={{ padding: '5px', transform: 'scale(0.5)' }}
tabindex="-1"
/>
</Match>
<Match when={currentLyrics().state === 'error'}>
<yt-icon-button
icon={errorIcon}
style={{ padding: '5px', transform: 'scale(0.5)' }}
tabindex="-1"
/>
</Match>
<Match
when={
currentLyrics().state === 'done' &&
(currentLyrics().data?.lines ||
currentLyrics().data?.lyrics)
}
>
<yt-icon-button
icon={successIcon}
style={{ padding: '5px', transform: 'scale(0.5)' }}
tabindex="-1"
/>
</Match>
<Match
when={
currentLyrics().state === 'done' &&
!currentLyrics().data?.lines &&
!currentLyrics().data?.lyrics
}
>
<yt-icon-button
icon={notFoundIcon}
style={{ padding: '5px', transform: 'scale(0.5)' }}
tabindex="-1"
/>
</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">
<yt-icon-button
class="style-scope ytmusic-player-bar"
icon={chevronRight}
onClick={next}
role={'button'}
>
<span class="yt-icon-shape style-scope yt-icon yt-spec-icon-shape">
<div
style={{
'width': '100%',
'height': '100%',
'display': 'flex',
'align-items': 'center',
'fill': 'currentcolor',
}}
>
<svg
class="style-scope yt-icon"
fill="#FFFFFF"
height={'40px'}
preserveAspectRatio="xMidYMid meet"
viewBox="0 -960 960 960"
width={'40px'}
>
<g class="style-scope yt-icon">
<path
class="style-scope yt-icon"
d="M521.33-480.67 328-674l47.33-47.33L616-480.67 375.33-240 328-287.33l193.33-193.34Z"
/>
</g>
</svg>
</div>
</span>
</yt-icon-button>
</div>
</div>
);
};