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

@ -1,17 +1,17 @@
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { UserConfig } from 'vite';
import { defineConfig, defineViteConfig } from 'electron-vite';
import builtinModules from 'builtin-modules';
import viteResolve from 'vite-plugin-resolve';
import Inspect from 'vite-plugin-inspect';
import solidPlugin from 'vite-plugin-solid';
import { pluginVirtualModuleGenerator } from './vite-plugins/plugin-importer.mjs';
import pluginLoader from './vite-plugins/plugin-loader.mjs';
import type { UserConfig } from 'vite';
import { i18nImporter } from './vite-plugins/i18n-importer.mjs';
import solidPlugin from 'vite-plugin-solid';
const __dirname = dirname(fileURLToPath(import.meta.url));
@ -52,7 +52,10 @@ export default defineConfig({
if (mode === 'development') {
commonConfig.plugins?.push(
Inspect({ build: true, outputDir: join(__dirname, '.vite-inspect/backend') }),
Inspect({
build: true,
outputDir: join(__dirname, '.vite-inspect/backend'),
}),
);
return commonConfig;
}
@ -96,7 +99,10 @@ export default defineConfig({
if (mode === 'development') {
commonConfig.plugins?.push(
Inspect({ build: true, outputDir: join(__dirname, '.vite-inspect/preload') }),
Inspect({
build: true,
outputDir: join(__dirname, '.vite-inspect/preload'),
}),
);
return commonConfig;
}
@ -143,7 +149,10 @@ export default defineConfig({
if (mode === 'development') {
commonConfig.plugins?.push(
Inspect({ build: true, outputDir: join(__dirname, '.vite-inspect/renderer') }),
Inspect({
build: true,
outputDir: join(__dirname, '.vite-inspect/renderer'),
}),
);
return commonConfig;
}

View File

@ -164,6 +164,7 @@
"@foobar404/wave": "2.0.5",
"@jellybrick/electron-better-web-request": "1.0.4",
"@jellybrick/mpris-service": "2.1.4",
"@skyra/jaro-winkler": "^1.1.1",
"@xhayper/discord-rpc": "1.1.4",
"async-mutex": "0.5.0",
"butterchurn": "3.0.0-beta.4",

23
pnpm-lock.yaml generated
View File

@ -57,6 +57,9 @@ importers:
'@jellybrick/mpris-service':
specifier: 2.1.4
version: 2.1.4
'@skyra/jaro-winkler':
specifier: ^1.1.1
version: 1.1.1
'@xhayper/discord-rpc':
specifier: 1.1.4
version: 1.1.4(patch_hash=n7icacbfxuqlodunyqwwt5lccm)
@ -990,6 +993,10 @@ packages:
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
engines: {node: '>=10'}
'@skyra/jaro-winkler@1.1.1':
resolution: {integrity: sha512-jT2OWwpajtXTb6opnaIwmBTMpQtKUwl2Ro1zApxIIrpZJon71kZIv6GZSc08LzKO2lpTqUjvD+i7Z2hGuG42KQ==}
engines: {node: '>=v18'}
'@solid-primitives/refs@1.0.6':
resolution: {integrity: sha512-ruh4YdVMxThEVnvqbpeLXKojW442vpFU8q7dSKtElGOTa31aKOAkRb9BTbdaTwVjN4BEq79fiiYIXozJNl4dSw==}
peerDependencies:
@ -4664,6 +4671,8 @@ snapshots:
'@sindresorhus/is@4.6.0': {}
'@skyra/jaro-winkler@1.1.1': {}
'@solid-primitives/refs@1.0.6(solid-js@1.8.19)':
dependencies:
'@solid-primitives/utils': 6.2.2(solid-js@1.8.19)
@ -4984,7 +4993,7 @@ snapshots:
app-builder-bin@4.0.0: {}
app-builder-lib@24.13.3(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)):
app-builder-lib@24.13.3(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)):
dependencies:
'@develar/schema-utils': 2.6.5
'@electron/notarize': 2.2.1
@ -4998,7 +5007,7 @@ snapshots:
builder-util-runtime: 9.2.4
chromium-pickle-js: 0.2.0
debug: 4.3.5
dmg-builder: 24.13.3(electron-builder-squirrel-windows@24.13.3)
dmg-builder: 24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
ejs: 3.1.9
electron-builder-squirrel-windows: 24.13.3(dmg-builder@24.13.3)
electron-publish: 24.13.1
@ -5630,9 +5639,9 @@ snapshots:
discord-api-types@0.37.93: {}
dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3):
dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)):
dependencies:
app-builder-lib: 24.13.3(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
app-builder-lib: 24.13.3(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
builder-util: 24.13.1
builder-util-runtime: 9.2.4
fs-extra: 10.1.0
@ -5704,7 +5713,7 @@ snapshots:
electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3):
dependencies:
app-builder-lib: 24.13.3(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
app-builder-lib: 24.13.3(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
archiver: 5.3.2
builder-util: 24.13.1
fs-extra: 10.1.0
@ -5714,11 +5723,11 @@ snapshots:
electron-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)):
dependencies:
app-builder-lib: 24.13.3(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
app-builder-lib: 24.13.3(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
builder-util: 24.13.1
builder-util-runtime: 9.2.4
chalk: 4.1.2
dmg-builder: 24.13.3(electron-builder-squirrel-windows@24.13.3)
dmg-builder: 24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
fs-extra: 10.1.0
is-ci: 3.0.1
lazy-val: 1.0.5

View File

@ -418,19 +418,19 @@
"presets": "Presets",
"skip-existing": "Skip existing files",
"download-finish-settings": {
"label": "Download on finish",
"submenu": {
"enabled": "Enabled",
"mode": "Time mode",
"seconds": "Seconds",
"percent": "Percent",
"advanced": "Advanced"
},
"prompt": {
"title": "Configure when to download",
"last-seconds": "Last x seconds",
"last-percent": "After x percent"
}
"label": "Download on finish",
"submenu": {
"enabled": "Enabled",
"mode": "Time mode",
"seconds": "Seconds",
"percent": "Percent",
"advanced": "Advanced"
},
"prompt": {
"title": "Configure when to download",
"last-seconds": "Last x seconds",
"last-percent": "After x percent"
}
}
},
"name": "Downloader",
@ -668,6 +668,23 @@
"description": "Automatically Skips non-music parts like intro/outro or parts of music videos where the song isn't playing",
"name": "SponsorBlock"
},
"synced-lyrics": {
"description": "Provides synced lyrics to songs, using providers like LRClib.",
"name": "Synced Lyrics",
"errors": {
"fetch": "⚠️ - An error occurred while fetching the lyrics. Please try again later.",
"not-found": "⚠️ - No lyrics found for this song."
},
"warnings": {
"instrumental": "⚠️ - This is an instrumental song",
"inexact": "⚠️ - The lyrics for this song may not be exact",
"duration-mismatch": "⚠️ - The lyrics may be out of sync due to a duration mismatch."
},
"refetch-btn": {
"normal": "Refetch lyrics",
"fetching": "Fetching..."
}
},
"taskbar-mediacontrol": {
"description": "Control playback from your Windows taskbar",
"name": "Taskbar Media Control"

View File

@ -77,6 +77,7 @@ export const onRendererLoad = ({
applyLyricsTabState();
}
};
const applyLyricsTabState = () => {
if (lyrics) {
tabs.lyrics.removeAttribute('disabled');
@ -86,6 +87,7 @@ export const onRendererLoad = ({
tabs.lyrics.setAttribute('aria-disabled', '');
}
};
const lyricsTabHandler = () => {
const tabContainer = document.querySelector('ytmusic-tab-renderer');
if (!tabContainer) return;

View File

@ -0,0 +1,28 @@
import style from './style.css?inline';
import { createPlugin } from '@/utils';
import { SyncedLyricsPluginConfig } from './types';
import { menu } from './menu';
import { renderer } from './renderer';
import { t } from '@/i18n';
export default createPlugin({
name: () => t('plugins.synced-lyrics.name'),
description: () => t('plugins.synced-lyrics.description'),
authors: ['Non0reo', 'ArjixWasTaken'],
restartNeeded: true,
addedVersion: '3.4.X',
config: {
preciseTiming: true,
showLyricsEvenIfInexact: true,
showTimeCodes: false,
defaultTextString: '♪',
lineEffect: 'scale',
} as SyncedLyricsPluginConfig,
menu,
renderer,
stylesheets: [style],
});

View File

@ -0,0 +1,138 @@
import { MenuItemConstructorOptions } from 'electron';
import { MenuContext } from '@/types/contexts';
import { SyncedLyricsPluginConfig } from './types';
export const menu = async ({
getConfig,
setConfig,
}: MenuContext<SyncedLyricsPluginConfig>): Promise<
MenuItemConstructorOptions[]
> => {
const config = await getConfig();
return [
{
label: 'Make the lyrics perfectly synced',
toolTip:
'Calculate to the milisecond the display of the next line (can have a small impact on performance)',
type: 'checkbox',
checked: config.preciseTiming,
click(item) {
setConfig({
preciseTiming: item.checked,
});
},
},
{
label: 'Line effect',
toolTip: 'Choose the effect to apply to the current line',
type: 'submenu',
submenu: [
{
label: 'Scale',
toolTip: 'Scale the current line',
type: 'radio',
checked: config.lineEffect === 'scale',
click() {
setConfig({
lineEffect: 'scale',
});
},
},
{
label: 'Offset',
toolTip: 'Offset on the right the current line',
type: 'radio',
checked: config.lineEffect === 'offset',
click() {
setConfig({
lineEffect: 'offset',
});
},
},
{
label: 'Focus',
toolTip: 'Make only the current line white',
type: 'radio',
checked: config.lineEffect === 'focus',
click() {
setConfig({
lineEffect: 'focus',
});
},
},
],
},
{
label: 'Default character between lyrics',
toolTip: 'Choose the default string to use for the gap between lyrics',
type: 'submenu',
submenu: [
{
label: '♪',
type: 'radio',
checked: config.defaultTextString === '♪',
click() {
setConfig({
defaultTextString: '♪',
});
},
},
{
label: '[SPACE]',
type: 'radio',
checked: config.defaultTextString === ' ',
click() {
setConfig({
defaultTextString: ' ',
});
},
},
{
label: '...',
type: 'radio',
checked: config.defaultTextString === '...',
click() {
setConfig({
defaultTextString: '...',
});
},
},
{
label: '———',
type: 'radio',
checked: config.defaultTextString === '———',
click() {
setConfig({
defaultTextString: '———',
});
},
},
],
},
{
label: 'Show time codes',
toolTip: 'Show the time codes next to the lyrics',
type: 'checkbox',
checked: config.showTimeCodes,
click(item) {
setConfig({
showTimeCodes: item.checked,
});
},
},
{
label: 'Show lyrics even if inexact',
toolTip:
'If the song is not found, the plugin tries again with a different search query.\nThe result from the second attempt may not be exact.',
type: 'checkbox',
checked: config.showLyricsEvenIfInexact,
click(item) {
setConfig({
showLyricsEvenIfInexact: item.checked,
});
},
},
];
};

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);
},
};

View File

@ -0,0 +1,78 @@
/* Hides the original lyrics, to only show our own. */
#tab-renderer[page-type='MUSIC_PAGE_TYPE_TRACK_LYRICS'] > * {
display: none !important;
}
#tab-renderer[page-type='MUSIC_PAGE_TYPE_TRACK_LYRICS'] > #synced-lyrics-container {
display: block !important;
}
/* :root {
--ytmusic-text-primary: #fff;
--ytmusic-text-secondary: #aaa;
} */
:root {
--global-margin: 0.7rem;
--previous-lyrics: var(--ytmusic-text-primary);
--current-lyrics: var(--ytmusic-text-primary);
--upcoming-lyrics: var(--ytmusic-text-secondary);
--size-lyrics: 1.2em;
--offset-lyrics: 1em;
}
.lyric-container {
padding-top: 16px;
}
.description {
font-size: clamp(1.4rem, 1.1vmax, 3rem) !important;
text-align: left !important;
}
.synced-line {
width: var(--lyric-width, 100%);
}
.synced-line > .text-lyrics {
cursor: pointer;
}
.synced-lyrics {
display: block;
justify-content: left;
text-align: left;
margin: 0.5rem 0;
margin-right: 20px;
transition: all 0.3s ease-in-out;
}
.warning-lyrics {
color: var(--ytmusic-text-secondary) !important;
font-style: italic;
}
.text-lyrics {
display: block;
text-align: left;
margin: var(--global-margin) 0;
transition: scale 0.3s ease-in-out, translate 0.3s ease-in-out, color 0.1s ease-in-out;
transform-origin: 0 50%;
}
.previous > .text-lyrics {
color: var(--previous-lyrics);
font-weight: normal;
}
.current > .text-lyrics {
color: var(--current-lyrics);
font-weight: bold;
scale: var(--size-lyrics);
translate: var(--offset-lyrics) 0;
}
.upcoming > .text-lyrics {
color: var(--upcoming-lyrics);
font-weight: normal;
}

View File

@ -0,0 +1,38 @@
export type SyncedLyricsPluginConfig = {
enabled: boolean;
preciseTiming: boolean;
showTimeCodes: boolean;
defaultTextString: string;
showLyricsEvenIfInexact: boolean;
lineEffect: LineEffect;
};
export type LineLyricsStatus = 'previous' | 'current' | 'upcoming';
export type LineLyrics = {
index: number;
time: string;
timeInMs: number;
text: string;
duration: number;
status: LineLyricsStatus;
};
export type PlayPauseEvent = {
isPaused: boolean;
elapsedSeconds: number;
};
export type LineEffect = 'scale' | 'offset' | 'focus';
export type LRCLIBSearchResponse = {
id: number;
name: string;
trackName: string;
artistName: string;
albumName: string;
duration: number;
instrumental: boolean;
plainLyrics: string;
syncedLyrics: string;
}[];

View File

@ -241,7 +241,7 @@ export interface FlagEndpoint {
flagAction: string;
}
export type VideoDataChangeValue = Record<string, unknown> & {
export type VideoDataChangeValue = {
videoId: string;
title: string;
author: string;

37
src/yt-web-components.d.ts vendored Normal file
View File

@ -0,0 +1,37 @@
import type { ComponentProps } from 'solid-js';
declare module 'solid-js' {
namespace JSX {
interface YtFormattedStringProps {
text?: {
runs: { text: string }[];
};
data?: object;
disabled?: boolean;
hidden?: boolean;
}
interface YtButtonRendererProps {
data?: {
icon?: {
iconType: string;
};
isDisabled?: boolean;
style?: string;
text?: {
simpleText: string;
};
};
}
interface YpYtPaperSpinnerLiteProps {
active?: boolean;
}
interface IntrinsicElements {
'yt-formatted-string': ComponentProps<'span'> & YtFormattedStringProps;
'yt-button-renderer': ComponentProps<'button'> & YtButtonRendererProps;
'tp-yt-paper-spinner-lite': ComponentProps<'div'> & YpYtPaperSpinnerLiteProps;
}
}
}

View File

@ -0,0 +1,6 @@
{
"status": "failed",
"failedTests": [
"3c41ab34e2f6ae47f5cb-f82377181f2146bd94b6"
]
}