feat(synced-lyrics): preferred provider (global/per-song) (#3741)

Co-authored-by: JellyBrick <shlee1503@naver.com>
This commit is contained in:
Angelos Bouklis
2025-09-07 07:35:29 +03:00
committed by GitHub
parent 336b7fe5e9
commit be3ae4d789
11 changed files with 358 additions and 215 deletions

View File

@ -813,6 +813,14 @@
"not-found": "⚠️ No lyrics found for this song." "not-found": "⚠️ No lyrics found for this song."
}, },
"menu": { "menu": {
"preferred-provider": {
"label": "Preferred Provider",
"tooltip": "Choose the default provider to use",
"none": {
"label": "None",
"tooltip": "No preferred provider"
}
},
"default-text-string": { "default-text-string": {
"label": "Default character between lyrics", "label": "Default character between lyrics",
"tooltip": "Choose the default character to use for the gap between lyrics" "tooltip": "Choose the default character to use for the gap between lyrics"

View File

@ -1,5 +1,7 @@
import { t } from '@/i18n'; import { t } from '@/i18n';
import { providerNames } from './providers';
import type { MenuItemConstructorOptions } from 'electron'; import type { MenuItemConstructorOptions } from 'electron';
import type { MenuContext } from '@/types/contexts'; import type { MenuContext } from '@/types/contexts';
import type { SyncedLyricsPluginConfig } from './types'; import type { SyncedLyricsPluginConfig } from './types';
@ -10,6 +12,35 @@ export const menu = async (
const config = await ctx.getConfig(); const config = await ctx.getConfig();
return [ return [
{
label: t('plugins.synced-lyrics.menu.preferred-provider.label'),
toolTip: t('plugins.synced-lyrics.menu.preferred-provider.tooltip'),
type: 'submenu',
submenu: [
{
label: t('plugins.synced-lyrics.menu.preferred-provider.none.label'),
toolTip: t(
'plugins.synced-lyrics.menu.preferred-provider.none.tooltip',
),
type: 'radio',
checked: config.preferredProvider === undefined,
click() {
ctx.setConfig({ preferredProvider: undefined });
},
},
...providerNames.map(
(provider) =>
({
label: provider,
type: 'radio',
checked: config.preferredProvider === provider,
click() {
ctx.setConfig({ preferredProvider: provider });
},
}) as const,
),
],
},
{ {
label: t('plugins.synced-lyrics.menu.precise-timing.label'), label: t('plugins.synced-lyrics.menu.precise-timing.label'),
toolTip: t('plugins.synced-lyrics.menu.precise-timing.tooltip'), toolTip: t('plugins.synced-lyrics.menu.precise-timing.tooltip'),

View File

@ -1,191 +1,21 @@
import { createStore } from 'solid-js/store'; import * as z from 'zod';
import { createMemo } from 'solid-js'; import type { LyricResult } from '../types';
import { LRCLib } from './LRCLib'; export enum ProviderNames {
import { LyricsGenius } from './LyricsGenius'; YTMusic = 'YTMusic',
import { MusixMatch } from './MusixMatch'; LRCLib = 'LRCLib',
import { YTMusic } from './YTMusic'; MusixMatch = 'MusixMatch',
LyricsGenius = 'LyricsGenius',
// Megalobiz = 'Megalobiz',
}
import { getSongInfo } from '@/providers/song-info-front'; export const ProviderNameSchema = z.enum(ProviderNames);
export type ProviderName = z.infer<typeof ProviderNameSchema>;
import type { LyricProvider, LyricResult } from '../types'; export const providerNames = ProviderNameSchema.options;
import type { SongInfo } from '@/providers/song-info';
export const providers = {
YTMusic: new YTMusic(),
LRCLib: new LRCLib(),
MusixMatch: new MusixMatch(),
LyricsGenius: new LyricsGenius(),
// Megalobiz: new Megalobiz(), // Disabled because it is too unstable and slow
} as const;
export type ProviderName = keyof typeof providers;
export const providerNames = Object.keys(providers) as ProviderName[];
export type ProviderState = { export type ProviderState = {
state: 'fetching' | 'done' | 'error'; state: 'fetching' | 'done' | 'error';
data: LyricResult | null; data: LyricResult | null;
error: Error | null; error: Error | null;
}; };
type LyricsStore = {
provider: ProviderName;
current: ProviderState;
lyrics: Record<ProviderName, ProviderState>;
};
const initialData = () =>
providerNames.reduce(
(acc, name) => {
acc[name] = { state: 'fetching', data: null, error: null };
return acc;
},
{} as LyricsStore['lyrics'],
);
export const [lyricsStore, setLyricsStore] = createStore<LyricsStore>({
provider: providerNames[0],
lyrics: initialData(),
get current(): ProviderState {
return this.lyrics[this.provider];
},
});
export const currentLyrics = createMemo(() => {
const provider = lyricsStore.provider;
return lyricsStore.lyrics[provider];
});
type VideoId = string;
type SearchCacheData = Record<ProviderName, ProviderState>;
interface SearchCache {
state: 'loading' | 'done';
data: SearchCacheData;
}
// TODO: Maybe use localStorage for the cache.
const searchCache = new Map<VideoId, SearchCache>();
export const fetchLyrics = (info: SongInfo) => {
if (searchCache.has(info.videoId)) {
const cache = searchCache.get(info.videoId)!;
if (cache.state === 'loading') {
setTimeout(() => {
fetchLyrics(info);
});
return;
}
if (getSongInfo().videoId === info.videoId) {
setLyricsStore('lyrics', () => {
// weird bug with solid-js
return JSON.parse(JSON.stringify(cache.data)) as typeof cache.data;
});
}
return;
}
const cache: SearchCache = {
state: 'loading',
data: initialData(),
};
searchCache.set(info.videoId, cache);
if (getSongInfo().videoId === info.videoId) {
setLyricsStore('lyrics', () => {
// weird bug with solid-js
return JSON.parse(JSON.stringify(cache.data)) as typeof cache.data;
});
}
const tasks: Promise<void>[] = [];
// prettier-ignore
for (
const [providerName, provider] of Object.entries(providers) as [
ProviderName,
LyricProvider,
][]
) {
const pCache = cache.data[providerName];
tasks.push(
provider
.search(info)
.then((res) => {
pCache.state = 'done';
pCache.data = res;
if (getSongInfo().videoId === info.videoId) {
setLyricsStore('lyrics', (old) => {
return {
...old,
[providerName]: {
state: 'done',
data: res ? { ...res } : null,
error: null,
},
};
});
}
})
.catch((error: Error) => {
pCache.state = 'error';
pCache.error = error;
console.error(error);
if (getSongInfo().videoId === info.videoId) {
setLyricsStore('lyrics', (old) => {
return {
...old,
[providerName]: { state: 'error', error, data: null },
};
});
}
}),
);
}
Promise.allSettled(tasks).then(() => {
cache.state = 'done';
searchCache.set(info.videoId, cache);
});
};
export const retrySearch = (provider: ProviderName, info: SongInfo) => {
setLyricsStore('lyrics', (old) => {
const pCache = {
state: 'fetching',
data: null,
error: null,
};
return {
...old,
[provider]: pCache,
};
});
providers[provider]
.search(info)
.then((res) => {
setLyricsStore('lyrics', (old) => {
return {
...old,
[provider]: { state: 'done', data: res, error: null },
};
});
})
.catch((error) => {
setLyricsStore('lyrics', (old) => {
return {
...old,
[provider]: { state: 'error', data: null, error },
};
});
});
};

View File

@ -0,0 +1,13 @@
import { ProviderNames } from './index';
import { YTMusic } from './YTMusic';
import { LRCLib } from './LRCLib';
import { MusixMatch } from './MusixMatch';
import { LyricsGenius } from './LyricsGenius';
export const providers = {
[ProviderNames.YTMusic]: new YTMusic(),
[ProviderNames.LRCLib]: new LRCLib(),
[ProviderNames.MusixMatch]: new MusixMatch(),
[ProviderNames.LyricsGenius]: new LyricsGenius(),
// [ProviderNames.Megalobiz]: new Megalobiz(), // Disabled because it is too unstable and slow
} as const;

View File

@ -2,7 +2,7 @@ import { t } from '@/i18n';
import { getSongInfo } from '@/providers/song-info-front'; import { getSongInfo } from '@/providers/song-info-front';
import { lyricsStore, retrySearch } from '../../providers'; import { lyricsStore, retrySearch } from '../store';
interface ErrorDisplayProps { interface ErrorDisplayProps {
error: Error; error: Error;

View File

@ -11,18 +11,24 @@ import {
Switch, Switch,
} from 'solid-js'; } from 'solid-js';
import * as z from 'zod';
import { import {
currentLyrics,
lyricsStore,
type ProviderName, type ProviderName,
providerNames, providerNames,
ProviderNameSchema,
type ProviderState, type ProviderState,
setLyricsStore,
} from '../../providers'; } from '../../providers';
import { currentLyrics, lyricsStore, setLyricsStore } from '../store';
import { _ytAPI } from '../index'; import { _ytAPI } from '../index';
import { config } from '../renderer';
import type { YtIcons } from '@/types/icons'; import type { YtIcons } from '@/types/icons';
import type { PlayerAPIEvents } from '@/types/player-api-events';
const LocalStorageSchema = z.object({
provider: ProviderNameSchema,
});
export const providerIdx = createMemo(() => export const providerIdx = createMemo(() =>
providerNames.indexOf(lyricsStore.provider), providerNames.indexOf(lyricsStore.provider),
@ -45,11 +51,19 @@ const providerBias = (p: ProviderName) =>
(lyricsStore.lyrics[p].data?.lyrics ? 1 : -1); (lyricsStore.lyrics[p].data?.lyrics ? 1 : -1);
const pickBestProvider = () => { const pickBestProvider = () => {
const preferred = config()?.preferredProvider;
if (preferred) {
const data = lyricsStore.lyrics[preferred].data;
if (Array.isArray(data?.lines) || data?.lyrics) {
return { provider: preferred, force: true };
}
}
const providers = Array.from(providerNames); const providers = Array.from(providerNames);
providers.sort((a, b) => providerBias(b) - providerBias(a)); providers.sort((a, b) => providerBias(b) - providerBias(a));
return providers[0]; return { provider: providers[0], force: false };
}; };
const [hasManuallySwitchedProvider, setHasManuallySwitchedProvider] = const [hasManuallySwitchedProvider, setHasManuallySwitchedProvider] =
@ -58,34 +72,91 @@ const [hasManuallySwitchedProvider, setHasManuallySwitchedProvider] =
export const LyricsPicker = (props: { export const LyricsPicker = (props: {
setStickRef: Setter<HTMLElement | null>; setStickRef: Setter<HTMLElement | null>;
}) => { }) => {
const [videoId, setVideoId] = createSignal<string | null>(null);
const [starredProvider, setStarredProvider] =
createSignal<ProviderName | null>(null);
createEffect(() => {
const id = videoId();
if (id === null) {
setStarredProvider(null);
return;
}
const key = `ytmd-sl-starred-${id}`;
const value = localStorage.getItem(key);
if (!value) {
setStarredProvider(null);
return;
}
const parseResult = LocalStorageSchema.safeParse(JSON.parse(value));
if (parseResult.success) {
setLyricsStore('provider', parseResult.data.provider);
setStarredProvider(parseResult.data.provider);
} else {
setStarredProvider(null);
}
});
const toggleStar = () => {
const id = videoId();
if (id === null) return;
const key = `ytmd-sl-starred-${id}`;
setStarredProvider((starredProvider) => {
if (lyricsStore.provider === starredProvider) {
localStorage.removeItem(key);
return null;
}
const provider = lyricsStore.provider;
localStorage.setItem(key, JSON.stringify({ provider }));
return provider;
});
};
const videoDataChangeHandler = (
name: string,
{ videoId }: PlayerAPIEvents['videodatachange']['value'],
) => {
setVideoId(videoId);
if (name !== 'dataloaded') return;
setHasManuallySwitchedProvider(false);
};
// prettier-ignore
{
onMount(() => _ytAPI?.addEventListener('videodatachange', videoDataChangeHandler));
onCleanup(() => _ytAPI?.removeEventListener('videodatachange', videoDataChangeHandler));
}
createEffect(() => { createEffect(() => {
// fallback to the next source, if the current one has an error
if (!hasManuallySwitchedProvider()) { if (!hasManuallySwitchedProvider()) {
const bestProvider = pickBestProvider(); const starred = starredProvider();
if (starred !== null) {
setLyricsStore('provider', starred);
return;
}
const allProvidersFailed = providerNames.every((p) => const allProvidersFailed = providerNames.every((p) =>
shouldSwitchProvider(lyricsStore.lyrics[p]), shouldSwitchProvider(lyricsStore.lyrics[p]),
); );
if (allProvidersFailed) return; if (allProvidersFailed) return;
if (providerBias(lyricsStore.provider) < providerBias(bestProvider)) { const { provider, force } = pickBestProvider();
setLyricsStore('provider', bestProvider); if (
force ||
providerBias(lyricsStore.provider) < providerBias(provider)
) {
setLyricsStore('provider', provider);
} }
} }
}); });
onMount(() => {
const videoDataChangeHandler = (name: string) => {
if (name !== 'dataloaded') return;
setHasManuallySwitchedProvider(false);
};
_ytAPI?.addEventListener('videodatachange', videoDataChangeHandler);
onCleanup(() =>
_ytAPI?.removeEventListener('videodatachange', videoDataChangeHandler),
);
});
const next = () => { const next = () => {
setHasManuallySwitchedProvider(true); setHasManuallySwitchedProvider(true);
setLyricsStore('provider', (prevProvider) => { setLyricsStore('provider', (prevProvider) => {
@ -176,9 +247,9 @@ export const LyricsPicker = (props: {
/> />
</Match> </Match>
<Match when={currentLyrics().state === 'error'}> <Match when={currentLyrics().state === 'error'}>
<yt-icon-button <yt-icon
icon={errorIcon} icon={errorIcon}
style={{ padding: '5px', transform: 'scale(0.5)' }} style={{ padding: '5px', transform: 'scale(0.8)' }}
tabindex="-1" tabindex="-1"
/> />
</Match> </Match>
@ -189,9 +260,9 @@ export const LyricsPicker = (props: {
currentLyrics().data?.lyrics) currentLyrics().data?.lyrics)
} }
> >
<yt-icon-button <yt-icon
icon={successIcon} icon={successIcon}
style={{ padding: '5px', transform: 'scale(0.5)' }} style={{ padding: '5px', transform: 'scale(0.8)' }}
tabindex="-1" tabindex="-1"
/> />
</Match> </Match>
@ -202,9 +273,9 @@ export const LyricsPicker = (props: {
!currentLyrics().data?.lyrics !currentLyrics().data?.lyrics
} }
> >
<yt-icon-button <yt-icon
icon={notFoundIcon} icon={notFoundIcon}
style={{ padding: '5px', transform: 'scale(0.5)' }} style={{ padding: '5px', transform: 'scale(0.8)' }}
tabindex="-1" tabindex="-1"
/> />
</Match> </Match>
@ -213,6 +284,20 @@ export const LyricsPicker = (props: {
class="description ytmusic-description-shelf-renderer" class="description ytmusic-description-shelf-renderer"
text={{ runs: [{ text: provider() }] }} text={{ runs: [{ text: provider() }] }}
/> />
<yt-icon
icon={
starredProvider() === provider()
? 'yt-sys-icons:star-filled'
: 'yt-sys-icons:star'
}
onClick={toggleStar}
style={{
padding: '5px',
transform: 'scale(0.8)',
cursor: 'pointer',
}}
tabindex="-1"
/>
</div> </div>
)} )}
</Index> </Index>

View File

@ -3,8 +3,7 @@ import { waitForElement } from '@/utils/wait-for-element';
import { selectors, tabStates } from './utils'; import { selectors, tabStates } from './utils';
import { setConfig, setCurrentTime } from './renderer'; import { setConfig, setCurrentTime } from './renderer';
import { fetchLyrics } from './store';
import { fetchLyrics } from '../providers';
import type { RendererContext } from '@/types/contexts'; import type { RendererContext } from '@/types/contexts';
import type { YoutubePlayer } from '@/types/youtube-player'; import type { YoutubePlayer } from '@/types/youtube-player';

View File

@ -20,7 +20,7 @@ import {
PlainLyrics, PlainLyrics,
} from './components'; } from './components';
import { currentLyrics } from '../providers'; import { currentLyrics } from './store';
import type { LineLyrics, SyncedLyricsPluginConfig } from '../types'; import type { LineLyrics, SyncedLyricsPluginConfig } from '../types';

View File

@ -0,0 +1,175 @@
import { createStore } from 'solid-js/store';
import { createMemo } from 'solid-js';
import { getSongInfo } from '@/providers/song-info-front';
import {
type ProviderName,
providerNames,
type ProviderState,
} from '../providers';
import { providers } from '../providers/renderer';
import type { LyricProvider } from '../types';
import type { SongInfo } from '@/providers/song-info';
type LyricsStore = {
provider: ProviderName;
current: ProviderState;
lyrics: Record<ProviderName, ProviderState>;
};
const initialData = () =>
providerNames.reduce(
(acc, name) => {
acc[name] = { state: 'fetching', data: null, error: null };
return acc;
},
{} as LyricsStore['lyrics'],
);
export const [lyricsStore, setLyricsStore] = createStore<LyricsStore>({
provider: providerNames[0],
lyrics: initialData(),
get current(): ProviderState {
return this.lyrics[this.provider];
},
});
export const currentLyrics = createMemo(() => {
const provider = lyricsStore.provider;
return lyricsStore.lyrics[provider];
});
type VideoId = string;
type SearchCacheData = Record<ProviderName, ProviderState>;
interface SearchCache {
state: 'loading' | 'done';
data: SearchCacheData;
}
// TODO: Maybe use localStorage for the cache.
const searchCache = new Map<VideoId, SearchCache>();
export const fetchLyrics = (info: SongInfo) => {
if (searchCache.has(info.videoId)) {
const cache = searchCache.get(info.videoId)!;
if (cache.state === 'loading') {
setTimeout(() => {
fetchLyrics(info);
});
return;
}
if (getSongInfo().videoId === info.videoId) {
setLyricsStore('lyrics', () => {
// weird bug with solid-js
return JSON.parse(JSON.stringify(cache.data)) as typeof cache.data;
});
}
return;
}
const cache: SearchCache = {
state: 'loading',
data: initialData(),
};
searchCache.set(info.videoId, cache);
if (getSongInfo().videoId === info.videoId) {
setLyricsStore('lyrics', () => {
// weird bug with solid-js
return JSON.parse(JSON.stringify(cache.data)) as typeof cache.data;
});
}
const tasks: Promise<void>[] = [];
// prettier-ignore
for (
const [providerName, provider] of Object.entries(providers) as [
ProviderName,
LyricProvider,
][]
) {
const pCache = cache.data[providerName];
tasks.push(
provider
.search(info)
.then((res) => {
pCache.state = 'done';
pCache.data = res;
if (getSongInfo().videoId === info.videoId) {
setLyricsStore('lyrics', (old) => {
return {
...old,
[providerName]: {
state: 'done',
data: res ? { ...res } : null,
error: null,
},
};
});
}
})
.catch((error: Error) => {
pCache.state = 'error';
pCache.error = error;
console.error(error);
if (getSongInfo().videoId === info.videoId) {
setLyricsStore('lyrics', (old) => {
return {
...old,
[providerName]: { state: 'error', error, data: null },
};
});
}
}),
);
}
Promise.allSettled(tasks).then(() => {
cache.state = 'done';
searchCache.set(info.videoId, cache);
});
};
export const retrySearch = (provider: ProviderName, info: SongInfo) => {
setLyricsStore('lyrics', (old) => {
const pCache = {
state: 'fetching',
data: null,
error: null,
};
return {
...old,
[provider]: pCache,
};
});
providers[provider]
.search(info)
.then((res) => {
setLyricsStore('lyrics', (old) => {
return {
...old,
[provider]: { state: 'done', data: res, error: null },
};
});
})
.catch((error) => {
setLyricsStore('lyrics', (old) => {
return {
...old,
[provider]: { state: 'error', data: null, error },
};
});
});
};

View File

@ -1,7 +1,9 @@
import type { SongInfo } from '@/providers/song-info'; import type { SongInfo } from '@/providers/song-info';
import type { ProviderName } from './providers';
export type SyncedLyricsPluginConfig = { export type SyncedLyricsPluginConfig = {
enabled: boolean; enabled: boolean;
preferredProvider?: ProviderName;
preciseTiming: boolean; preciseTiming: boolean;
showTimeCodes: boolean; showTimeCodes: boolean;
defaultTextString: string | string[]; defaultTextString: string | string[];

View File

@ -48,8 +48,8 @@ declare module 'solid-js' {
'tp-yt-paper-icon-button': ComponentProps<'div'> & 'tp-yt-paper-icon-button': ComponentProps<'div'> &
TpYtPaperIconButtonProps; TpYtPaperIconButtonProps;
'yt-icon-button': ComponentProps<'div'> & TpYtPaperIconButtonProps; 'yt-icon-button': ComponentProps<'div'> & TpYtPaperIconButtonProps;
'tp-yt-iron-icon': ComponentProps<'div'>; 'tp-yt-iron-icon': ComponentProps<'div'> & TpYtPaperIconButtonProps;
'yt-icon': ComponentProps<'div'>; 'yt-icon': ComponentProps<'div'> & TpYtPaperIconButtonProps;
// input type="range" slider component // input type="range" slider component
'tp-yt-paper-slider': ComponentProps<'input'> & { 'tp-yt-paper-slider': ComponentProps<'input'> & {
'value'?: number | string; 'value'?: number | string;