MusicControls: Update SpotifyLyrics

This commit is contained in:
thororen1234 2025-08-14 09:40:26 -04:00
parent bfb765097c
commit fc36d08465
No known key found for this signature in database
7 changed files with 103 additions and 62 deletions

View file

@ -10,7 +10,8 @@ import { makeRange, OptionType } from "@utils/types";
import { Button, Forms, MaskedLink, showToast, Toasts } from "@webpack/common";
import hoverOnlyStyle from "./hoverOnly.css?managed";
import { clearLyricsCache } from "./spotify/lyrics/api";
import { clearLyricsCache, removeTranslations } from "./spotify/lyrics/api";
import languages from "./spotify/lyrics/providers/translator/languages";
import { Provider } from "./spotify/lyrics/providers/types";
const sliderOptions = {
@ -61,6 +62,15 @@ export const settings = definePluginSettings({
{ value: Provider.Lrclib, label: "LRCLIB" },
],
},
TranslateTo: {
description: "Translate lyrics to - Changing this will clear existing translations",
type: OptionType.SELECT,
options: languages,
onChange: async () => {
await removeTranslations();
showToast("Translations cleared", Toasts.Type.SUCCESS);
}
},
LyricsConversion: {
description: "Automatically translate or romanize lyrics",
type: OptionType.SELECT,

View file

@ -29,8 +29,7 @@ export const lyricFetchers = {
export const providers = Object.keys(lyricFetchers) as Provider[];
export async function getLyrics(track: Track | null): Promise<LyricsData | null> {
if (!track) return null;
if (!track.id) return null;
if (!track || !track.id) return null;
const cacheKey = track.id;
const cached = await DataStore.get(LyricsCacheKey) as Record<string, LyricsData | null>;
@ -76,7 +75,7 @@ export async function clearLyricsCache() {
export async function getLyricsCount(): Promise<number> {
const cache = await DataStore.get(LyricsCacheKey) as Record<string, LyricsData | null>;
return Object.keys(cache).length;
return Object.keys(cache ?? {}).length;
}
export async function updateLyrics(trackId: string, newLyrics: SyncedLyric[], provider: Provider) {

View file

@ -14,51 +14,45 @@ import { LyricsContextMenu } from "./ctxMenu";
import { LyricsModal } from "./modal";
import { cl, NoteSvg, useLyrics } from "./util";
const prevCl = cl("prev");
const nextCl = cl("next");
const currentCl = cl("current");
function LyricsDisplay({ scroll = true }: { scroll?: boolean; }) {
const { ShowMusicNoteOnNoLyrics } = settings.use(["ShowMusicNoteOnNoLyrics"]);
const { lyricsInfo, lyricRefs, currLrcIndex } = useLyrics({ scroll });
const currentLyrics = lyricsInfo?.lyricsVersions[lyricsInfo.useLyric] || null;
const NoteElement = NoteSvg(cl("music-note"));
const makeClassName = (index: number): string => {
if (currLrcIndex === null) return "";
if (currLrcIndex == null) return prevCl;
const diff = index - currLrcIndex;
if (diff === 0) return cl("current");
return cl(diff > 0 ? "next" : "prev");
if (diff === 0) return currentCl;
return diff > 0 ? nextCl : prevCl;
};
if (!lyricsInfo) {
return ShowMusicNoteOnNoLyrics ? (
<div className="vc-spotify-lyrics"
onContextMenu={e => ContextMenuApi.openContextMenu(e, () => <LyricsContextMenu />)}
>
<TooltipContainer text="No synced lyrics found">
{NoteElement}
</TooltipContainer>
</div>
) : null;
}
return (
<div
className="vc-spotify-lyrics"
onClick={() => openModal(props => <LyricsModal rootProps={props} />)}
onClick={() => openModal(props => <LyricsModal props={props} />)}
onContextMenu={e => ContextMenuApi.openContextMenu(e, () => <LyricsContextMenu />)}
>
{currentLyrics?.map((line, i) => (
{currentLyrics ? currentLyrics.map((line, i) => (
<div ref={lyricRefs[i]} key={i}>
<Text
variant={currLrcIndex === i ? "text-sm/normal" : "text-xs/normal"}
className={makeClassName(i)}
>
{line.text || NoteElement}
{line.text || NoteSvg()}
</Text>
</div>
))}
)) : ShowMusicNoteOnNoLyrics ? (
<TooltipContainer text="No synced lyrics found">
<NoteSvg />
</TooltipContainer>
) : null}
</div>
);
}

View file

@ -17,7 +17,14 @@ const formatTime = (time: number) => {
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
function ModalHeaderContent({ track }: { track: Track; }) {
function ModalHeaderContent({ track }: { track: Track | null; }) {
if (!track) {
return (
<ModalHeader>
<Text variant="text-sm/semibold">No track playing</Text>
</ModalHeader>
);
}
return (
<ModalHeader>
<div className={cl("header-content")}>
@ -43,27 +50,30 @@ function ModalHeaderContent({ track }: { track: Track; }) {
);
}
export function LyricsModal({ rootProps }: { rootProps: ModalProps; }) {
const modalCurrentLine = cl("modal-line-current");
const modalLine = cl("modal-line");
export function LyricsModal({ props }: { props: ModalProps; }) {
const { track, lyricsInfo, currLrcIndex } = useLyrics({ scroll: false });
const currentLyrics = lyricsInfo?.lyricsVersions[lyricsInfo.useLyric] || null;
const currentLyrics = lyricsInfo?.lyricsVersions[lyricsInfo.useLyric];
return (
<ModalRoot {...rootProps}>
<ModalHeaderContent track={track!} />
<ModalRoot {...props}>
<ModalHeaderContent track={track} />
<ModalContent>
<div className={cl("lyrics-modal-container") + ` ${scrollClasses.auto}`}>
<div className={`${cl("lyrics-modal-container")} ${scrollClasses.auto}`}>
{currentLyrics ? (
currentLyrics.map((line, i) => (
<Text
key={i}
variant={currLrcIndex === i ? "text-md/semibold" : "text-sm/normal"}
selectable
className={currLrcIndex === i ? cl("modal-line-current") : cl("modal-line")}
className={currLrcIndex === i ? modalCurrentLine : modalLine}
>
<span className={cl("modal-timestamp")} onClick={() => SpotifyStore.seek(line.time * 1000)}>
{formatTime(line.time)}
</span>
{line.text || NoteSvg(cl("modal-note"))}
{line.text || NoteSvg()}
</Text>
))
) : (

View file

@ -12,23 +12,51 @@ import { settings } from "../../../settings";
import { SpotifyStore } from "../../SpotifyStore";
import { SpotifyLrcStore } from "../providers/store";
import { SyncedLyric } from "../providers/types";
export const scrollClasses = findByPropsLazy("auto", "customTheme");
export const cl = classNameFactory("vc-spotify-lyrics-");
export function NoteSvg(className: string) {
export function NoteSvg() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 480 720" fill="currentColor" className={className} >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 480 720" fill="currentColor" className={cl("music-note")}>
<path d="m160,-240 q -66,0 -113,-47 -47,-47 -47,-113 0,-66 47,-113 47,-47 113,-47 23,0 42.5,5.5 19.5,5.5 37.5,16.5 v -422 h 240 v 160 H 320 v 400 q 0,66 -47,113 -47,47 -113,47 z" />
</svg>
);
}
const calculateIndexes = (lyrics: SyncedLyric[], position: number, delay: number) => {
const posInSec = position / 1000;
const currentIndex = lyrics.findIndex(l => l.time - (delay / 1000) > posInSec && l.time < posInSec + 8) - 1;
const nextLyric = lyrics.findIndex(l => l.time >= posInSec);
return [currentIndex, nextLyric];
const getIndexes = (lyrics: SyncedLyric[], position: number, delay: number) => {
const posInSec = (position + delay) / 1000;
let left = 0, right = lyrics.length - 1;
let currentIndex: number | null = null;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const curr = lyrics[mid];
const next = lyrics[mid + 1];
if (curr.time <= posInSec && (!next || next.time > posInSec)) {
currentIndex = mid;
break;
}
if (curr.time > posInSec) {
right = mid - 1;
} else {
left = mid + 1;
}
}
const nextIdx = currentIndex !== null ? currentIndex + 1 : left;
const nextLyricIdx = nextIdx < lyrics.length ? nextIdx : null;
if (currentIndex !== null && posInSec - lyrics[currentIndex].time > 8) {
return [null, nextLyricIdx];
}
return [currentIndex, nextLyricIdx];
};
export function useLyrics({ scroll = true }: { scroll?: boolean; } = {}) {
@ -47,7 +75,7 @@ export function useLyrics({ scroll = true }: { scroll?: boolean; } = {}) {
const [position, setPosition] = useState(storePosition);
const [lyricRefs, setLyricRefs] = useState<React.RefObject<HTMLDivElement | null>[]>([]);
const currentLyrics = lyricsInfo?.lyricsVersions[lyricsInfo.useLyric] || null;
const currentLyrics = lyricsInfo?.lyricsVersions[lyricsInfo.useLyric];
useEffect(() => {
if (currentLyrics) {
@ -57,12 +85,15 @@ export function useLyrics({ scroll = true }: { scroll?: boolean; } = {}) {
useEffect(() => {
if (currentLyrics && position) {
const [currentIndex, nextLyric] = calculateIndexes(currentLyrics, position, LyricDelay);
if (currentLyrics && position != null) {
const [currentIndex, nextLyricIndex] = getIndexes(currentLyrics, position, LyricDelay);
setCurrLrcIndex(currentIndex);
setNextLyric(nextLyric);
setNextLyric(nextLyricIndex);
} else {
setCurrLrcIndex(null);
setNextLyric(null);
}
}, [currentLyrics, position]);
}, [currentLyrics, position, LyricDelay]);
useEffect(() => {
if (scroll && currLrcIndex !== null) {
@ -73,17 +104,15 @@ export function useLyrics({ scroll = true }: { scroll?: boolean; } = {}) {
lyricRefs[nextLyric]?.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
}, [currLrcIndex, nextLyric, scroll]);
}, [currLrcIndex, nextLyric, scroll, lyricRefs]);
useEffect(() => {
if (isPlaying) {
setPosition(SpotifyStore.position);
const interval = setInterval(() => {
setPosition(p => p + 1000);
}, 1000);
if (!isPlaying) return;
return () => clearInterval(interval);
}
setPosition(SpotifyStore.position);
const interval = setInterval(() => setPosition(p => p + 1000), 1000);
return () => clearInterval(interval);
}, [storePosition, isPlaying]);
return { track, lyricsInfo, lyricRefs, currLrcIndex, nextLyric };

View file

@ -5,7 +5,7 @@
*/
import settings from "../../../..";
import { LyricsData, Provider, SyncedLyric } from "../types";
import { Provider, SyncedLyric } from "../types";
// stolen from src\plugins\translate\utils.ts
@ -45,7 +45,7 @@ async function googleTranslate(text: string, targetLang: string, romanize: boole
}
async function processLyrics(
lyrics: LyricsData["lyricsVersions"][Provider],
lyrics: SyncedLyric[],
targetLang: string,
romanize: boolean
): Promise<SyncedLyric[] | null> {
@ -67,7 +67,7 @@ async function processLyrics(
})
);
if (processedLyricsResp[0][1] === null) return null;
if (processedLyricsResp.every(mapping => mapping[1] === null)) return null;
return lyrics.map(lyric => ({
...lyric,
@ -75,13 +75,12 @@ async function processLyrics(
}));
}
async function translateLyrics(lyrics: LyricsData["lyricsVersions"][Provider]): Promise<SyncedLyric[] | null> {
const language = settings.store.TranslateTo;
return processLyrics(lyrics, language, false);
async function translateLyrics(lyrics: SyncedLyric[]) {
return await processLyrics(lyrics, settings.store.TranslateTo, false);
}
async function romanizeLyrics(lyrics: LyricsData["lyricsVersions"][Provider]): Promise<SyncedLyric[] | null> {
return processLyrics(lyrics, "", true);
async function romanizeLyrics(lyrics: SyncedLyric[]) {
return await processLyrics(lyrics, "", true);
}
export const lyricsAlternativeFetchers = {

View file

@ -243,4 +243,4 @@
.eq-tidal-lyrics-word-active {
transform: none;
}
}
}