feat(tidalLyrics): add back syncmode

* implement infinite scroll for plugin list instead of pagination (🤢🤢🤮🤮)

* feat(tidalLyrics): add back syncmode

* refactor(settings): reorganize settings
This commit is contained in:
vMohammad 2025-08-10 15:31:56 +03:00 • committed by GitHub
parent a3449f747f
commit c52cc5f911
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 285 additions and 92 deletions

View file

@ -25,13 +25,14 @@ import { classNameFactory } from "@api/Styles";
import { CogWheel, InfoIcon } from "@components/Icons";
import { openPluginModal, SettingsTab } from "@components/settings";
import { AddonCard } from "@components/settings/AddonCard";
import { debounce } from "@shared/debounce";
import { ChangeList } from "@utils/ChangeList";
import { proxyLazy } from "@utils/lazy";
import { Logger } from "@utils/Logger";
import { Margins } from "@utils/margins";
import { classes, isObjectEmpty } from "@utils/misc";
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { useAwaiter } from "@utils/react";
import { useAwaiter, useIntersection } from "@utils/react";
import { Plugin } from "@utils/types";
import { findByPropsLazy } from "@webpack";
import { Alerts, Button, Card, Flex, Forms, lodash, Parser, React, Select, Text, TextInput, Toasts, Tooltip, useMemo } from "@webpack/common";
@ -261,17 +262,13 @@ export default function PluginSettings() {
.sort((a, b) => a.name.localeCompare(b.name)), []);
const [searchValue, setSearchValue] = React.useState({ value: "", status: SearchStatus.ALL });
const [currentPage, setCurrentPage] = React.useState(1);
const pageSize = 36;
const search = searchValue.value.toLowerCase();
const onSearch = (query: string) => {
setSearchValue(prev => ({ ...prev, value: query }));
setCurrentPage(1);
};
const onStatusChange = (status: SearchStatus) => {
setSearchValue(prev => ({ ...prev, status }));
setCurrentPage(1);
};
const pluginFilter = (plugin: typeof Plugins[keyof typeof Plugins]) => {
@ -476,17 +473,22 @@ export default function PluginSettings() {
const totalUserPlugins = totalPlugins.filter(p => PluginMeta[p].userPlugin).length;
const enabledStockPlugins = enabledPlugins.filter(p => !PluginMeta[p].userPlugin).length;
const enabledUserPlugins = enabledPlugins.filter(p => PluginMeta[p].userPlugin).length;
const pluginsToLoad = Math.min(36, plugins.length);
const [visibleCount, setVisibleCount] = React.useState(pluginsToLoad);
const loadMore = React.useCallback(() => {
setVisibleCount(v => Math.min(v + pluginsToLoad, plugins.length));
}, [plugins.length]);
const paginatedPlugins = plugins.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize
);
const dLoadMore = useMemo(() => debounce(loadMore, 100), [loadMore]);
const totalPages = Math.ceil(plugins.length / pageSize);
const [sentinelRef, isSentinelVisible] = useIntersection();
React.useEffect(() => {
if (isSentinelVisible && visibleCount < plugins.length) {
dLoadMore();
}
}, [isSentinelVisible, visibleCount, plugins.length, dLoadMore]);
function goToPage(page: number) {
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
}
const visiblePlugins = plugins.slice(0, visibleCount);
return (
<SettingsTab title="Plugins">
@ -539,30 +541,13 @@ export default function PluginSettings() {
? (
<>
<div className={cl("grid")}>
{paginatedPlugins.length
? paginatedPlugins
{visiblePlugins.length
? visiblePlugins
: <Text variant="text-md/normal">No plugins meet the search criteria.</Text>
}
</div>
{totalPages > 1 && (
<div className={cl("page-buttons")}>
<Button
size={Button.Sizes.SMALL}
disabled={currentPage === 1}
onClick={() => goToPage(currentPage - 1)}
>
Prev
</Button>
<Text>{`Page ${currentPage} of ${totalPages}`}</Text>
<Button
size={Button.Sizes.SMALL}
disabled={currentPage === totalPages}
onClick={() => goToPage(currentPage + 1)}
>
Next
</Button>
</div>
{visibleCount < plugins.length && (
<div ref={sentinelRef} style={{ height: 32 }} />
)}
</>
)

View file

@ -56,46 +56,12 @@ function InstallInstructions() {
}
export const settings = definePluginSettings({
showSpotifyControls: {
description: "Show Spotify Controls",
type: OptionType.BOOLEAN,
default: false,
},
showSpotifyLyrics: {
description: "Show Spotify Lyrics",
type: OptionType.BOOLEAN,
default: false,
},
installTidalWithWS: {
type: OptionType.COMPONENT,
component: () => <InstallInstructions />
},
showTidalControls: {
description: "Show Tidal Player",
type: OptionType.BOOLEAN,
default: false,
},
showTidalLyrics: {
description: "Show Tidal Controls",
type: OptionType.BOOLEAN,
default: false,
},
hoverControls: {
description: "Show controls on hover",
type: OptionType.BOOLEAN,
default: false,
onChange: v => toggleHoverControls(v)
},
useSpotifyUris: {
type: OptionType.BOOLEAN,
description: "Open Spotify URIs instead of Spotify URLs. Will only work if you have Spotify installed and might not work on all platforms",
default: false
},
previousButtonRestartsTrack: {
type: OptionType.BOOLEAN,
description: "Restart currently playing track when pressing the previous button if playtime is >3s",
default: true
},
ShowMusicNoteOnNoLyrics: {
description: "Show a music note icon when no lyrics are found",
type: OptionType.BOOLEAN,
@ -136,23 +102,6 @@ export const settings = definePluginSettings({
type: OptionType.BOOLEAN,
default: true,
},
TidalLyricFetch: {
description: "Custom URL for fetching lyrics",
type: OptionType.STRING,
default: "https://api.vmohammad.dev/",
placeholder: "https://api.vmohammad.dev/",
onChange: (value: string) => {
if (!value.endsWith("/")) {
value += "/";
}
if (URL.canParse(value)) {
settings.store.TidalLyricFetch = value;
} else {
showToast("Invalid URL format for CustomUrl: " + value, Toasts.Type.FAILURE);
settings.store.TidalLyricFetch = "https://api.vmohammad.dev/";
}
}
},
LyricDelay: {
description: "",
type: OptionType.SLIDER,
@ -174,4 +123,82 @@ export const settings = definePluginSettings({
</Button>
),
},
SpotifySectionTitle: {
type: OptionType.COMPONENT,
component: () => (
<Forms.FormSection>
<Forms.FormTitle tag="h3">Spotify</Forms.FormTitle>
</Forms.FormSection>
)
},
showSpotifyControls: {
description: "Show Spotify Controls",
type: OptionType.BOOLEAN,
default: false,
},
showSpotifyLyrics: {
description: "Show Spotify Lyrics",
type: OptionType.BOOLEAN,
default: false,
},
useSpotifyUris: {
type: OptionType.BOOLEAN,
description: "Open Spotify URIs instead of Spotify URLs. Will only work if you have Spotify installed and might not work on all platforms",
default: false
},
previousButtonRestartsTrack: {
type: OptionType.BOOLEAN,
description: "Restart currently playing track when pressing the previous button if playtime is >3s",
default: true
},
TidalSectionTitle: {
type: OptionType.COMPONENT,
component: () => (
<Forms.FormSection>
<Forms.FormTitle tag="h3">Tidal</Forms.FormTitle>
</Forms.FormSection>
)
},
installTidalWithWS: {
type: OptionType.COMPONENT,
component: () => <InstallInstructions />
},
showTidalControls: {
description: "Show Tidal Player",
type: OptionType.BOOLEAN,
default: false,
},
showTidalLyrics: {
description: "Show Tidal Controls",
type: OptionType.BOOLEAN,
default: false,
},
TidalLyricFetch: {
description: "Custom URL for fetching lyrics",
type: OptionType.STRING,
default: "https://api.vmohammad.dev/",
placeholder: "https://api.vmohammad.dev/",
onChange: (value: string) => {
if (!value.endsWith("/")) {
value += "/";
}
if (URL.canParse(value)) {
settings.store.TidalLyricFetch = value;
} else {
showToast("Invalid URL format for CustomUrl: " + value, Toasts.Type.FAILURE);
settings.store.TidalLyricFetch = "https://api.vmohammad.dev/";
}
}
},
TidalSyncMode: {
description: "Lyrics sync mode",
type: OptionType.SELECT,
options: [
{ value: "line", label: "Line", default: true },
{ value: "word", label: "Word" },
{ value: "character", label: "Character" },
],
default: "line",
},
});

View file

@ -158,4 +158,89 @@
.eq-tidal-lyrics-modal-no-lyrics {
text-align: center;
padding: 1rem;
}
}
.eq-tidal-lyrics-char {
color: var(--text-muted);
padding: 0 1px;
display: inline-block;
transition: color .15s ease, opacity .15s ease, transform .15s ease, text-shadow .15s ease;
will-change: transform, color, opacity, text-shadow;
}
.eq-tidal-lyrics-char-active {
color: var(--brand-experiment);
font-weight: 600;
text-shadow: 0 0 .25em currentcolor;
}
.eq-tidal-lyrics-word {
color: var(--text-muted);
border-radius: 4px;
padding: 0 1px;
margin-right: 2px;
border: 1px solid transparent;
display: inline-block;
transition: color .15s ease, opacity .15s ease, background-color .15s ease, box-shadow .15s ease, transform .15s ease;
will-change: transform, color, opacity, background-color, box-shadow;
}
.eq-tidal-lyrics-word:last-child {
margin-right: 0;
}
.eq-tidal-lyrics-word-active {
color: var(--brand-experiment);
background: none;
box-shadow: none;
text-shadow: 0 0 .25em currentcolor;
}
.eq-tidal-lyrics-current,
.eq-tidal-lyrics-next,
.eq-tidal-lyrics-prev {
transition: color .2s ease, opacity .2s ease, transform .2s ease, text-shadow .2s ease;
}
.eq-tidal-lyrics-current {
font-weight: 600;
text-shadow: 0 0 .35em rgb(0 0 0 / 15%);
}
.eq-tidal-lyrics-next {
opacity: .85;
}
.eq-tidal-lyrics-prev {
opacity: .65;
}
.eq-tidal-lyrics-current .eq-tidal-lyrics-char,
.eq-tidal-lyrics-current .eq-tidal-lyrics-word {
color: var(--text-normal);
}
.eq-tidal-lyrics-next .eq-tidal-lyrics-char,
.eq-tidal-lyrics-prev .eq-tidal-lyrics-char,
.eq-tidal-lyrics-next .eq-tidal-lyrics-word,
.eq-tidal-lyrics-prev .eq-tidal-lyrics-word {
color: var(--text-muted);
}
@media (prefers-reduced-motion: reduce) {
.eq-tidal-lyrics-current,
.eq-tidal-lyrics-next,
.eq-tidal-lyrics-prev,
.eq-tidal-lyrics-char,
.eq-tidal-lyrics-word {
transition: none;
text-shadow: none;
}
.eq-tidal-lyrics-char-active,
.eq-tidal-lyrics-word-active {
transform: none;
}
}

View file

@ -149,7 +149,7 @@ export const TidalStore = proxyLazyWebpack(() => {
public repeat: Repeat = 0;
public shuffle = false;
public volume = 100;
public playerElement: HTMLElement | null = null;
private playerElement: HTMLElement | null = null;
public socket = new TidalSocket((message: Message) => {
if (message.type === "update" && message.all && message.fields) {
const apiData = message.fields;

View file

@ -15,11 +15,11 @@ import { LyricsModal } from "./modal";
import { cl, NoteSvg, useLyrics } from "./util";
function LyricsDisplay({ scroll = true }: { scroll?: boolean; }) {
const { ShowMusicNoteOnNoLyrics } = settings.use(["ShowMusicNoteOnNoLyrics"]);
const { lyrics, lyricRefs, currLrcIndex, nextLyric } = useLyrics({ scroll });
const { ShowMusicNoteOnNoLyrics, TidalSyncMode: SyncMode } = settings.use(["ShowMusicNoteOnNoLyrics", "TidalSyncMode"]);
const { lyrics, lyricRefs, currLrcIndex } = useLyrics({ scroll });
const currentLyrics = lyrics || null;
const NoteElement = NoteSvg(cl("music-note"));
const position = useStateFromStores([TidalStore], () => TidalStore.mPosition);
const makeClassName = (index: number): string => {
if (currLrcIndex === null) return "";
const diff = index - currLrcIndex;
@ -51,7 +51,53 @@ function LyricsDisplay({ scroll = true }: { scroll?: boolean; }) {
variant={currLrcIndex === i ? "text-sm/normal" : "text-xs/normal"}
className={makeClassName(i)}
>
{line.text || NoteElement}
{SyncMode === "character" && line.characters && line.characters.length > 0 && line.words && line.words.length > 0 ? (
line.words.map((word, wIdx) => (
<span key={wIdx}>
{word.characters.map((char, j) => {
const charActive = position / 1000 >= char.time && position / 1000 < char.endTime;
return (
<span
key={j}
className={charActive ? "eq-tidal-lyrics-char-active" : "eq-tidal-lyrics-char"}
style={{ opacity: charActive ? 1 : 0.5 }}
>
{char.char}
</span>
);
})}
{wIdx < line.words.length - 1 && <span> </span>}
</span>
))
) : SyncMode === "character" && line.characters && line.characters.length > 0 ? (
line.characters.map((char, j) => {
const charActive = position / 1000 >= char.time && position / 1000 < char.endTime;
return (
<span
key={j}
className={charActive ? "eq-tidal-lyrics-char-active" : "eq-tidal-lyrics-char"}
style={{ opacity: charActive ? 1 : 0.5 }}
>
{char.char}
</span>
);
})
) : SyncMode === "word" && line.words && line.words.length > 0 ? (
line.words.map((word, j) => {
const wordActive = position / 1000 >= word.time && position / 1000 < word.endTime;
return (
<span
key={j}
className={wordActive ? "eq-tidal-lyrics-word-active" : "eq-tidal-lyrics-word"}
style={{ opacity: wordActive ? 1 : 0.5 }}
>
{word.word}
</span>
);
})
) : (
line.text || NoteElement
)}
</Text>
</div>
))}

View file

@ -7,6 +7,7 @@
import { ModalContent, ModalHeader, ModalProps, ModalRoot } from "@utils/modal";
import { Text } from "@webpack/common";
import { settings } from "../../../settings";
import { TidalStore, Track } from "../../TidalStore";
import { cl, NoteSvg, scrollClasses, useLyrics } from "./util";
@ -40,6 +41,8 @@ function ModalHeaderContent({ track }: { track: Track; }) {
export function LyricsModal({ rootProps }: { rootProps: ModalProps; }) {
const { track, lyrics, currLrcIndex } = useLyrics({ scroll: false });
const currentLyrics = lyrics || null;
const position = track ? TidalStore.mPosition : 0;
const { TidalSyncMode: SyncMode } = settings.use(["TidalSyncMode"]);
return (
<ModalRoot {...rootProps}>
@ -58,7 +61,54 @@ export function LyricsModal({ rootProps }: { rootProps: ModalProps; }) {
onClick={() => TidalStore.seek(line.time * 1000)}
>
{formatTime(line.time)}
</span>{line.text || NoteSvg(cl("modal-note"))}
</span>
{SyncMode === "character" && line.characters && line.characters.length > 0 && line.words && line.words.length > 0 ? (
line.words.map((word, wIdx) => (
<span key={wIdx}>
{word.characters.map((char, j) => {
const charActive = position / 1000 >= char.time && position / 1000 < char.endTime;
return (
<span
key={j}
className={charActive ? cl("char-active") : cl("char")}
style={{ opacity: charActive ? 1 : 0.5 }}
>
{char.char}
</span>
);
})}
{wIdx < line.words.length - 1 && <span> </span>}
</span>
))
) : SyncMode === "character" && line.characters && line.characters.length > 0 ? (
line.characters.map((char, j) => {
const charActive = position / 1000 >= char.time && position / 1000 < char.endTime;
return (
<span
key={j}
className={charActive ? cl("char-active") : cl("char")}
style={{ opacity: charActive ? 1 : 0.5 }}
>
{char.char}
</span>
);
})
) : SyncMode === "word" && line.words && line.words.length > 0 ? (
line.words.map((word, j) => {
const wordActive = position / 1000 >= word.time && position / 1000 < word.endTime;
return (
<span
key={j}
className={wordActive ? cl("word-active") : cl("word")}
style={{ opacity: wordActive ? 1 : 0.5, marginRight: 2 }}
>
{word.word}
</span>
);
})
) : (
line.text || NoteSvg(cl("modal-note"))
)}
</Text>
))
) : (