plugin: Synced Lyrics (#2207)

* Added Plugin File

* Added Logic

* Known issue

* Finished Backend part

* Before cleanup

* Added Style
Removed log

* Fixed time and visibility issues

* Changed lyrics style

* Changed way lyrics are selected

* Fix

* Added style lyrics options

* Cleanup

* Fix lyrics styling
Changed how lyrics status are changed

* Moved code to make file more readable

* Change Tab Size

* Fixed issue with overlapping lyrics

* Removed debug console.log

* Added style adaptation for music videos

* Changed file indent

* Revered back to original pnpm file

* Removed unnecessary option

* Fix lyrics status bug
Removed leftover logs

* Started to implement fetching for genius lyrics

* feat(synced-lyrics): add `addedVersion` field

* Made changes according to feedbacks

* fix: add a delay of 300ms to the current time

- Since the transition takes 300ms, we need to add a delay of 300ms to the current time

* Removed test about genius.com scraping

* Removed 300ms delay

* chore: cleaned up the code

* Specified path and variable

* chore: always enable lyrics tab

* chore: use SolidJS to render the lyrics

* chore: remove useless signal

* chore: feature-parity with original PR (+some nice stuff)

* recreate lock file

* show json decode error

* feat(synced-lyrics): improve ui
- Change type assertion code
- Replace span to `yt-formatted-string`
- Add refetch button

* chore: make the lyric styling a solidjs effect

* feat: i18n

* chore: apply suggestion

---------

Co-authored-by: Su-Yong <simssy2205@gmail.com>
Co-authored-by: JellyBrick <shlee1503@naver.com>
Co-authored-by: Angelos Bouklis <53124886+ArjixWasTaken@users.noreply.github.com>
This commit is contained in:
No NOréo
2024-07-31 12:54:21 +02:00
committed by GitHub
parent 116dbad9bc
commit 8ce91b143a
19 changed files with 977 additions and 26 deletions

View File

@ -0,0 +1,145 @@
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 { LineLyrics } from '../../types';
import {
differentDuration,
hadSecondAttempt,
isFetching,
isInstrumental,
makeLyricsRequest,
} from '../lyrics/fetch';
export const [debugInfo, setDebugInfo] = createSignal<string>();
export const [lineLyrics, setLineLyrics] = createSignal<LineLyrics[]>([]);
export const [currentTime, setCurrentTime] = createSignal<number>(-1);
export const LyricsContainer = () => {
const [error, setError] = createSignal('');
const onRefetch = async () => {
if (isFetching()) return;
setError('');
const info = getSongInfo();
await makeLyricsRequest(info).catch((err) => {
setError(`${err}`);
});
};
return (
<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>
<Match when={error()}>
<yt-formatted-string
class="warning-lyrics description ytmusic-description-shelf-renderer"
text={{
runs: [
{
text: t('plugins.synced-lyrics.errors.fetch'),
},
],
}}
/>
</Match>
</Switch>
<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>
<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>
</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,53 @@
import { createEffect, createMemo } from 'solid-js';
import { currentTime } from './LyricsContainer';
import { config } from '../renderer';
import { _ytAPI } from '..';
import type { LineLyrics } from '../../types';
interface SyncedLineProps {
line: LineLyrics;
}
export const SyncedLine = ({ line }: SyncedLineProps) => {
const status = createMemo(() => {
const current = currentTime();
if (line.timeInMs >= current) return 'upcoming';
if (current - line.timeInMs >= line.duration) return 'previous';
return 'current';
});
let ref: HTMLDivElement;
createEffect(() => {
if (status() === 'current') {
ref.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
return (
<div
ref={ref!}
class={`synced-line ${status()}`}
onClick={() => {
_ytAPI?.seekTo(line.timeInMs / 1000);
}}
>
<yt-formatted-string
class="text-lyrics description ytmusic-description-shelf-renderer"
text={{
runs: [
{
text: '',
},
{
text: `${config()?.showTimeCodes ? `[${line.time}] ` : ''}${line.text}`,
},
],
}}
/>
</div>
);
};

View File

@ -0,0 +1,90 @@
/* eslint-disable prefer-const, @typescript-eslint/no-unused-vars */
import { createRenderer } from '@/utils';
import { SongInfo } from '@/providers/song-info';
import { YoutubePlayer } from '@/types/youtube-player';
import { makeLyricsRequest } from './lyrics';
import { selectors, tabStates } from './utils';
import { setConfig } from './renderer';
import { setCurrentTime } from './components/LyricsContainer';
import type { SyncedLyricsPluginConfig } from '../types';
export let _ytAPI: YoutubePlayer | null = null;
export const renderer = createRenderer({
onConfigChange(newConfig) {
setConfig(newConfig as SyncedLyricsPluginConfig);
},
observerCallback(mutations: MutationRecord[]) {
for (const mutation of mutations) {
const header = mutation.target as HTMLElement;
switch (mutation.attributeName) {
case 'disabled':
header.removeAttribute('disabled');
break;
case 'aria-selected':
tabStates[header.ariaSelected as 'true' | 'false']?.(
_ytAPI?.getVideoData(),
);
break;
}
}
},
onPlayerApiReady(api) {
_ytAPI = api;
// @ts-expect-error type is 'unknown', so TS complains
api.addEventListener('videodatachange', this.videoDataChange);
// @ts-expect-error type is 'unknown', so TS complains
this.videoDataChange();
},
hasAddedEvents: false,
observer: null as MutationObserver | null,
videoDataChange() {
if (!this.hasAddedEvents) {
const video = document.querySelector('video');
// @ts-expect-error type is 'unknown', so TS complains
video?.addEventListener('timeupdate', this.progressCallback);
if (video) this.hasAddedEvents = true;
}
const header = document.querySelector<HTMLElement>(selectors.head);
if (!header) return;
this.observer ??= new MutationObserver(
this.observerCallback as MutationCallback,
);
// Force the lyrics tab to be enabled at all times.
this.observer.disconnect();
this.observer.observe(header, { attributes: true });
header.removeAttribute('disabled');
},
progressCallback(evt: Event) {
switch (evt.type) {
case 'timeupdate': {
const video = evt.target as HTMLVideoElement;
setCurrentTime(video.currentTime * 1000);
break;
}
}
},
async start({ getConfig, ipc: { on } }) {
setConfig((await getConfig()) as SyncedLyricsPluginConfig);
on('ytmd:update-song-info', async (info: SongInfo) => {
await makeLyricsRequest(info);
});
},
});

View File

@ -0,0 +1,197 @@
import { createSignal } from 'solid-js';
import { jaroWinkler } from '@skyra/jaro-winkler';
import { SongInfo } from '@/providers/song-info';
import { LineLyrics, LRCLIBSearchResponse } from '../../types';
import { config } from '../renderer';
import { setDebugInfo, setLineLyrics } from '../components/LyricsContainer';
// prettier-ignore
export const [isInstrumental, setIsInstrumental] = createSignal(false);
// prettier-ignore
export const [isFetching, setIsFetching] = createSignal(false);
// prettier-ignore
export const [hadSecondAttempt, setHadSecondAttempt] = createSignal(false);
// prettier-ignore
export const [differentDuration, setDifferentDuration] = createSignal(false);
// eslint-disable-next-line prefer-const
export let foundPlainTextLyrics = false;
export type SongData = {
title: string;
artist: string;
album: string;
songDuration: number;
};
export const extractTimeAndText = (
line: string,
index: number,
): LineLyrics | null => {
const groups = /\[(\d+):(\d+)\.(\d+)\](.+)/.exec(line);
if (!groups) return null;
const [_, rMinutes, rSeconds, rMillis, text] = groups;
const [minutes, seconds, millis] = [
parseInt(rMinutes),
parseInt(rSeconds),
parseInt(rMillis),
];
// prettier-ignore
const timeInMs = (minutes * 60 * 1000) + (seconds * 1000) + millis;
return {
index,
timeInMs,
time: `${minutes}:${seconds}:${millis}`,
text: text?.trim() ?? config()!.defaultTextString,
status: 'upcoming',
duration: 0,
};
};
export const makeLyricsRequest = async (extractedSongInfo: SongInfo) => {
setLineLyrics([]);
const songData: SongData = {
title: `${extractedSongInfo.title}`,
artist: `${extractedSongInfo.artist}`,
album: `${extractedSongInfo.album}`,
songDuration: extractedSongInfo.songDuration,
};
const lyrics = await getLyricsList(songData);
setLineLyrics(lyrics ?? []);
};
export const getLyricsList = async (
songData: SongData,
): Promise<LineLyrics[] | null> => {
setIsFetching(true);
setIsInstrumental(false);
setHadSecondAttempt(false);
setDifferentDuration(false);
setDebugInfo('Searching for lyrics...');
let query = new URLSearchParams({
artist_name: songData.artist,
track_name: songData.title,
});
if (songData.album) {
query.set('album_name', songData.album);
}
let url = `https://lrclib.net/api/search?${query.toString()}`;
let response = await fetch(url);
if (!response.ok) {
setIsFetching(false);
setDebugInfo('Got non-OK response from server.');
return null;
}
let data = (await response.json().catch((e: Error) => {
setDebugInfo(`Error: ${e.message}\n\n${e.stack}`);
return null;
})) as LRCLIBSearchResponse | null;
if (!data || !Array.isArray(data)) {
setIsFetching(false);
setDebugInfo('Unexpected server response.');
return null;
}
// Note: If no lyrics are found, try again with a different search query
if (data.length === 0) {
if (!config()?.showLyricsEvenIfInexact) {
return null;
}
query = new URLSearchParams({ q: songData.title });
url = `https://lrclib.net/api/search?${query.toString()}`;
response = await fetch(url);
if (!response.ok) {
setIsFetching(false);
setDebugInfo('Got non-OK response from server. (2)');
return null;
}
data = (await response.json()) as LRCLIBSearchResponse;
if (!Array.isArray(data)) {
setIsFetching(false);
setDebugInfo('Unexpected server response. (2)');
return null;
}
setHadSecondAttempt(true);
}
const filteredResults = [];
for (const item of data) {
if (!item.syncedLyrics) continue;
const { artist } = songData;
const { artistName } = item;
const ratio = jaroWinkler(artist.toLowerCase(), artistName.toLowerCase());
if (ratio <= 0.9) continue;
filteredResults.push(item);
}
const duration = songData.songDuration;
filteredResults.sort(({ duration: durationA }, { duration: durationB }) => {
const left = Math.abs(durationA - duration);
const right = Math.abs(durationB - duration);
return left - right;
});
const closestResult = filteredResults[0];
if (!closestResult) {
setIsFetching(false);
setDebugInfo('No search result matched the criteria.');
return null;
}
// setDebugInfo(JSON.stringify(closestResult, null, 4));
if (Math.abs(closestResult.duration - duration) > 15) return null;
if (Math.abs(closestResult.duration - duration) > 5) {
// show message that the timings may be wrong
setDifferentDuration(true);
}
setIsInstrumental(closestResult.instrumental);
// Separate the lyrics into lines
const raw = closestResult.syncedLyrics.split('\n');
// Add a blank line at the beginning
raw.unshift('[0:0.0] ');
const syncedLyricList = [];
for (let idx = 0; idx < raw.length; idx++) {
const syncedLine = extractTimeAndText(raw[idx], idx);
if (syncedLine) {
syncedLyricList.push(syncedLine);
}
}
for (const line of syncedLyricList) {
const next = syncedLyricList[line.index + 1];
if (!next) {
line.duration = Infinity;
break;
}
line.duration = next.timeInMs - line.timeInMs;
}
setIsFetching(false);
return syncedLyricList;
};

View File

@ -0,0 +1,45 @@
/* eslint-disable import/order */
import { createEffect } from 'solid-js';
import { config } from '../renderer';
export { makeLyricsRequest } from './fetch';
createEffect(() => {
if (!config()?.enabled) return;
const root = document.documentElement;
// Set the line effect
switch (config()?.lineEffect) {
case 'scale':
root.style.setProperty(
'--previous-lyrics',
'var(--ytmusic-text-primary)',
);
root.style.setProperty('--current-lyrics', 'var(--ytmusic-text-primary)');
root.style.setProperty('--size-lyrics', '1.2');
root.style.setProperty('--offset-lyrics', '0');
root.style.setProperty('--lyric-width', '83%');
break;
case 'offset':
root.style.setProperty(
'--previous-lyrics',
'var(--ytmusic-text-primary)',
);
root.style.setProperty('--current-lyrics', 'var(--ytmusic-text-primary)');
root.style.setProperty('--size-lyrics', '1');
root.style.setProperty('--offset-lyrics', '5%');
root.style.setProperty('--lyric-width', '100%');
break;
case 'focus':
root.style.setProperty(
'--previous-lyrics',
'var(--ytmusic-text-secondary)',
);
root.style.setProperty('--current-lyrics', 'var(--ytmusic-text-primary)');
root.style.setProperty('--size-lyrics', '1');
root.style.setProperty('--offset-lyrics', '0');
root.style.setProperty('--lyric-width', '100%');
break;
}
});

View File

@ -0,0 +1,21 @@
/* eslint-disable import/order */
import { createSignal, Show } from 'solid-js';
import { VideoDetails } from '@/types/video-details';
import { SyncedLyricsPluginConfig } from '../types';
import { LyricsContainer } from './components/LyricsContainer';
export const [isVisible, setIsVisible] = createSignal<boolean>(false);
// prettier-ignore
export const [config, setConfig] = createSignal<SyncedLyricsPluginConfig | null>(null);
// prettier-ignore
export const [playerState, setPlayerState] = createSignal<VideoDetails | null>(null);
export const LyricsRenderer = () => {
return (
<Show when={isVisible()}>
<LyricsContainer />
</Show>
);
};

View File

@ -0,0 +1,37 @@
import { render } from 'solid-js/web';
import { LyricsRenderer, setIsVisible, setPlayerState } from './renderer';
import { VideoDetails } from '@/types/video-details';
export const selectors = {
head: '#tabsContent > .tab-header:nth-of-type(2)',
body: {
tabRenderer: '#tab-renderer[page-type="MUSIC_PAGE_TYPE_TRACK_LYRICS"]',
root: 'ytmusic-description-shelf-renderer',
},
};
export const tabStates = {
true: (data?: VideoDetails) => {
setIsVisible(true);
setPlayerState(data ?? null);
const tabRenderer = document.querySelector<HTMLElement>(
selectors.body.tabRenderer,
);
if (!tabRenderer) return;
let container = document.querySelector('#synced-lyrics-container');
if (container) return;
container = Object.assign(document.createElement('div'), {
id: 'synced-lyrics-container',
});
tabRenderer.appendChild(container);
render(() => <LyricsRenderer />, container);
},
false: () => {
setIsVisible(false);
},
};