Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion modules/lyrics-plus/config.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ describe("CONFIG", () => {
assert.equal(CONFIG.visual.alignment, "center");
assert.equal(CONFIG.visual["background-color"], "var(--spice-main)");
assert.equal(CONFIG.visual["playbar-button"], false);
assert.equal(CONFIG.visual["animated-background"], false);
assert.equal(CONFIG.visual["inactive-blur"], false);
// Numeric coercion runs after the literal.
assert.equal(CONFIG.visual["font-size"], 32);
assert.equal(CONFIG.visual["lines-before"], 0);
Expand All @@ -89,6 +91,7 @@ describe("CONFIG", () => {

it("rejects duplicate and non-string provider keys from stored JSON", async () => {
for (const order of [
["local", "lrclib", "lyricsovh", "musixmatch", "netease"],
["lrclib", "lrclib", "musixmatch", "spotify", "local"],
["lrclib", "netease", "musixmatch", "spotify", {}],
]) {
Expand Down Expand Up @@ -147,6 +150,22 @@ describe("CONFIG", () => {

it("exposes only supported providers in configuration", async () => {
const { CONFIG } = await import(`./config.ts?providers=${Date.now()}`);
assert.deepEqual(Object.keys(CONFIG.providers).sort(), ["local", "lrclib", "musixmatch", "netease", "spotify"]);
assert.deepEqual(Object.keys(CONFIG.providers).sort(), [
"local",
"lrclib",
"lyricsovh",
"musixmatch",
"netease",
"spotify",
]);
});

it("appends Lyrics.ovh without resetting the user's existing provider priority", async () => {
const order = ["local", "spotify", "musixmatch", "netease", "lrclib"];
localStorage.setItem("lyrics-plus:services-order", JSON.stringify(order));
const { CONFIG, UNSYNCED } = await import(`./config.ts?new-provider=${Date.now()}`);
assert.deepEqual(CONFIG.providersOrder, [...order, "lyricsovh"]);
assert.deepEqual(CONFIG.providers.lyricsovh.modes, [UNSYNCED]);
assert.deepEqual(JSON.parse(localStorage.getItem("lyrics-plus:services-order")!), CONFIG.providersOrder);
});
});
20 changes: 17 additions & 3 deletions modules/lyrics-plus/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ const providers = {
desc: "Provide lyrics from cache/local files loaded from previous Spotify sessions.",
modes: [KARAOKE, SYNCED, UNSYNCED],
},
lyricsovh: {
on: getConfig("lyrics-plus:provider:lyricsovh:on"),
desc: "Unsynced lyrics from lyrics.ovh. Used as a fallback when earlier providers have no lyrics.",
modes: [UNSYNCED],
},
};

export type ProviderKey = keyof typeof providers;
Expand All @@ -85,6 +90,8 @@ export const CONFIG = {
visual: {
"playbar-button": getConfig("lyrics-plus:visual:playbar-button", false),
colorful: getConfig("lyrics-plus:visual:colorful"),
"animated-background": getConfig("lyrics-plus:visual:animated-background", false),
"inactive-blur": getConfig("lyrics-plus:visual:inactive-blur", false),
noise: getConfig("lyrics-plus:visual:noise"),
"background-color": localStorage.getItem("lyrics-plus:visual:background-color") || "var(--spice-main)",
"active-color": localStorage.getItem("lyrics-plus:visual:active-color") || "var(--spice-text)",
Expand Down Expand Up @@ -134,13 +141,20 @@ try {
const storedOrder: unknown = JSON.parse(localStorage.getItem("lyrics-plus:services-order") ?? "null");
if (
!Array.isArray(storedOrder) ||
storedOrder.length !== providerKeys.length ||
new Set(storedOrder).size !== providerKeys.length ||
!(
storedOrder.length === providerKeys.length ||
(storedOrder.length === providerKeys.length - 1 && !storedOrder.includes("lyricsovh"))
) ||
new Set(storedOrder).size !== storedOrder.length ||
!storedOrder.every(isProviderKey)
) {
throw new Error("Invalid stored provider order");
}
CONFIG.providersOrder = storedOrder;
const missingProviders = providerKeys.filter((key) => !storedOrder.includes(key));
CONFIG.providersOrder = [...storedOrder, ...missingProviders];
if (missingProviders.length) {
localStorage.setItem("lyrics-plus:services-order", JSON.stringify(CONFIG.providersOrder));
}
} catch {
CONFIG.providersOrder = providerKeys;
localStorage.setItem("lyrics-plus:services-order", JSON.stringify(CONFIG.providersOrder));
Expand Down
9 changes: 8 additions & 1 deletion modules/lyrics-plus/container-requests.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@ const components: Record<string, string[]> = {
"./options-menu.tsx": ["AdjustmentsMenu", "TranslationMenu"],
"./tab-bar.tsx": ["TopBarContent"],
"./settings.tsx": ["LyricsPlusSettings", "openLyricsPlusAppearanceSettings"],
"./pages.tsx": ["GeniusPage", "LoadingIcon", "SyncedExpandedLyricsPage", "SyncedLyricsPage", "UnsyncedLyricsPage"],
"./pages.tsx": [
"GeniusPage",
"LoadingIcon",
"LyricsBackground",
"SyncedExpandedLyricsPage",
"SyncedLyricsPage",
"UnsyncedLyricsPage",
],
"/modules/stdlib/lib/primitives.js": ["SettingsSection", "Tooltip"],
};
const hooks = registerHooks({
Expand Down
75 changes: 73 additions & 2 deletions modules/lyrics-plus/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@

.lyrics-lyricsContainer-LyricsContainer {
display: grid;
isolation: isolate;
grid-template-rows: 1fr;
position: absolute;
height: 100%;
Expand Down Expand Up @@ -116,12 +117,82 @@
}

.lyrics-lyricsContainer-LyricsBackground {
position: relative;
z-index: -1;
overflow: hidden;
pointer-events: none;
background-color: var(--lyrics-color-background);
background-image: var(--lyrics-background-noise);
grid-area: 1 / 1 / -1 / -1;
transition: background-color 0.25s ease-out;
}

.lyrics-lyricsContainer-LyricsBackground::after {
content: "";
position: absolute;
inset: 0;
background-image: var(--lyrics-background-noise);
}

.lyrics-album-art {
position: absolute;
inset: 0;
overflow: hidden;
filter: brightness(0.5) saturate(1.3);
}

.lyrics-album-art img {
position: absolute;
width: 150%;
height: 150%;
top: -25%;
left: -25%;
object-fit: cover;
filter: blur(48px);
animation: lyrics-album-drift 40s ease-in-out infinite alternate;
}

.lyrics-album-art img:nth-child(2) {
opacity: 0.5;
animation-direction: alternate-reverse;
animation-duration: 55s;
}

@keyframes lyrics-album-drift {
from {
transform: rotate(-15deg) scale(1);
}
to {
transform: rotate(15deg) scale(1.2);
}
}

.lyrics-lyricsContainer-LyricsContainer.inactive-blur-enabled
.lyrics-expanded-synced
.lyrics-lyricsContainer-LyricsLine {
filter: blur(calc(var(--blur-index, 0) * 1px));
transition:
filter 0.2s ease-out,
color 0.25s ease-out;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

.lyrics-lyricsContainer-LyricsContainer.inactive-blur-enabled
.lyrics-expanded-synced
.lyrics-lyricsContainer-LyricsLine:hover {
filter: none;
}

@media (prefers-reduced-motion: reduce) {
.lyrics-album-art img {
animation: none;
}

.lyrics-lyricsContainer-LyricsContainer.inactive-blur-enabled
.lyrics-expanded-synced
.lyrics-lyricsContainer-LyricsLine {
transition: none;
}
}

.lyrics-lyricsContainer-Provider {
align-self: end;
color: var(--lyrics-color-inactive);
Expand Down Expand Up @@ -198,7 +269,7 @@
.lyrics-lyricsContainer-UnsyncedLyricsPage .lyrics-lyricsContainer-LyricsLine.lyrics-lyricsContainer-LyricsLine-past {
color: var(--lyrics-color-active);
opacity: 1;
filter: none !important;
filter: none;
}

.lyrics-lyricsContainer-LyricsLine,
Expand Down
2 changes: 1 addition & 1 deletion modules/lyrics-plus/metadata.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "lyrics-plus",
"kind": "app",
"version": "0.2.6",
"version": "0.3.0",
"authors": ["spicetify"],
"description": "Full-featured lyrics: synced, karaoke, unsynced and translated, from Musixmatch, Spotify, LRCLIB and Netease.",
"entries": {
Expand Down
30 changes: 25 additions & 5 deletions modules/lyrics-plus/mod.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,14 @@ import { TopBarContent } from "./tab-bar.tsx";
import { LyricsPlusSettings, openLyricsPlusAppearanceSettings } from "./settings.tsx";
import { lyricsReplacementReady, mountLyricsPlaybarStyleWhenReady, watchLyricsHistory } from "./playbar-lifecycle.ts";
import type { LyricsHistory } from "./playbar-lifecycle.ts";
import { GeniusPage, LoadingIcon, SyncedExpandedLyricsPage, SyncedLyricsPage, UnsyncedLyricsPage } from "./pages.tsx";
import {
GeniusPage,
LoadingIcon,
LyricsBackground,
SyncedExpandedLyricsPage,
SyncedLyricsPage,
UnsyncedLyricsPage,
} from "./pages.tsx";
import { ProviderMusixmatch } from "./providers/musixmatch.ts";
import { configureLyricsClient, getLyricsResponse, requestLyrics } from "./runtime-client.ts";

Expand All @@ -76,6 +83,7 @@ const ICON =
interface LyricsState extends CachedLyrics {
currentLyrics: DisplayLyricLine[] | null;
colors: { background: string; inactive: string };
image: string;
tempo: string;
explicitMode: number;
lockMode: number;
Expand Down Expand Up @@ -348,6 +356,7 @@ export class LyricsContainer extends react.Component<LyricsProps, LyricsState> {
background: "",
inactive: "",
},
image: "",
tempo: "0.25s",
explicitMode: -1,
lockMode: CONFIG.locked,
Expand Down Expand Up @@ -646,9 +655,11 @@ export class LyricsContainer extends react.Component<LyricsProps, LyricsState> {
const generation = ++this.requestGeneration;
const info = this.infoFromTrack(track);
if (!info) {
this.setState({ error: "No track info" });
this.setState({ error: "No track info", image: "" });
return;
}
const image = info.image ?? "";
this.setState((state) => (state.image === image ? null : { image }));

if (mode === -1) mode = this.props.queries.preferredMode(info.uri) ?? -1;
this.state.explicitMode = mode;
Expand Down Expand Up @@ -1285,6 +1296,15 @@ export class LyricsContainer extends react.Component<LyricsProps, LyricsState> {
"--lyrics-background-noise": CONFIG.visual.noise ? "var(--background-noise)" : "unset",
};
}
if (CONFIG.visual["animated-background"] && !this.state.isFADMode) {
this.styleVariables = {
...this.styleVariables,
"--lyrics-color-active": "white",
"--lyrics-color-inactive": "rgba(255,255,255,0.65)",
"--lyrics-color-background": "#161616",
"--lyrics-highlight-background": "rgba(255,255,255,0.2)",
};
}

this.styleVariables = {
...this.styleVariables,
Expand Down Expand Up @@ -1400,7 +1420,7 @@ export class LyricsContainer extends react.Component<LyricsProps, LyricsState> {
const out = react.createElement(
"div",
{
className: `lyrics-lyricsContainer-LyricsContainer${CONFIG.visual["fade-blur"] ? " blur-enabled" : ""}${
className: `lyrics-lyricsContainer-LyricsContainer${CONFIG.visual["fade-blur"] ? " blur-enabled" : ""}${CONFIG.visual["inactive-blur"] ? " inactive-blur-enabled" : ""}${
fadLyricsContainer ? " fad-enabled" : ""
}`,
style: this.styleVariables,
Expand All @@ -1409,8 +1429,8 @@ export class LyricsContainer extends react.Component<LyricsProps, LyricsState> {
el.onwheel = this.onFontSizeChange;
},
},
react.createElement("div", {
className: "lyrics-lyricsContainer-LyricsBackground",
react.createElement(LyricsBackground, {
image: CONFIG.visual["animated-background"] && !fadLyricsContainer ? this.state.image : "",
}),
react.createElement(
"div",
Expand Down
36 changes: 35 additions & 1 deletion modules/lyrics-plus/pages.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ const hooks = registerHooks({
return nextLoad(url, context);
},
});
const { SearchBar, SyncedLyricsPage, SyncedExpandedLyricsPage, VersionSelector } = await import("./pages.tsx");
const { LyricsBackground, SearchBar, SyncedLyricsPage, SyncedExpandedLyricsPage, VersionSelector } =
await import("./pages.tsx");
hooks.deregister();

const roots: Root[] = [];
Expand All @@ -75,6 +76,39 @@ it("renders both synchronized views while lyrics are empty", async () => {
assert.equal(document.querySelectorAll(".lyrics-idling-indicator").length, 2);
});

it("uses Spotify artwork for the backdrop and falls back if it fails to load", async () => {
const container = await render(React.createElement(LyricsBackground, { image: "spotify:image:abc123" }));
const image = container.querySelector("img");
assert.ok(image);
assert.equal(image.src, "https://i.scdn.co/image/abc123");
assert.equal(image.alt, "");
assert.equal(container.firstElementChild?.getAttribute("aria-hidden"), "true");
await React.act(async () => image.dispatchEvent(new Event("error")));
assert.equal(container.querySelectorAll("img").length, 0);
});

it("keeps a solid backdrop when there is no artwork", async () => {
const container = await render(React.createElement(LyricsBackground, { image: "" }));
assert.equal(container.querySelectorAll("img").length, 0);
});

it("keeps active scrolling lyrics sharp and caps blur on distant lines", async () => {
playback.position = 1000;
const container = await render(
React.createElement(SyncedExpandedLyricsPage, {
lyrics: Array.from({ length: 8 }, (_, i) => ({ text: `Line ${i}`, startTime: i * 1000 })),
}),
);
const active = container.querySelector<HTMLElement>(
".lyrics-lyricsContainer-LyricsLine-active:not(.lyrics-idling-indicator)",
);
assert.equal(active?.style.getPropertyValue("--blur-index"), "0");
const distant = [...container.querySelectorAll<HTMLElement>(".lyrics-lyricsContainer-LyricsLine")].find(
(line) => line.textContent === "Line 7",
);
assert.equal(distant?.style.getPropertyValue("--blur-index"), "4");
});

it("selects Genius versions with a numeric index", async () => {
const selections: number[] = [];
const versions = [
Expand Down
29 changes: 28 additions & 1 deletion modules/lyrics-plus/pages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,32 @@ import { convertParsedToLRC, convertParsedToUnsynced, lyricText, isKaraokeWords

const { useState, useEffect, useMemo, useRef } = react;

export function LyricsBackground({ image }: { image: string }) {
const [failedImage, setFailedImage] = useState<string | null>(null);
const source = image.startsWith("spotify:image:")
? `https://i.scdn.co/image/${image.slice("spotify:image:".length)}`
: image;
return react.createElement(
"div",
{ className: "lyrics-lyricsContainer-LyricsBackground", "aria-hidden": true },
source &&
failedImage !== image &&
react.createElement(
"div",
{ className: "lyrics-album-art" },
[0, 1].map((layer) =>
react.createElement("img", {
key: layer,
src: source,
alt: "",
draggable: false,
onError: () => setFailedImage(image),
}),
),
),
);
}

interface CreditProps {
reRenderLyricsPage?: boolean;
provider?: string | null;
Expand Down Expand Up @@ -676,7 +702,7 @@ export const SyncedExpandedLyricsPage = react.memo(({ lyrics, provider, copyrigh
return react.createElement(
"div",
{
className: "lyrics-lyricsContainer-UnsyncedLyricsPage",
className: "lyrics-lyricsContainer-UnsyncedLyricsPage lyrics-expanded-synced",
key: lyricsId,
ref: pageRef,
},
Expand Down Expand Up @@ -730,6 +756,7 @@ export const SyncedExpandedLyricsPage = react.memo(({ lyrics, provider, copyrigh
key: i,
style: {
cursor: "pointer",
"--blur-index": isActive ? 0 : Math.min(Math.abs(i - activeLineIndex), 4),
},
dir: "auto",
ref: isFocused ? activeLineRef : null,
Expand Down
Loading
Loading