mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-09 17:51:46 +00:00
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:
@ -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;
|
||||
}
|
||||
|
||||
@ -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
23
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
28
src/plugins/synced-lyrics/index.ts
Normal file
28
src/plugins/synced-lyrics/index.ts
Normal 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],
|
||||
});
|
||||
138
src/plugins/synced-lyrics/menu.ts
Normal file
138
src/plugins/synced-lyrics/menu.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
53
src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx
Normal file
53
src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
90
src/plugins/synced-lyrics/renderer/index.ts
Normal file
90
src/plugins/synced-lyrics/renderer/index.ts
Normal 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);
|
||||
});
|
||||
},
|
||||
});
|
||||
197
src/plugins/synced-lyrics/renderer/lyrics/fetch.ts
Normal file
197
src/plugins/synced-lyrics/renderer/lyrics/fetch.ts
Normal 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;
|
||||
};
|
||||
45
src/plugins/synced-lyrics/renderer/lyrics/index.ts
Normal file
45
src/plugins/synced-lyrics/renderer/lyrics/index.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
21
src/plugins/synced-lyrics/renderer/renderer.tsx
Normal file
21
src/plugins/synced-lyrics/renderer/renderer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
37
src/plugins/synced-lyrics/renderer/utils.tsx
Normal file
37
src/plugins/synced-lyrics/renderer/utils.tsx
Normal 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);
|
||||
},
|
||||
};
|
||||
78
src/plugins/synced-lyrics/style.css
Normal file
78
src/plugins/synced-lyrics/style.css
Normal 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;
|
||||
}
|
||||
38
src/plugins/synced-lyrics/types.ts
Normal file
38
src/plugins/synced-lyrics/types.ts
Normal 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;
|
||||
}[];
|
||||
@ -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
37
src/yt-web-components.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
6
test-results/.last-run.json
Normal file
6
test-results/.last-run.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"3c41ab34e2f6ae47f5cb-f82377181f2146bd94b6"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user