From be3ae4d7896604b4642bce5f7d118f8ecdb1b8ef Mon Sep 17 00:00:00 2001 From: Angelos Bouklis Date: Sun, 7 Sep 2025 07:35:29 +0300 Subject: [PATCH] feat(synced-lyrics): preferred provider (global/per-song) (#3741) Co-authored-by: JellyBrick --- src/i18n/resources/en.json | 8 + src/plugins/synced-lyrics/menu.ts | 31 +++ src/plugins/synced-lyrics/providers/index.ts | 194 ++---------------- .../synced-lyrics/providers/renderer.ts | 13 ++ .../renderer/components/ErrorDisplay.tsx | 2 +- .../renderer/components/LyricsPicker.tsx | 139 ++++++++++--- src/plugins/synced-lyrics/renderer/index.ts | 3 +- .../synced-lyrics/renderer/renderer.tsx | 2 +- src/plugins/synced-lyrics/renderer/store.ts | 175 ++++++++++++++++ src/plugins/synced-lyrics/types.ts | 2 + src/yt-web-components.d.ts | 4 +- 11 files changed, 358 insertions(+), 215 deletions(-) create mode 100644 src/plugins/synced-lyrics/providers/renderer.ts create mode 100644 src/plugins/synced-lyrics/renderer/store.ts diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index 72eeea47..85638a0f 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -813,6 +813,14 @@ "not-found": "⚠️ No lyrics found for this song." }, "menu": { + "preferred-provider": { + "label": "Preferred Provider", + "tooltip": "Choose the default provider to use", + "none": { + "label": "None", + "tooltip": "No preferred provider" + } + }, "default-text-string": { "label": "Default character between lyrics", "tooltip": "Choose the default character to use for the gap between lyrics" diff --git a/src/plugins/synced-lyrics/menu.ts b/src/plugins/synced-lyrics/menu.ts index c6f51c12..d1b9e12f 100644 --- a/src/plugins/synced-lyrics/menu.ts +++ b/src/plugins/synced-lyrics/menu.ts @@ -1,5 +1,7 @@ import { t } from '@/i18n'; +import { providerNames } from './providers'; + import type { MenuItemConstructorOptions } from 'electron'; import type { MenuContext } from '@/types/contexts'; import type { SyncedLyricsPluginConfig } from './types'; @@ -10,6 +12,35 @@ export const menu = async ( const config = await ctx.getConfig(); 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'), toolTip: t('plugins.synced-lyrics.menu.precise-timing.tooltip'), diff --git a/src/plugins/synced-lyrics/providers/index.ts b/src/plugins/synced-lyrics/providers/index.ts index 1de4768c..80413b0f 100644 --- a/src/plugins/synced-lyrics/providers/index.ts +++ b/src/plugins/synced-lyrics/providers/index.ts @@ -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'; -import { LyricsGenius } from './LyricsGenius'; -import { MusixMatch } from './MusixMatch'; -import { YTMusic } from './YTMusic'; +export enum ProviderNames { + YTMusic = 'YTMusic', + LRCLib = 'LRCLib', + MusixMatch = 'MusixMatch', + LyricsGenius = 'LyricsGenius', + // Megalobiz = 'Megalobiz', +} -import { getSongInfo } from '@/providers/song-info-front'; - -import type { LyricProvider, LyricResult } from '../types'; -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 const ProviderNameSchema = z.enum(ProviderNames); +export type ProviderName = z.infer; +export const providerNames = ProviderNameSchema.options; export type ProviderState = { state: 'fetching' | 'done' | 'error'; data: LyricResult | null; error: Error | null; }; - -type LyricsStore = { - provider: ProviderName; - current: ProviderState; - lyrics: Record; -}; - -const initialData = () => - providerNames.reduce( - (acc, name) => { - acc[name] = { state: 'fetching', data: null, error: null }; - return acc; - }, - {} as LyricsStore['lyrics'], - ); - -export const [lyricsStore, setLyricsStore] = createStore({ - 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; -interface SearchCache { - state: 'loading' | 'done'; - data: SearchCacheData; -} - -// TODO: Maybe use localStorage for the cache. -const searchCache = new Map(); -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[] = []; - - // 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 }, - }; - }); - }); -}; diff --git a/src/plugins/synced-lyrics/providers/renderer.ts b/src/plugins/synced-lyrics/providers/renderer.ts new file mode 100644 index 00000000..77f16c96 --- /dev/null +++ b/src/plugins/synced-lyrics/providers/renderer.ts @@ -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; diff --git a/src/plugins/synced-lyrics/renderer/components/ErrorDisplay.tsx b/src/plugins/synced-lyrics/renderer/components/ErrorDisplay.tsx index 7d017c84..55c0c8bd 100644 --- a/src/plugins/synced-lyrics/renderer/components/ErrorDisplay.tsx +++ b/src/plugins/synced-lyrics/renderer/components/ErrorDisplay.tsx @@ -2,7 +2,7 @@ import { t } from '@/i18n'; import { getSongInfo } from '@/providers/song-info-front'; -import { lyricsStore, retrySearch } from '../../providers'; +import { lyricsStore, retrySearch } from '../store'; interface ErrorDisplayProps { error: Error; diff --git a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx index 81cb9e6f..6f6ac92a 100644 --- a/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx +++ b/src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx @@ -11,18 +11,24 @@ import { Switch, } from 'solid-js'; +import * as z from 'zod'; + import { - currentLyrics, - lyricsStore, type ProviderName, providerNames, + ProviderNameSchema, type ProviderState, - setLyricsStore, } from '../../providers'; - +import { currentLyrics, lyricsStore, setLyricsStore } from '../store'; import { _ytAPI } from '../index'; +import { config } from '../renderer'; import type { YtIcons } from '@/types/icons'; +import type { PlayerAPIEvents } from '@/types/player-api-events'; + +const LocalStorageSchema = z.object({ + provider: ProviderNameSchema, +}); export const providerIdx = createMemo(() => providerNames.indexOf(lyricsStore.provider), @@ -45,11 +51,19 @@ const providerBias = (p: ProviderName) => (lyricsStore.lyrics[p].data?.lyrics ? 1 : -1); 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); providers.sort((a, b) => providerBias(b) - providerBias(a)); - return providers[0]; + return { provider: providers[0], force: false }; }; const [hasManuallySwitchedProvider, setHasManuallySwitchedProvider] = @@ -58,34 +72,91 @@ const [hasManuallySwitchedProvider, setHasManuallySwitchedProvider] = export const LyricsPicker = (props: { setStickRef: Setter; }) => { + const [videoId, setVideoId] = createSignal(null); + const [starredProvider, setStarredProvider] = + createSignal(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(() => { - // fallback to the next source, if the current one has an error if (!hasManuallySwitchedProvider()) { - const bestProvider = pickBestProvider(); + const starred = starredProvider(); + if (starred !== null) { + setLyricsStore('provider', starred); + return; + } const allProvidersFailed = providerNames.every((p) => shouldSwitchProvider(lyricsStore.lyrics[p]), ); if (allProvidersFailed) return; - if (providerBias(lyricsStore.provider) < providerBias(bestProvider)) { - setLyricsStore('provider', bestProvider); + const { provider, force } = pickBestProvider(); + 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 = () => { setHasManuallySwitchedProvider(true); setLyricsStore('provider', (prevProvider) => { @@ -176,9 +247,9 @@ export const LyricsPicker = (props: { /> - @@ -189,9 +260,9 @@ export const LyricsPicker = (props: { currentLyrics().data?.lyrics) } > - @@ -202,9 +273,9 @@ export const LyricsPicker = (props: { !currentLyrics().data?.lyrics } > - @@ -213,6 +284,20 @@ export const LyricsPicker = (props: { class="description ytmusic-description-shelf-renderer" text={{ runs: [{ text: provider() }] }} /> + )} diff --git a/src/plugins/synced-lyrics/renderer/index.ts b/src/plugins/synced-lyrics/renderer/index.ts index 5f01f7d3..732e7815 100644 --- a/src/plugins/synced-lyrics/renderer/index.ts +++ b/src/plugins/synced-lyrics/renderer/index.ts @@ -3,8 +3,7 @@ import { waitForElement } from '@/utils/wait-for-element'; import { selectors, tabStates } from './utils'; import { setConfig, setCurrentTime } from './renderer'; - -import { fetchLyrics } from '../providers'; +import { fetchLyrics } from './store'; import type { RendererContext } from '@/types/contexts'; import type { YoutubePlayer } from '@/types/youtube-player'; diff --git a/src/plugins/synced-lyrics/renderer/renderer.tsx b/src/plugins/synced-lyrics/renderer/renderer.tsx index b6031b42..24c3d701 100644 --- a/src/plugins/synced-lyrics/renderer/renderer.tsx +++ b/src/plugins/synced-lyrics/renderer/renderer.tsx @@ -20,7 +20,7 @@ import { PlainLyrics, } from './components'; -import { currentLyrics } from '../providers'; +import { currentLyrics } from './store'; import type { LineLyrics, SyncedLyricsPluginConfig } from '../types'; diff --git a/src/plugins/synced-lyrics/renderer/store.ts b/src/plugins/synced-lyrics/renderer/store.ts new file mode 100644 index 00000000..8d9df88b --- /dev/null +++ b/src/plugins/synced-lyrics/renderer/store.ts @@ -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; +}; + +const initialData = () => + providerNames.reduce( + (acc, name) => { + acc[name] = { state: 'fetching', data: null, error: null }; + return acc; + }, + {} as LyricsStore['lyrics'], + ); + +export const [lyricsStore, setLyricsStore] = createStore({ + 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; +interface SearchCache { + state: 'loading' | 'done'; + data: SearchCacheData; +} + +// TODO: Maybe use localStorage for the cache. +const searchCache = new Map(); +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[] = []; + + // 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 }, + }; + }); + }); +}; diff --git a/src/plugins/synced-lyrics/types.ts b/src/plugins/synced-lyrics/types.ts index 7afe2c47..dab1edf9 100644 --- a/src/plugins/synced-lyrics/types.ts +++ b/src/plugins/synced-lyrics/types.ts @@ -1,7 +1,9 @@ import type { SongInfo } from '@/providers/song-info'; +import type { ProviderName } from './providers'; export type SyncedLyricsPluginConfig = { enabled: boolean; + preferredProvider?: ProviderName; preciseTiming: boolean; showTimeCodes: boolean; defaultTextString: string | string[]; diff --git a/src/yt-web-components.d.ts b/src/yt-web-components.d.ts index 64489065..947d8331 100644 --- a/src/yt-web-components.d.ts +++ b/src/yt-web-components.d.ts @@ -48,8 +48,8 @@ declare module 'solid-js' { 'tp-yt-paper-icon-button': ComponentProps<'div'> & TpYtPaperIconButtonProps; 'yt-icon-button': ComponentProps<'div'> & TpYtPaperIconButtonProps; - 'tp-yt-iron-icon': ComponentProps<'div'>; - 'yt-icon': ComponentProps<'div'>; + 'tp-yt-iron-icon': ComponentProps<'div'> & TpYtPaperIconButtonProps; + 'yt-icon': ComponentProps<'div'> & TpYtPaperIconButtonProps; // input type="range" slider component 'tp-yt-paper-slider': ComponentProps<'input'> & { 'value'?: number | string;