Merge Controls & Lyrics
MusicControls: Styles
SpotifyControls: 👋
This commit is contained in:
parent
228fdc019b
commit
9b948728d6
42 changed files with 346 additions and 1712 deletions
|
|
@ -6,6 +6,15 @@
|
|||
transition-property: height;
|
||||
}
|
||||
|
||||
.eq-tdl-button-row {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: 0.2s;
|
||||
transition-property: height;
|
||||
}
|
||||
|
||||
#eq-tdl-player:hover .eq-tdl-button-row,
|
||||
#vc-spotify-player:hover .vc-spotify-button-row {
|
||||
opacity: 1;
|
||||
height: 32px;
|
||||
|
|
@ -13,4 +22,4 @@
|
|||
|
||||
/* only transition opacity on show to prevent clipping */
|
||||
transition-property: height, opacity;
|
||||
}
|
||||
}
|
||||
|
|
@ -16,51 +16,34 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { Settings } from "@api/Settings";
|
||||
import { disableStyle, enableStyle } from "@api/Styles";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Devs, EquicordDevs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
|
||||
import hoverOnlyStyle from "./hoverOnly.css?managed";
|
||||
import { Player } from "./PlayerComponent";
|
||||
|
||||
function toggleHoverControls(value: boolean) {
|
||||
(value ? enableStyle : disableStyle)(hoverOnlyStyle);
|
||||
}
|
||||
import { settings, toggleHoverControls } from "./settings";
|
||||
import { migrateOldLyrics } from "./spotify/lyrics/api";
|
||||
import { SpotifyLyrics } from "./spotify/lyrics/components/lyrics";
|
||||
import { SpotifyPlayer } from "./spotify/PlayerComponent";
|
||||
import { TidalLyrics } from "./tidal/lyrics/components/lyrics";
|
||||
import { TidalPlayer } from "./tidal/TidalPlayer";
|
||||
|
||||
export default definePlugin({
|
||||
name: "SpotifyControls",
|
||||
description: "Adds a Spotify player above the account panel",
|
||||
authors: [Devs.Ven, Devs.afn, Devs.KraXen72, Devs.Av32000, Devs.nin0dev],
|
||||
options: {
|
||||
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
|
||||
}
|
||||
},
|
||||
name: "MusicControls",
|
||||
description: "Music Controls and Lyrics for multiple services ",
|
||||
authors: [EquicordDevs.thororen, EquicordDevs.vmohammad, Devs.Joona],
|
||||
settings,
|
||||
patches: [
|
||||
{
|
||||
// HEY THOR UPDATE YOUTUBE MUSIC CONTROLS IF THIS SHIT CHANGES TY :)
|
||||
find: "this.isCopiedStreakGodlike",
|
||||
replacement: {
|
||||
// react.jsx)(AccountPanel, { ..., showTaglessAccountPanel: blah })
|
||||
match: /(?<=\i\.jsxs?\)\()(\i),{(?=[^}]*?userTag:\i,hidePrivateData:)/,
|
||||
// react.jsx(WrapperComponent, { VencordOriginal: AccountPanel, ...
|
||||
replace: "$self.PanelWrapper,{VencordOriginal:$1,"
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
find: ".PLAYER_DEVICES",
|
||||
|
|
@ -92,24 +75,34 @@ export default definePlugin({
|
|||
},
|
||||
],
|
||||
|
||||
start: () => toggleHoverControls(Settings.plugins.SpotifyControls.hoverControls),
|
||||
|
||||
PanelWrapper({ VencordOriginal, ...props }) {
|
||||
const { showTidalControls, showTidalLyrics, showSpotifyLyrics, showSpotifyControls, LyricsPosition } = settings.store;
|
||||
return (
|
||||
<>
|
||||
<ErrorBoundary
|
||||
fallback={() => (
|
||||
<div className="vc-spotify-fallback">
|
||||
<p>Failed to render Spotify Modal :(</p>
|
||||
<p >Check the console for errors</p>
|
||||
<div className="vc-tidal-fallback">
|
||||
<p>Failed to render Modal :(</p>
|
||||
<p>Check the console for errors</p>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Player />
|
||||
{showTidalLyrics && LyricsPosition === "above" && <TidalLyrics />}
|
||||
{showTidalControls && <TidalPlayer />}
|
||||
{showTidalLyrics && LyricsPosition === "below" && <TidalLyrics />}
|
||||
{showSpotifyLyrics && LyricsPosition === "above" && <SpotifyLyrics />}
|
||||
{showSpotifyControls && <SpotifyPlayer />}
|
||||
{showSpotifyLyrics && LyricsPosition === "below" && <SpotifyLyrics />}
|
||||
</ErrorBoundary>
|
||||
|
||||
<VencordOriginal {...props} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async start() {
|
||||
await migrateOldLyrics();
|
||||
toggleHoverControls(Settings.plugins.MusicControls.hoverControls);
|
||||
},
|
||||
});
|
||||
187
src/equicordplugins/musicControls/settings.tsx
Normal file
187
src/equicordplugins/musicControls/settings.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { disableStyle, enableStyle } from "@api/Styles";
|
||||
import { useAwaiter } from "@utils/react";
|
||||
import { makeRange, OptionType } from "@utils/types";
|
||||
import { Button, Forms, MaskedLink, showToast, Text, Toasts, useMemo } from "@webpack/common";
|
||||
|
||||
import hoverOnlyStyle from "./hoverOnly.css?managed";
|
||||
import { clearLyricsCache, getLyricsCount } from "./spotify/lyrics/api";
|
||||
import { useLyrics } from "./spotify/lyrics/components/util";
|
||||
import { Provider } from "./spotify/lyrics/providers/types";
|
||||
|
||||
const sliderOptions = {
|
||||
markers: makeRange(-2500, 2500, 250),
|
||||
stickToMarkers: true,
|
||||
};
|
||||
|
||||
export function toggleHoverControls(value: boolean) {
|
||||
(value ? enableStyle : disableStyle)(hoverOnlyStyle);
|
||||
}
|
||||
|
||||
function Details() {
|
||||
const { lyricsInfo } = useLyrics();
|
||||
|
||||
const [count, error, loading] = useAwaiter(
|
||||
useMemo(() => getLyricsCount, []),
|
||||
{
|
||||
onError: () => console.error("Failed to get lyrics count"),
|
||||
fallbackValue: null,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>Current lyrics provider: {lyricsInfo?.useLyric || "None"}</Text>
|
||||
{loading ? <Text>Loading lyrics count...</Text> : error ? <Text>Failed to get lyrics count</Text> : <Text>Lyrics count: {count}</Text>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function InstallInstructions() {
|
||||
return (
|
||||
<Forms.FormSection>
|
||||
<Forms.FormTitle tag="h3">How to install</Forms.FormTitle>
|
||||
<Forms.FormText>
|
||||
Install <MaskedLink href="https://github.com/Inrixia/TidaLuna#installation">TidaLuna</MaskedLink> from here, then go to TidalLuna settings → Plugin stores → Install <code>@vmohammad/api</code>
|
||||
</Forms.FormText>
|
||||
</Forms.FormSection>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
default: true,
|
||||
},
|
||||
LyricsPosition: {
|
||||
description: "Position of the lyrics",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ value: "above", label: "Above Player(s)" },
|
||||
{ value: "below", label: "Below Player(s)", default: true },
|
||||
],
|
||||
},
|
||||
LyricsProvider: {
|
||||
description: "Where lyrics are fetched from",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ value: Provider.Spotify, label: "Spotify (Musixmatch)", default: true },
|
||||
{ value: Provider.Lrclib, label: "LRCLIB" },
|
||||
],
|
||||
},
|
||||
LyricsConversion: {
|
||||
description: "Automatically translate or romanize lyrics",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ value: Provider.None, label: "None", default: true },
|
||||
{ value: Provider.Translated, label: "Translate" },
|
||||
{ value: Provider.Romanized, label: "Romanize" },
|
||||
]
|
||||
},
|
||||
FallbackProvider: {
|
||||
description: "When a lyrics provider fails, try other providers",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
ShowFailedToasts: {
|
||||
description: "Hide toasts when lyrics fail to fetch",
|
||||
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,
|
||||
default: 0,
|
||||
...sliderOptions
|
||||
},
|
||||
SyncMode: {
|
||||
description: "Lyrics sync mode",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ value: "line", label: "Line" },
|
||||
{ value: "word", label: "Word", default: true },
|
||||
{ value: "character", label: "Character" },
|
||||
],
|
||||
default: "word",
|
||||
},
|
||||
PurgeLyricsCache: {
|
||||
description: "Purge the lyrics cache",
|
||||
type: OptionType.COMPONENT,
|
||||
component: () => (
|
||||
<Button
|
||||
color={Button.Colors.RED}
|
||||
onClick={() => {
|
||||
clearLyricsCache();
|
||||
showToast("Lyrics cache purged", Toasts.Type.SUCCESS);
|
||||
}}
|
||||
>
|
||||
Purge Cache
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
});
|
||||
|
|
@ -134,7 +134,7 @@ function Controls() {
|
|||
<Shuffle />
|
||||
</Button>
|
||||
<Button onClick={() => {
|
||||
Settings.plugins.SpotifyControls.previousButtonRestartsTrack && SpotifyStore.position > 3000 ? SpotifyStore.seek(0) : SpotifyStore.prev();
|
||||
Settings.plugins.MusicControls.previousButtonRestartsTrack && SpotifyStore.position > 3000 ? SpotifyStore.seek(0) : SpotifyStore.prev();
|
||||
}}>
|
||||
<SkipPrev />
|
||||
</Button>
|
||||
|
|
@ -347,7 +347,7 @@ function Info({ track }: { track: Track; }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function Player() {
|
||||
export function SpotifyPlayer() {
|
||||
const track = useStateFromStores(
|
||||
[SpotifyStore],
|
||||
() => SpotifyStore.track,
|
||||
|
|
@ -89,7 +89,7 @@ export const SpotifyStore = proxyLazyWebpack(() => {
|
|||
public isSettingPosition = false;
|
||||
|
||||
public openExternal(path: string) {
|
||||
const url = Settings.plugins.SpotifyControls.useSpotifyUris || Vencord.Plugins.isPluginEnabled("OpenInApp")
|
||||
const url = Settings.plugins.MusicControls.useSpotifyUris || Vencord.Plugins.isPluginEnabled("OpenInApp")
|
||||
? "spotify:" + path.replaceAll("/", (_, idx) => idx === 0 ? "" : ":")
|
||||
: "https://open.spotify.com" + path;
|
||||
|
||||
|
|
@ -5,13 +5,12 @@
|
|||
*/
|
||||
|
||||
import { DataStore } from "@api/index";
|
||||
import { Track } from "plugins/spotifyControls/SpotifyStore";
|
||||
|
||||
import { settings } from "../../settings";
|
||||
import { Track } from "../SpotifyStore";
|
||||
import { getLyricsLrclib } from "./providers/lrclibAPI";
|
||||
import { getLyricsSpotify } from "./providers/SpotifyAPI";
|
||||
import { LyricsData, Provider, SyncedLyric } from "./providers/types";
|
||||
import settings from "./settings";
|
||||
|
||||
|
||||
const LyricsCacheKey = "SpotifyLyricsCacheNew";
|
||||
|
||||
|
|
@ -6,10 +6,10 @@
|
|||
|
||||
import { openModal } from "@utils/modal";
|
||||
import { ContextMenuApi, React, Text, TooltipContainer, useEffect, useState, useStateFromStores } from "@webpack/common";
|
||||
import { SpotifyStore } from "plugins/spotifyControls/SpotifyStore";
|
||||
|
||||
import { settings } from "../../../settings";
|
||||
import { SpotifyStore } from "../../SpotifyStore";
|
||||
import { SpotifyLrcStore } from "../providers/store";
|
||||
import settings from "../settings";
|
||||
import { LyricsContextMenu } from "./ctxMenu";
|
||||
import { LyricsModal } from "./modal";
|
||||
import { cl, NoteSvg, useLyrics } from "./util";
|
||||
|
|
@ -63,7 +63,7 @@ function LyricsDisplay({ scroll = true }: { scroll?: boolean; }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function Lyrics({ scroll = true }: { scroll?: boolean; } = {}) {
|
||||
export function SpotifyLyrics({ scroll = true }: { scroll?: boolean; } = {}) {
|
||||
SpotifyLrcStore.init();
|
||||
const track = useStateFromStores(
|
||||
[SpotifyStore],
|
||||
|
|
@ -7,8 +7,8 @@
|
|||
import { openImageModal } from "@utils/discord";
|
||||
import { ModalContent, ModalHeader, ModalProps, ModalRoot } from "@utils/modal";
|
||||
import { React, Text } from "@webpack/common";
|
||||
import { SpotifyStore, Track } from "plugins/spotifyControls/SpotifyStore";
|
||||
|
||||
import { SpotifyStore, Track } from "../../SpotifyStore";
|
||||
import { cl, NoteSvg, scrollClasses, useLyrics } from "./util";
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
|
|
@ -7,12 +7,11 @@
|
|||
import { classNameFactory } from "@api/Styles";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { React, useEffect, useState, useStateFromStores } from "@webpack/common";
|
||||
import { SpotifyStore } from "plugins/spotifyControls/SpotifyStore";
|
||||
|
||||
import { settings } from "../../../settings";
|
||||
import { SpotifyStore } from "../../SpotifyStore";
|
||||
import { SpotifyLrcStore } from "../providers/store";
|
||||
import { SyncedLyric } from "../providers/types";
|
||||
import settings from "../settings";
|
||||
|
||||
export const scrollClasses = findByPropsLazy("auto", "customTheme");
|
||||
|
||||
export const cl = classNameFactory("vc-spotify-lyrics-");
|
||||
|
|
@ -4,8 +4,7 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Track } from "plugins/spotifyControls/SpotifyStore";
|
||||
|
||||
import { Track } from "../../../SpotifyStore";
|
||||
import { LyricsData, Provider } from "../types";
|
||||
|
||||
const baseUrlLrclib = "https://lrclib.net/api/get";
|
||||
|
|
@ -7,10 +7,10 @@
|
|||
import { showNotification } from "@api/Notifications";
|
||||
import { proxyLazyWebpack } from "@webpack";
|
||||
import { Flux, FluxDispatcher } from "@webpack/common";
|
||||
import { SpotifyStore, type Track } from "plugins/spotifyControls/SpotifyStore";
|
||||
|
||||
import { settings } from "../../../settings";
|
||||
import { SpotifyStore, type Track } from "../../SpotifyStore";
|
||||
import { getLyrics, lyricFetchers, providers, updateLyrics } from "../api";
|
||||
import settings from "../settings";
|
||||
import { lyricsAlternativeFetchers } from "./translator";
|
||||
import { LyricsData, Provider } from "./types";
|
||||
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import settings from "../../settings";
|
||||
import settings from "../../../..";
|
||||
import { LyricsData, Provider, SyncedLyric } from "../types";
|
||||
|
||||
// stolen from src\plugins\translate\utils.ts
|
||||
|
|
@ -1,4 +1,82 @@
|
|||
/* Rework: polish container and sync mode visuals; modernize color notation; fix selector order; merge reduced-motion rules */
|
||||
/* stylelint-disable rule-empty-line-before */
|
||||
.vc-spotify-lyrics {
|
||||
padding: 0.375rem 0.5rem;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
max-height: 60px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vc-spotify-lyrics-music-note {
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
animation: side-to-side 2s ease-in-out infinite;
|
||||
color: var(--text-muted);
|
||||
height: 18px;
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
.vc-spotify-lyrics-next,
|
||||
.vc-spotify-lyrics-prev {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1%;
|
||||
}
|
||||
|
||||
@keyframes side-to-side {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.vc-spotify-lyrics-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vc-spotify-lyrics-album-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin-right: 5%;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vc-spotify-lyrics-modal-line-current,
|
||||
.vc-spotify-lyrics-modal-line {
|
||||
margin: 4px 0;
|
||||
padding: 4px 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vc-spotify-lyrics-modal-line-current {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.vc-spotify-lyrics-modal-note {
|
||||
height: 1lh;
|
||||
}
|
||||
|
||||
.vc-spotify-lyrics-modal-timestamp {
|
||||
color: var(--text-muted);
|
||||
margin-right: 0.5em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vc-spotify-lyrics-modal-timestamp:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.vc-spotify-lyrics-modal-no-lyrics {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.eq-tidal-lyrics {
|
||||
padding: 0.375rem 0.5rem;
|
||||
|
|
@ -95,7 +173,6 @@
|
|||
color: var(--brand-experiment);
|
||||
font-weight: 600;
|
||||
text-shadow: 0 0 .25em currentcolor;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.eq-tidal-lyrics-word {
|
||||
|
|
@ -118,7 +195,6 @@
|
|||
background: none;
|
||||
box-shadow: none;
|
||||
text-shadow: 0 0 .25em currentcolor;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.eq-tidal-lyrics-current,
|
||||
|
|
@ -154,6 +230,7 @@
|
|||
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
.eq-tidal-lyrics-current,
|
||||
.eq-tidal-lyrics-next,
|
||||
.eq-tidal-lyrics-prev,
|
||||
|
|
@ -167,4 +244,4 @@
|
|||
.eq-tidal-lyrics-word-active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,13 +6,14 @@
|
|||
|
||||
import "./tidalStyles.css";
|
||||
|
||||
import { Settings } from "@api/Settings";
|
||||
import { ImageIcon, LinkIcon, OpenExternalIcon } from "@components/Icons";
|
||||
import { SeekBar } from "@plugins/spotifyControls/SeekBar";
|
||||
import { debounce } from "@shared/debounce";
|
||||
import { openImageModal } from "@utils/discord";
|
||||
import { classes, copyWithToast } from "@utils/misc";
|
||||
import { ContextMenuApi, Flex, FluxDispatcher, Forms, Menu, React, useEffect, useState, useStateFromStores } from "@webpack/common";
|
||||
|
||||
import { SeekBar } from "../spotify/SeekBar";
|
||||
import { type PlayerState, type Repeat, TidalStore } from "./TidalStore";
|
||||
|
||||
const cl = (className: string) => `eq-tdl-${className}`;
|
||||
|
|
@ -120,7 +121,10 @@ function Controls() {
|
|||
>
|
||||
<Shuffle />
|
||||
</Button>
|
||||
<Button onClick={() => TidalStore.previous()}>
|
||||
<Button onClick={() => {
|
||||
Settings.plugins.MusicControls.previousButtonRestartsTrack && TidalStore.position > 3000 ? TidalStore.seek(0) : TidalStore.previous();
|
||||
}
|
||||
}>
|
||||
<SkipPrev />
|
||||
</Button>
|
||||
<Button onClick={() => TidalStore.setPlaying(!isPlaying)}>
|
||||
|
|
@ -4,22 +4,18 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Track } from "equicordplugins/tidalControls/TidalStore";
|
||||
|
||||
import settings from "./settings";
|
||||
import { settings } from "../../settings";
|
||||
import { Track } from "../TidalStore";
|
||||
import { EnhancedLyric } from "./types";
|
||||
|
||||
|
||||
|
||||
|
||||
export async function getLyrics(track: Track | null, url?: string): Promise<EnhancedLyric[] | null> {
|
||||
if (!track) return null;
|
||||
if (!track.id) return null;
|
||||
const { CustomUrl } = settings.store;
|
||||
const res = await fetch((url ?? CustomUrl) + "lyrics?tidal_id=" + track.id);
|
||||
const defaultUrl = settings.def.CustomUrl.default;
|
||||
const { TidalLyricFetch } = settings.store;
|
||||
const res = await fetch((url ?? TidalLyricFetch) + "lyrics?tidal_id=" + track.id);
|
||||
const defaultUrl = settings.def.TidalLyricFetch.default;
|
||||
if (!res.ok) {
|
||||
if (CustomUrl !== defaultUrl) {
|
||||
if (TidalLyricFetch !== defaultUrl) {
|
||||
return getLyrics(track, defaultUrl);
|
||||
}
|
||||
console.error("Failed to fetch lyrics", res.status, res.statusText);
|
||||
|
|
@ -4,12 +4,12 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { TidalStore } from "@equicordplugins/tidalControls/TidalStore";
|
||||
import { openModal } from "@utils/modal";
|
||||
import { ContextMenuApi, Text, TooltipContainer, useEffect, useState, useStateFromStores } from "@webpack/common";
|
||||
|
||||
import { settings } from "../../../settings";
|
||||
import { TidalStore } from "../../TidalStore";
|
||||
import { TidalLrcStore } from "../providers/store";
|
||||
import settings from "../settings";
|
||||
import { LyricsContextMenu } from "./ctxMenu";
|
||||
import { LyricsModal } from "./modal";
|
||||
import { cl, NoteSvg, useLyrics } from "./util";
|
||||
|
|
@ -106,7 +106,7 @@ function LyricsDisplay({ scroll = true }: { scroll?: boolean; }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function Lyrics({ scroll = true }: { scroll?: boolean; } = {}) {
|
||||
export function TidalLyrics({ scroll = true }: { scroll?: boolean; } = {}) {
|
||||
TidalLrcStore.init();
|
||||
const track = useStateFromStores(
|
||||
[TidalStore],
|
||||
|
|
@ -4,11 +4,11 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { TidalStore, Track as TidalTrack } from "@equicordplugins/tidalControls/TidalStore";
|
||||
import { ModalContent, ModalHeader, ModalProps, ModalRoot } from "@utils/modal";
|
||||
import { Text } from "@webpack/common";
|
||||
|
||||
import settings from "../settings";
|
||||
import { settings } from "../../../settings";
|
||||
import { TidalStore, Track } from "../../TidalStore";
|
||||
import { cl, NoteSvg, scrollClasses, useLyrics } from "./util";
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
|
|
@ -17,7 +17,7 @@ const formatTime = (time: number) => {
|
|||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
function ModalHeaderContent({ track }: { track: TidalTrack; }) {
|
||||
function ModalHeaderContent({ track }: { track: Track; }) {
|
||||
return (
|
||||
<ModalHeader>
|
||||
<div className={cl("header-content")}>
|
||||
|
|
@ -5,12 +5,12 @@
|
|||
*/
|
||||
|
||||
import { classNameFactory } from "@api/Styles";
|
||||
import { TidalStore } from "@equicordplugins/tidalControls/TidalStore";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { React, useEffect, useState, useStateFromStores } from "@webpack/common";
|
||||
|
||||
import { settings } from "../../../settings";
|
||||
import { TidalStore } from "../../TidalStore";
|
||||
import { TidalLrcStore } from "../providers/store";
|
||||
import settings from "../settings";
|
||||
import { EnhancedLyric } from "../types";
|
||||
|
||||
export const scrollClasses = findByPropsLazy("auto", "customTheme");
|
||||
|
|
@ -5,12 +5,12 @@
|
|||
*/
|
||||
|
||||
import { showNotification } from "@api/Notifications";
|
||||
import { TidalStore } from "@equicordplugins/tidalControls/TidalStore";
|
||||
import { proxyLazyWebpack } from "@webpack";
|
||||
import { Flux, FluxDispatcher } from "@webpack/common";
|
||||
|
||||
import { settings } from "../../../settings";
|
||||
import { TidalStore } from "../../TidalStore";
|
||||
import { getLyrics } from "../api";
|
||||
import settings from "../settings";
|
||||
import { EnhancedLyric } from "../types";
|
||||
|
||||
function showNotif(title: string, body: string) {
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { Settings } from "@api/Settings";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
import { Player } from "plugins/spotifyControls/PlayerComponent";
|
||||
|
||||
import { migrateOldLyrics } from "./api";
|
||||
import { Lyrics } from "./components/lyrics";
|
||||
import settings from "./settings";
|
||||
|
||||
|
||||
export default definePlugin({
|
||||
name: "SpotifyLyrics",
|
||||
authors: [Devs.Joona],
|
||||
description: "Adds lyrics to SpotifyControls",
|
||||
dependencies: ["SpotifyControls"],
|
||||
patches: [
|
||||
{
|
||||
find: "this.isCopiedStreakGodlike",
|
||||
replacement: {
|
||||
match: /Vencord\.Plugins\.plugins\["SpotifyControls"\]\.PanelWrapper/,
|
||||
replace: "$self.FakePanelWrapper",
|
||||
},
|
||||
predicate: () => Settings.plugins.SpotifyControls.enabled,
|
||||
noWarn: true,
|
||||
},
|
||||
],
|
||||
FakePanelWrapper({ VencordOriginal, ...props }) {
|
||||
const { LyricsPosition } = settings.use(["LyricsPosition"]);
|
||||
return (
|
||||
<>
|
||||
<ErrorBoundary
|
||||
fallback={() => (
|
||||
<div className="vc-spotify-fallback">
|
||||
<p>Failed to render Spotify Lyrics Modal :(</p>
|
||||
<p>Check the console for errors</p>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{LyricsPosition === "above" && <Lyrics />}
|
||||
<Player />
|
||||
{LyricsPosition === "below" && <Lyrics />}
|
||||
</ErrorBoundary>
|
||||
|
||||
<VencordOriginal {...props} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
settings,
|
||||
async start() {
|
||||
await migrateOldLyrics();
|
||||
},
|
||||
});
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { SliderSetting } from "@components/settings/tabs/plugins/components/SliderSetting";
|
||||
import { useAwaiter } from "@utils/react";
|
||||
import { makeRange, OptionType } from "@utils/types";
|
||||
import { Button, showToast, Text, Toasts, useMemo } from "@webpack/common";
|
||||
|
||||
import { clearLyricsCache, getLyricsCount, removeTranslations } from "./api";
|
||||
import { Lyrics } from "./components/lyrics";
|
||||
import { useLyrics } from "./components/util";
|
||||
import languages from "./providers/translator/languages";
|
||||
import { Provider } from "./providers/types";
|
||||
|
||||
const sliderOptions = {
|
||||
markers: makeRange(-2500, 2500, 250),
|
||||
stickToMarkers: true,
|
||||
};
|
||||
|
||||
function Details() {
|
||||
const { lyricsInfo } = useLyrics();
|
||||
|
||||
const [count, error, loading] = useAwaiter(
|
||||
useMemo(() => getLyricsCount, []),
|
||||
{
|
||||
onError: () => console.error("Failed to get lyrics count"),
|
||||
fallbackValue: null,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text>Current lyrics provider: {lyricsInfo?.useLyric || "None"}</Text>
|
||||
{loading ? <Text>Loading lyrics count...</Text> : error ? <Text>Failed to get lyrics count</Text> : <Text>Lyrics count: {count}</Text>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
ShowMusicNoteOnNoLyrics: {
|
||||
description: "Show a music note icon when no lyrics are found",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
LyricsPosition: {
|
||||
description: "Position of the lyrics",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ value: "above", label: "Above SpotifyControls" },
|
||||
{ value: "below", label: "Below SpotifyControls", default: true },
|
||||
],
|
||||
},
|
||||
LyricsProvider: {
|
||||
description: "Where lyrics are fetched from",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ value: Provider.Spotify, label: "Spotify (Musixmatch)", default: true },
|
||||
{ value: Provider.Lrclib, label: "LRCLIB" },
|
||||
],
|
||||
},
|
||||
FallbackProvider: {
|
||||
description: "When a lyrics provider fails, try other providers",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
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,
|
||||
options: [
|
||||
{ value: Provider.None, label: "None", default: true },
|
||||
{ value: Provider.Translated, label: "Translate" },
|
||||
{ value: Provider.Romanized, label: "Romanize" },
|
||||
]
|
||||
},
|
||||
ShowFailedToasts: {
|
||||
description: "Hide toasts when lyrics fail to fetch",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
LyricDelay: {
|
||||
description: "",
|
||||
type: OptionType.SLIDER,
|
||||
default: 0,
|
||||
hidden: true,
|
||||
...sliderOptions
|
||||
},
|
||||
Display: {
|
||||
description: "",
|
||||
type: OptionType.COMPONENT,
|
||||
component: () => (
|
||||
<>
|
||||
<SliderSetting
|
||||
option={{ ...sliderOptions } as any}
|
||||
onChange={v => {
|
||||
settings.store.LyricDelay = v;
|
||||
}}
|
||||
pluginSettings={Vencord.Settings.plugins.SpotifyLyrics}
|
||||
id={"LyricDelay"}
|
||||
/>
|
||||
<Lyrics />
|
||||
</>
|
||||
)
|
||||
},
|
||||
Details: {
|
||||
description: "",
|
||||
type: OptionType.COMPONENT,
|
||||
component: () => <Details />,
|
||||
},
|
||||
PurgeLyricsCache: {
|
||||
description: "Purge the lyrics cache",
|
||||
type: OptionType.COMPONENT,
|
||||
component: () => (
|
||||
<Button
|
||||
color={Button.Colors.RED}
|
||||
onClick={() => {
|
||||
clearLyricsCache();
|
||||
showToast("Lyrics cache purged", Toasts.Type.SUCCESS);
|
||||
}}
|
||||
>
|
||||
Purge Cache
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
TestingCache: {
|
||||
description: "Save songs to a testing cache instead",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
hidden: true,
|
||||
}
|
||||
});
|
||||
|
||||
export default settings;
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
.vc-spotify-lyrics {
|
||||
padding: 0.375rem 0.5rem;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
max-height: 60px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vc-spotify-lyrics-music-note {
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
animation: side-to-side 2s ease-in-out infinite;
|
||||
color: var(--text-muted);
|
||||
height: 18px;
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
.vc-spotify-lyrics-next,
|
||||
.vc-spotify-lyrics-prev {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1%;
|
||||
}
|
||||
|
||||
@keyframes side-to-side {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.vc-spotify-lyrics-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vc-spotify-lyrics-album-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin-right: 5%;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vc-spotify-lyrics-modal-line-current,
|
||||
.vc-spotify-lyrics-modal-line {
|
||||
margin: 4px 0;
|
||||
padding: 4px 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vc-spotify-lyrics-modal-line-current {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.vc-spotify-lyrics-modal-note {
|
||||
height: 1lh;
|
||||
}
|
||||
|
||||
.vc-spotify-lyrics-modal-timestamp {
|
||||
color: var(--text-muted);
|
||||
margin-right: 0.5em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vc-spotify-lyrics-modal-timestamp:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.vc-spotify-lyrics-modal-no-lyrics {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
.eq-tdl-button-row {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: 0.2s;
|
||||
transition-property: height;
|
||||
}
|
||||
|
||||
#eq-tdl-player:hover .eq-tdl-button-row {
|
||||
opacity: 1;
|
||||
height: 32px;
|
||||
pointer-events: auto;
|
||||
|
||||
/* only transition opacity on show to prevent clipping */
|
||||
transition-property: height, opacity;
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
/*
|
||||
* Vencord, a modification for Discord's desktop app
|
||||
* Copyright (c) 2022 Vendicated and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { definePluginSettings, Settings } from "@api/Settings";
|
||||
import { disableStyle, enableStyle } from "@api/Styles";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Lyrics as SpotifyLyrics } from "@equicordplugins/spotifyLyrics/components/lyrics";
|
||||
import { Player as SpotifyPlayer } from "@plugins/spotifyControls/PlayerComponent";
|
||||
import { Player as YTMPlayer } from "@equicordplugins/youtubeMusicControls/PlayerComponent";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Forms, MaskedLink } from "@webpack/common";
|
||||
|
||||
import hoverOnlyStyle from "./hoverOnly.css?managed";
|
||||
import { TidalPlayer } from "./TidalPlayer";
|
||||
|
||||
function toggleHoverControls(value: boolean) {
|
||||
(value ? enableStyle : disableStyle)(hoverOnlyStyle);
|
||||
}
|
||||
|
||||
|
||||
function InstallInstructions() {
|
||||
return (
|
||||
<Forms.FormSection>
|
||||
<Forms.FormTitle tag="h3">How to install</Forms.FormTitle>
|
||||
<Forms.FormText>
|
||||
Install <MaskedLink href="https://github.com/Inrixia/TidaLuna#installation">TidaLuna</MaskedLink> from here, then go to TidalLuna settings → Plugin stores → Install <code>@vmohammad/api</code>
|
||||
</Forms.FormText>
|
||||
</Forms.FormSection>
|
||||
);
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
installYtmWithWebsocket: {
|
||||
type: OptionType.COMPONENT,
|
||||
component: () => <InstallInstructions />
|
||||
},
|
||||
hoverControls: {
|
||||
description: "Show controls on hover",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
onChange: v => toggleHoverControls(v)
|
||||
},
|
||||
// SOON™️™️™️
|
||||
// lyricsProvider: {
|
||||
// description: "Which provider should be used for lyrics",
|
||||
// type: OptionType.SELECT,
|
||||
// options: [
|
||||
// { label: "Tidal", value: "tidal", default: true },
|
||||
// { label: "vMohammad's Synced API", value: "synced" }
|
||||
// ]
|
||||
// }
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
name: "TidalControls",
|
||||
description: "Adds a TIDAL player above the account panel",
|
||||
authors: [EquicordDevs.vmohammad],
|
||||
settings,
|
||||
patches: [
|
||||
{
|
||||
find: "this.isCopiedStreakGodlike",
|
||||
replacement: [
|
||||
{
|
||||
match: /(?<=\i\.jsxs?\)\()(\i),{(?=[^}]*?userTag:\i,hidePrivateData:)/,
|
||||
replace: "$self.PanelWrapper,{VencordOriginal:$1,",
|
||||
predicate: () => !Settings.plugins.SpotifyControls.enabled,
|
||||
noWarn: true,
|
||||
},
|
||||
{
|
||||
match: /Vencord\.Plugins\.plugins\["SpotifyLyrics"\]\.FakePanelWrapper/,
|
||||
replace: "$self.PanelWrapper",
|
||||
predicate: () => Settings.plugins.SpotifyLyrics.enabled,
|
||||
noWarn: true,
|
||||
},
|
||||
{
|
||||
match: /Vencord\.Plugins\.plugins\["SpotifyControls"\]\.PanelWrapper/,
|
||||
replace: "$self.PanelWrapper",
|
||||
predicate: () => Settings.plugins.SpotifyControls.enabled && !Settings.plugins.SpotifyLyrics.enabled,
|
||||
noWarn: true,
|
||||
},
|
||||
{
|
||||
match: /Vencord\.Plugins\.plugins\["YouTubeMusicControls"\]\.FakePanelWrapper/,
|
||||
replace: "$self.PanelWrapper",
|
||||
predicate: () => Settings.plugins.YouTubeMusicControls.enabled,
|
||||
noWarn: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
start: () => toggleHoverControls(Settings.plugins.YouTubeMusicControls.hoverControls),
|
||||
|
||||
PanelWrapper({ VencordOriginal, ...props }) {
|
||||
const showYTMPlayer = Settings.plugins.YouTubeMusicControls.enabled;
|
||||
const showSpotifyControls = Settings.plugins.SpotifyControls.enabled;
|
||||
const showSpotifyLyrics = Settings.plugins.SpotifyLyrics.enabled;
|
||||
const LyricsPosition = showSpotifyLyrics ? Settings.plugins.SpotifyLyrics.LyricsPosition : null;
|
||||
return (
|
||||
<>
|
||||
<ErrorBoundary
|
||||
fallback={() => (
|
||||
<div className="vc-ytmusic-fallback">
|
||||
<p>Failed to render YouTube Music Modal :(</p>
|
||||
<p>Check the console for errors</p>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<TidalPlayer />
|
||||
|
||||
{showYTMPlayer && <YTMPlayer /> }
|
||||
{showSpotifyLyrics && LyricsPosition === "above" && <SpotifyLyrics />}
|
||||
{showSpotifyControls && <SpotifyPlayer />}
|
||||
{showSpotifyLyrics && LyricsPosition === "below" && <SpotifyLyrics />}
|
||||
</ErrorBoundary>
|
||||
|
||||
<VencordOriginal {...props} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { Settings } from "@api/Settings";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Lyrics as SpotifyLyrics } from "@equicordplugins/spotifyLyrics/components/lyrics";
|
||||
import { TidalPlayer } from "@equicordplugins/tidalControls/TidalPlayer";
|
||||
import { Player as SpotifyPlayer } from "@plugins/spotifyControls/PlayerComponent";
|
||||
import { Player as YTMPlayer } from "@equicordplugins/youtubeMusicControls/PlayerComponent";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
|
||||
import { Lyrics } from "./components/lyrics";
|
||||
import settings from "./settings";
|
||||
|
||||
|
||||
export default definePlugin({
|
||||
name: "TidalLyrics",
|
||||
authors: [EquicordDevs.vmohammad],
|
||||
description: "Adds lyrics to TidalControls",
|
||||
dependencies: ["TidalControls"],
|
||||
patches: [
|
||||
{
|
||||
find: "this.isCopiedStreakGodlike",
|
||||
replacement: {
|
||||
match: /Vencord\.Plugins\.plugins\["TidalControls"\]\.PanelWrapper/,
|
||||
replace: "$self.FakePanelWrapper",
|
||||
},
|
||||
predicate: () => Settings.plugins.TidalControls.enabled,
|
||||
noWarn: true,
|
||||
},
|
||||
],
|
||||
FakePanelWrapper({ VencordOriginal, ...props }) {
|
||||
const { LyricsPosition } = settings.use(["LyricsPosition"]);
|
||||
const showYTMPlayer = Settings.plugins.YouTubeMusicControls.enabled;
|
||||
const showSpotifyControls = Settings.plugins.SpotifyControls.enabled;
|
||||
const showSpotifyLyrics = Settings.plugins.SpotifyLyrics.enabled;
|
||||
const SpotifyLyricsPosition = showSpotifyLyrics ? Settings.plugins.SpotifyLyrics.LyricsPosition : null;
|
||||
return (
|
||||
<>
|
||||
<ErrorBoundary
|
||||
fallback={() => (
|
||||
<div className="vc-tidal-fallback">
|
||||
<p>Failed to render Tidal Lyrics Modal :(</p>
|
||||
<p>Check the console for errors</p>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{LyricsPosition === "above" && <Lyrics />}
|
||||
<TidalPlayer />
|
||||
{LyricsPosition === "below" && <Lyrics />}
|
||||
|
||||
{showYTMPlayer && <YTMPlayer /> }
|
||||
{showSpotifyLyrics && SpotifyLyricsPosition === "above" && <SpotifyLyrics />}
|
||||
{showSpotifyControls && <SpotifyPlayer />}
|
||||
{showSpotifyLyrics && SpotifyLyricsPosition === "below" && <SpotifyLyrics />}
|
||||
</ErrorBoundary>
|
||||
|
||||
<VencordOriginal {...props} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
settings,
|
||||
});
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { SliderSetting } from "@components/settings/tabs/plugins/components/SliderSetting";
|
||||
import { makeRange, OptionType } from "@utils/types";
|
||||
import { showToast, Toasts } from "@webpack/common";
|
||||
|
||||
import { Lyrics } from "./components/lyrics";
|
||||
|
||||
const sliderOptions = {
|
||||
markers: makeRange(-2500, 2500, 250),
|
||||
stickToMarkers: true,
|
||||
};
|
||||
|
||||
const settings = definePluginSettings({
|
||||
ShowMusicNoteOnNoLyrics: {
|
||||
description: "Show a music note icon when no lyrics are found",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
LyricsPosition: {
|
||||
description: "Position of the lyrics",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ value: "above", label: "Above TidalControls" },
|
||||
{ value: "below", label: "Below TidalControls", default: true },
|
||||
],
|
||||
},
|
||||
ShowFailedToasts: {
|
||||
description: "Hide toasts when lyrics fail to fetch",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true,
|
||||
},
|
||||
CustomUrl: {
|
||||
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.CustomUrl = value;
|
||||
} else {
|
||||
showToast("Invalid URL format for CustomUrl: " + value, Toasts.Type.FAILURE);
|
||||
settings.store.CustomUrl = "https://api.vmohammad.dev/";
|
||||
}
|
||||
}
|
||||
},
|
||||
LyricDelay: {
|
||||
description: "",
|
||||
type: OptionType.SLIDER,
|
||||
default: 0,
|
||||
hidden: true,
|
||||
...sliderOptions
|
||||
},
|
||||
SyncMode: {
|
||||
description: "Lyrics sync mode",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{ value: "line", label: "Line" },
|
||||
{ value: "word", label: "Word", default: true },
|
||||
{ value: "character", label: "Character" },
|
||||
],
|
||||
default: "word",
|
||||
},
|
||||
Display: {
|
||||
description: "",
|
||||
type: OptionType.COMPONENT,
|
||||
component: () => (
|
||||
<>
|
||||
<SliderSetting
|
||||
option={{ ...sliderOptions } as any}
|
||||
onChange={v => {
|
||||
settings.store.LyricDelay = v;
|
||||
}}
|
||||
pluginSettings={Vencord.Settings.plugins.TidalLyrics}
|
||||
id={"LyricDelay"}
|
||||
/>
|
||||
<Lyrics />
|
||||
</>
|
||||
)
|
||||
},
|
||||
});
|
||||
|
||||
export default settings;
|
||||
|
|
@ -1,369 +0,0 @@
|
|||
/*
|
||||
* Vencord, a modification for Discord's desktop app
|
||||
* Copyright (c) 2022 Vendicated and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import "./ytmStyles.css";
|
||||
|
||||
import { ImageIcon, LinkIcon, OpenExternalIcon } from "@components/Icons";
|
||||
import { SeekBar } from "@plugins/spotifyControls/SeekBar";
|
||||
import { debounce } from "@shared/debounce";
|
||||
import { openImageModal } from "@utils/discord";
|
||||
import { classes, copyWithToast } from "@utils/misc";
|
||||
import { ContextMenuApi, Flex, FluxDispatcher, Forms, Menu, React, useEffect, useState, useStateFromStores } from "@webpack/common";
|
||||
|
||||
import { type PlayerState, type Repeat, YoutubeMusicStore } from "./YtmStore";
|
||||
|
||||
const cl = (className: string) => `vc-ytm-${className}`;
|
||||
|
||||
function msToHuman(ms: number) {
|
||||
const minutes = ms / 1000 / 60;
|
||||
const m = Math.floor(minutes);
|
||||
const s = Math.floor((minutes - m) * 60);
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function Svg(path: string, label: string) {
|
||||
return () => (
|
||||
<svg
|
||||
className={classes(cl("button-icon"), cl(label))}
|
||||
aria-labelledby="title"
|
||||
height="24"
|
||||
width="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-label={label}
|
||||
focusable={false}
|
||||
>
|
||||
<title id="title">{label}</title>
|
||||
<path d={path} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// KraXen's icons :yesyes:
|
||||
// from https://fonts.google.com/icons?icon.style=Rounded&icon.set=Material+Icons
|
||||
// older material icon style, but still really good
|
||||
const PlayButton = Svg("M8 6.82v10.36c0 .79.87 1.27 1.54.84l8.14-5.18c.62-.39.62-1.29 0-1.69L9.54 5.98C8.87 5.55 8 6.03 8 6.82z", "play");
|
||||
const PauseButton = Svg("M8 19c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2s-2 .9-2 2v10c0 1.1.9 2 2 2zm6-12v10c0 1.1.9 2 2 2s2-.9 2-2V7c0-1.1-.9-2-2-2s-2 .9-2 2z", "pause");
|
||||
const SkipPrev = Svg("M7 6c.55 0 1 .45 1 1v10c0 .55-.45 1-1 1s-1-.45-1-1V7c0-.55.45-1 1-1zm3.66 6.82l5.77 4.07c.66.47 1.58-.01 1.58-.82V7.93c0-.81-.91-1.28-1.58-.82l-5.77 4.07c-.57.4-.57 1.24 0 1.64z", "previous");
|
||||
const SkipNext = Svg("M7.58 16.89l5.77-4.07c.56-.4.56-1.24 0-1.63L7.58 7.11C6.91 6.65 6 7.12 6 7.93v8.14c0 .81.91 1.28 1.58.82zM16 7v10c0 .55.45 1 1 1s1-.45 1-1V7c0-.55-.45-1-1-1s-1 .45-1 1z", "next");
|
||||
const Repeat = Svg("M7 7h10v1.79c0 .45.54.67.85.35l2.79-2.79c.2-.2.2-.51 0-.71l-2.79-2.79c-.31-.31-.85-.09-.85.36V5H6c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1s1-.45 1-1V7zm10 10H7v-1.79c0-.45-.54-.67-.85-.35l-2.79 2.79c-.2.2-.2.51 0 .71l2.79 2.79c.31.31.85.09.85-.36V19h11c.55 0 1-.45 1-1v-4c0-.55-.45-1-1-1s-1 .45-1 1v3z", "repeat");
|
||||
const Shuffle = Svg("M10.59 9.17L6.12 4.7c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41l4.46 4.46 1.42-1.4zm4.76-4.32l1.19 1.19L4.7 17.88c-.39.39-.39 1.02 0 1.41.39.39 1.02.39 1.41 0L17.96 7.46l1.19 1.19c.31.31.85.09.85-.36V4.5c0-.28-.22-.5-.5-.5h-3.79c-.45 0-.67.54-.36.85zm-.52 8.56l-1.41 1.41 3.13 3.13-1.2 1.2c-.31.31-.09.85.36.85h3.79c.28 0 .5-.22.5-.5v-3.79c0-.45-.54-.67-.85-.35l-1.19 1.19-3.13-3.14z", "shuffle");
|
||||
|
||||
function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
className={cl("button")}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyContextMenu({ name, path }: { name: string; path: string; }) {
|
||||
const copyId = `ytm-copy-${name}`;
|
||||
const openId = `ytm-open-${name}`;
|
||||
|
||||
return (
|
||||
<Menu.Menu
|
||||
navId={`ytm-${name}-menu`}
|
||||
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
|
||||
aria-label={`YouTube Music ${name} Menu`}
|
||||
>
|
||||
<Menu.MenuItem
|
||||
key={copyId}
|
||||
id={copyId}
|
||||
label={`Copy ${name} Link`}
|
||||
action={() => copyWithToast(`https://music.youtube.com${path}`)}
|
||||
icon={LinkIcon}
|
||||
/>
|
||||
<Menu.MenuItem
|
||||
key={openId}
|
||||
id={openId}
|
||||
label={`Open ${name} in YouTube Music`}
|
||||
action={() => YoutubeMusicStore.openExternal(path)}
|
||||
icon={OpenExternalIcon}
|
||||
/>
|
||||
</Menu.Menu>
|
||||
);
|
||||
}
|
||||
|
||||
function makeContextMenu(name: string, path: string) {
|
||||
return (e: React.MouseEvent<HTMLElement, MouseEvent>) =>
|
||||
ContextMenuApi.openContextMenu(e, () => <CopyContextMenu name={name} path={path} />);
|
||||
}
|
||||
|
||||
function Controls() {
|
||||
const [isPlaying, shuffle, repeat] = useStateFromStores(
|
||||
[YoutubeMusicStore],
|
||||
() => [YoutubeMusicStore.isPlaying, YoutubeMusicStore.shuffle, YoutubeMusicStore.repeat]
|
||||
);
|
||||
|
||||
const [nextRepeat, repeatClassName] = (() => {
|
||||
switch (repeat) {
|
||||
case "NONE": return ["ALL", "repeat-off"] as const;
|
||||
case "ALL": return ["ONE", "repeat-context"] as const;
|
||||
case "ONE": return ["NONE", "repeat-track"] as const;
|
||||
default: throw new Error(`Invalid repeat state ${repeat}`);
|
||||
}
|
||||
})();
|
||||
|
||||
// the 1 is using position absolute so it does not make the button jump around
|
||||
return (
|
||||
<Flex className={cl("button-row")} style={{ gap: 0 }}>
|
||||
<Button
|
||||
className={classes(cl("button"), cl("shuffle"), cl(shuffle ? "shuffle-on" : "shuffle-off"))}
|
||||
onClick={() => YoutubeMusicStore.setShuffle(!shuffle)}
|
||||
>
|
||||
<Shuffle />
|
||||
</Button>
|
||||
<Button onClick={() => YoutubeMusicStore.previous()}>
|
||||
<SkipPrev />
|
||||
</Button>
|
||||
<Button onClick={() => YoutubeMusicStore.setPlaying(!isPlaying)}>
|
||||
{isPlaying ? <PauseButton /> : <PlayButton />}
|
||||
</Button>
|
||||
<Button onClick={() => YoutubeMusicStore.next()}>
|
||||
<SkipNext />
|
||||
</Button>
|
||||
<Button
|
||||
className={classes(cl("button"), cl(repeatClassName))}
|
||||
onClick={() => YoutubeMusicStore.setRepeat(nextRepeat)}
|
||||
>
|
||||
{repeat === "ONE" && <span className={cl("repeat-1")}>1</span>}
|
||||
<Repeat />
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const seek = debounce((v: number) => {
|
||||
YoutubeMusicStore.seek(v);
|
||||
});
|
||||
|
||||
function YtmSeekBar() {
|
||||
const { songDuration, videoId } = YoutubeMusicStore.song ?? { songDuration: 0, videoId: 0 };
|
||||
|
||||
const [storePosition, isPlaying] = useStateFromStores(
|
||||
[YoutubeMusicStore],
|
||||
() => [YoutubeMusicStore.mPosition, YoutubeMusicStore.isPlaying]
|
||||
);
|
||||
|
||||
const [position, setPosition] = useState<number>(storePosition);
|
||||
|
||||
useEffect(() => {
|
||||
setPosition(storePosition);
|
||||
}, [videoId, songDuration, storePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
setPosition(YoutubeMusicStore.position);
|
||||
const interval = setInterval(() => {
|
||||
setPosition(p => p + 1000);
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [storePosition, isPlaying]);
|
||||
|
||||
const onChange = (v: number) => {
|
||||
setPosition(v);
|
||||
seek(v);
|
||||
};
|
||||
|
||||
return (
|
||||
<div id={cl("progress-bar")}>
|
||||
<Forms.FormText
|
||||
variant="text-xs/medium"
|
||||
className={`${cl("progress-time")} ${cl("time-left")}`}
|
||||
aria-label="Progress"
|
||||
>
|
||||
{msToHuman(position)}
|
||||
</Forms.FormText>
|
||||
<SeekBar
|
||||
initialValue={position}
|
||||
minValue={0}
|
||||
maxValue={songDuration * 1000}
|
||||
onValueChange={onChange}
|
||||
asValueChanges={onChange}
|
||||
onValueRender={msToHuman}
|
||||
/>
|
||||
<Forms.FormText
|
||||
variant="text-xs/medium"
|
||||
className={`${cl("progress-time")} ${cl("time-right")}`}
|
||||
aria-label="Total Duration"
|
||||
>
|
||||
{msToHuman(songDuration * 1000)}
|
||||
</Forms.FormText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlbumContextMenu({ track }: { track: PlayerState["song"]; }) {
|
||||
const volume = useStateFromStores([YoutubeMusicStore], () => YoutubeMusicStore.volume);
|
||||
|
||||
return (
|
||||
<Menu.Menu
|
||||
navId="ytm-album-menu"
|
||||
onClose={() => FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })}
|
||||
aria-label="YouTube Music Album Menu"
|
||||
>
|
||||
{/* <Menu.MenuItem
|
||||
key="open-album"
|
||||
id="open-album"
|
||||
label="Open Album"
|
||||
action={() => YoutubeMusicStore.openExternal(`/album/${track?.album}`)}
|
||||
icon={OpenExternalIcon}
|
||||
/> */}
|
||||
<Menu.MenuItem
|
||||
key="view-cover"
|
||||
id="view-cover"
|
||||
label="View Album Cover"
|
||||
// trolley
|
||||
action={() => track?.imageSrc && openImageModal({ url: track.imageSrc })}
|
||||
icon={ImageIcon}
|
||||
/>
|
||||
<Menu.MenuControlItem
|
||||
id="ytm-volume"
|
||||
key="ytm-volume"
|
||||
label="Volume"
|
||||
control={(props, ref) => (
|
||||
<Menu.MenuSliderControl
|
||||
{...props}
|
||||
ref={ref}
|
||||
value={volume}
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
onChange={debounce((v: number) => YoutubeMusicStore.setVolume(v))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Menu.Menu>
|
||||
);
|
||||
}
|
||||
|
||||
function makeLinkProps(name: string, condition: unknown, path: string) {
|
||||
if (!condition) return {};
|
||||
|
||||
return {
|
||||
role: "link",
|
||||
onClick: () => YoutubeMusicStore.openExternal(path),
|
||||
onContextMenu: makeContextMenu(name, path)
|
||||
} satisfies React.HTMLAttributes<HTMLElement>;
|
||||
}
|
||||
|
||||
function Info({ track }: { track: NonNullable<PlayerState["song"]>; }) {
|
||||
const img = track?.imageSrc;
|
||||
|
||||
const [coverExpanded, setCoverExpanded] = useState(false);
|
||||
|
||||
const i = (
|
||||
<>
|
||||
{img && (
|
||||
<img
|
||||
id={cl("album-image")}
|
||||
src={img}
|
||||
alt="Album Image"
|
||||
onClick={() => setCoverExpanded(!coverExpanded)}
|
||||
onContextMenu={e => {
|
||||
ContextMenuApi.openContextMenu(e, () => <AlbumContextMenu track={track} />);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (coverExpanded && img) return (
|
||||
<div id={cl("album-expanded-wrapper")}>
|
||||
{i}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div id={cl("info-wrapper")}>
|
||||
{i}
|
||||
<div id={cl("titles")}>
|
||||
<Forms.FormText
|
||||
variant="text-sm/semibold"
|
||||
id={cl("song-title")}
|
||||
className={cl("ellipoverflow")}
|
||||
title={track?.title}
|
||||
{...makeLinkProps("Song", track?.videoId, `/watch?v=${track?.videoId}`)}
|
||||
>
|
||||
{track?.title}
|
||||
</Forms.FormText>
|
||||
{track.artist && (
|
||||
<Forms.FormText variant="text-sm/normal" className={cl("ellipoverflow")}>
|
||||
by
|
||||
<span className={cl("artist")} style={{ fontSize: "inherit" }} title={track.artist}>
|
||||
{track.artist}
|
||||
</span>
|
||||
</Forms.FormText>
|
||||
)}
|
||||
{track.album && (
|
||||
<Forms.FormText variant="text-sm/normal" className={cl("ellipoverflow")}>
|
||||
on
|
||||
<span
|
||||
id={cl("album-title")}
|
||||
className={cl("album")}
|
||||
style={{ fontSize: "inherit" }}
|
||||
title={track.album}
|
||||
>
|
||||
{track.album}
|
||||
</span>
|
||||
</Forms.FormText>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Player() {
|
||||
const track = useStateFromStores(
|
||||
[YoutubeMusicStore],
|
||||
() => YoutubeMusicStore.song,
|
||||
null,
|
||||
(prev, next) => prev?.videoId ? (prev.videoId === next?.videoId) : prev?.title === next?.title
|
||||
);
|
||||
|
||||
const isPlaying = useStateFromStores([YoutubeMusicStore], () => YoutubeMusicStore.isPlaying);
|
||||
const [shouldHide, setShouldHide] = useState(false);
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
setShouldHide(false);
|
||||
if (!isPlaying) {
|
||||
const timeout = setTimeout(() => setShouldHide(true), 1000 * 60 * 5);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
if (!track || shouldHide) return;
|
||||
|
||||
const exportTrackImageStyle = {
|
||||
"--vc-ytm-track-image": `url(${track?.imageSrc || ""})`,
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<div id={cl("player")} style={exportTrackImageStyle}>
|
||||
<Info track={track} />
|
||||
<YtmSeekBar />
|
||||
<Controls />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
# YouTubeMusicControls
|
||||
|
||||
Adds a YouTube Music player above the account panel.
|
||||
Shows the current playing playing track and adds controls same,
|
||||
basicly the same as SpotifyControls
|
||||
|
||||
# Prerequirements
|
||||
|
||||
This only works with [th-ch's YouTube Music](https://github.com/th-ch/youtube-music)
|
||||
and my plugin [ApiWebsocket Plugin](https://github.com/Johannes7k75/youtube-music/tree/feat/api-websocket) installed (Currently not included in [th-ch's YouTube Music](https://github.com/th-ch/youtube-music))
|
||||
Plugins that have to be enabled
|
||||
- API Server
|
||||
- API Websocket
|
||||
|
||||
# Installation
|
||||
- [git](https://git-scm.com/downloads)
|
||||
- [node.js](https://nodejs.org/en/download) >= 22.12.0
|
||||
- [pnpm](https://pnpm.io/installation)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/th-ch/youtube-music
|
||||
cd .\youtube-music\
|
||||
git remote add johannes https://github.com/Johannes7k75/youtube-music.git
|
||||
git fetch johannes
|
||||
git merge johannes/feat/api-websocket
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm dist:win
|
||||
```
|
||||
|
||||
|
||||
See the [forum thread](https://discord.com/channels/1015060230222131221/1257038407503446176/1257038407503446176) / [Vencord docs](https://docs.vencord.dev/installing/custom-plugins/)
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Logger } from "@utils/Logger";
|
||||
import { proxyLazyWebpack } from "@webpack";
|
||||
import { Flux, FluxDispatcher } from "@webpack/common";
|
||||
import { Settings } from "Vencord";
|
||||
|
||||
enum MediaType {
|
||||
/**
|
||||
* Audio uploaded by the original artist
|
||||
*/
|
||||
Audio = "AUDIO",
|
||||
/**
|
||||
* Official music video uploaded by the original artist
|
||||
*/
|
||||
OriginalMusicVideo = "ORIGINAL_MUSIC_VIDEO",
|
||||
/**
|
||||
* Normal YouTube video uploaded by a user
|
||||
*/
|
||||
UserGeneratedContent = "USER_GENERATED_CONTENT",
|
||||
/**
|
||||
* Podcast episode
|
||||
*/
|
||||
PodcastEpisode = "PODCAST_EPISODE",
|
||||
OtherVideo = "OTHER_VIDEO",
|
||||
}
|
||||
|
||||
interface Song {
|
||||
title: string;
|
||||
artist: string;
|
||||
views: number;
|
||||
uploadDate?: string;
|
||||
imageSrc?: string | null;
|
||||
isPaused?: boolean;
|
||||
songDuration: number;
|
||||
elapsedSeconds?: number;
|
||||
url?: string;
|
||||
album?: string | null;
|
||||
videoId: string;
|
||||
playlistId?: string;
|
||||
mediaType: MediaType;
|
||||
}
|
||||
|
||||
export interface PlayerState {
|
||||
song: Song | null;
|
||||
isPlaying: boolean,
|
||||
position: number,
|
||||
repeat: Repeat,
|
||||
volume: number,
|
||||
}
|
||||
|
||||
export type Repeat = "NONE" | "ONE" | "ALL";
|
||||
|
||||
const logger = new Logger("YoutubeMusicControls");
|
||||
|
||||
type Message = { type: "PLAYER_STATE"; } & PlayerState;
|
||||
|
||||
class YoutubeMusicSocket {
|
||||
public onChange: (e: Message) => void;
|
||||
public ready = false;
|
||||
|
||||
public socket: WebSocket | undefined;
|
||||
|
||||
constructor(onChange: typeof this.onChange) {
|
||||
this.reconnect();
|
||||
this.onChange = onChange;
|
||||
}
|
||||
|
||||
public reconnect() {
|
||||
if (this.ready) return;
|
||||
try {
|
||||
this.initWs();
|
||||
} catch (e) {
|
||||
logger.error("Failed to connect to YouTube Music WebSocket", e);
|
||||
return;
|
||||
}
|
||||
this.ready = true;
|
||||
}
|
||||
|
||||
get routes() {
|
||||
return {
|
||||
"play": () => this.socket?.send(JSON.stringify({ type: "ACTION", action: "play" })),
|
||||
"pause": () => this.socket?.send(JSON.stringify({ type: "ACTION", action: "pause" })),
|
||||
"previous": () => this.socket?.send(JSON.stringify({ type: "ACTION", action: "previous" })),
|
||||
"next": () => this.socket?.send(JSON.stringify({ type: "ACTION", action: "next" })),
|
||||
"seek": (seconds: number) => this.socket?.send(JSON.stringify({ type: "ACTION", action: "seek", data: seconds })),
|
||||
"shuffle": () => this.socket?.send(JSON.stringify({ type: "ACTION", action: "shuffle" })),
|
||||
"setVolume": (percent: number) => this.socket?.send(JSON.stringify({ type: "ACTION", action: "setVolume", data: percent })),
|
||||
"getVolume": () => this.socket?.send(JSON.stringify({ type: "ACTION", action: "getVolume" })),
|
||||
"repeat": (repeat: Repeat) => this.socket?.send(JSON.stringify({ type: "ACTION", action: "repeat", data: repeat })),
|
||||
};
|
||||
}
|
||||
|
||||
private initWs() {
|
||||
const url = Settings.plugins.YouTubeMusicControls.websocketUrl;
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
this.socket = new WebSocket(url);
|
||||
|
||||
this.socket.addEventListener("open", () => {
|
||||
this.ready = true;
|
||||
this.routes.pause();
|
||||
this.routes.play();
|
||||
});
|
||||
|
||||
this.socket.addEventListener("error", e => {
|
||||
// logger.error("YouTube Music Socket Error:", e);
|
||||
if (!this.ready) setTimeout(() => this.reconnect(), 5_000);
|
||||
this.onChange({ type: "PLAYER_STATE", song: null, isPlaying: false, position: 0, repeat: "NONE", volume: 0 });
|
||||
|
||||
});
|
||||
|
||||
this.socket.addEventListener("close", e => {
|
||||
this.ready = false;
|
||||
if (!this.ready) setTimeout(() => this.reconnect(), 10_000);
|
||||
|
||||
this.onChange({ type: "PLAYER_STATE", song: null, isPlaying: false, position: 0, repeat: "NONE", volume: 0 });
|
||||
});
|
||||
|
||||
|
||||
this.socket.addEventListener("message", e => {
|
||||
let message: Message;
|
||||
try {
|
||||
message = JSON.parse(e.data) as Message;
|
||||
|
||||
switch (message.type) {
|
||||
case "PLAYER_STATE":
|
||||
this.onChange(message);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Invalid JSON:", err, `\n${e.data}`);
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const YoutubeMusicStore = proxyLazyWebpack(() => {
|
||||
const { Store } = Flux;
|
||||
|
||||
class YoutubeMusicStore extends Store {
|
||||
public mPosition = 0;
|
||||
public start = 0;
|
||||
|
||||
public song: Song | null = null;
|
||||
public isPlaying = false;
|
||||
public repeat: Repeat = "NONE";
|
||||
public shuffle = false;
|
||||
public volume = 0;
|
||||
|
||||
public socket = new YoutubeMusicSocket((message: Message) => {
|
||||
if (message.song) {
|
||||
store.song = message.song;
|
||||
store.position = message.song.elapsedSeconds ?? 0;
|
||||
}
|
||||
if (message.isPlaying != null) store.isPlaying = message.isPlaying;
|
||||
if (message.position && message.position !== store.position) store.position = message.position ?? 0;
|
||||
if (message.volume) store.volume = message.volume ?? 0;
|
||||
if (message.repeat) store.repeat = message.repeat;
|
||||
|
||||
store.emitChange();
|
||||
});
|
||||
|
||||
public openExternal(path: string) {
|
||||
const videoId = path.match(/watch\?v=([\w-]+)/);
|
||||
|
||||
const url = (Settings.plugins.YouTubeMusicControls.useYoutubeMusicUri || Vencord.Plugins.isPluginEnabled("OpenInApp")) && videoId
|
||||
? encodeURI("youtubemusic://openVideo " + videoId[1])
|
||||
: "https://music.youtube.com" + path;
|
||||
|
||||
VencordNative.native.openExternal(url);
|
||||
}
|
||||
|
||||
set position(p: number) {
|
||||
this.mPosition = p * 1000;
|
||||
this.start = Date.now();
|
||||
}
|
||||
|
||||
get position(): number {
|
||||
let pos = this.mPosition;
|
||||
if (this.isPlaying) {
|
||||
pos += Date.now() - this.start;
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
previous() {
|
||||
if (!this.ensureSocketReady()) return;
|
||||
this.socket.routes.previous();
|
||||
}
|
||||
next() {
|
||||
if (!this.ensureSocketReady()) return;
|
||||
this.socket.routes.next();
|
||||
}
|
||||
setVolume(percent: number) {
|
||||
if (!this.ensureSocketReady()) return;
|
||||
this.socket.routes.setVolume(Math.round(percent));
|
||||
this.volume = percent;
|
||||
this.emitChange();
|
||||
}
|
||||
setPlaying(playing: boolean) {
|
||||
if (!this.ensureSocketReady()) return;
|
||||
this.socket.routes[playing ? "play" : "pause"]();
|
||||
this.isPlaying = playing;
|
||||
}
|
||||
setRepeat(state: Repeat) {
|
||||
if (!this.ensureSocketReady()) return;
|
||||
this.socket.routes.repeat(state);
|
||||
this.repeat = state;
|
||||
this.emitChange();
|
||||
}
|
||||
setShuffle(state: boolean) {
|
||||
if (!this.ensureSocketReady()) return;
|
||||
this.socket.routes.shuffle();
|
||||
this.shuffle = state;
|
||||
this.emitChange();
|
||||
}
|
||||
seek(ms: number) {
|
||||
if (!this.ensureSocketReady()) return;
|
||||
this.socket.routes.seek(Math.round(ms / 1000));
|
||||
}
|
||||
|
||||
public ensureSocketReady(): boolean {
|
||||
if (!this.socket || !this.socket.ready) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const store = new YoutubeMusicStore(FluxDispatcher);
|
||||
|
||||
return store;
|
||||
});
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
.vc-ytm-button-row {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: 0.2s;
|
||||
transition-property: height;
|
||||
}
|
||||
|
||||
#vc-ytm-player:hover .vc-ytm-button-row {
|
||||
opacity: 1;
|
||||
height: 32px;
|
||||
pointer-events: auto;
|
||||
|
||||
/* only transition opacity on show to prevent clipping */
|
||||
transition-property: height, opacity;
|
||||
}
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
/*
|
||||
* Vencord, a modification for Discord's desktop app
|
||||
* Copyright (c) 2022 Vendicated and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { definePluginSettings, Settings } from "@api/Settings";
|
||||
import { disableStyle, enableStyle } from "@api/Styles";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Lyrics } from "@equicordplugins/spotifyLyrics/components/lyrics";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Forms, MaskedLink } from "@webpack/common";
|
||||
import { Player as SpotifyPlayer } from "plugins/spotifyControls/PlayerComponent";
|
||||
|
||||
import hoverOnlyStyle from "./hoverOnly.css?managed";
|
||||
import { Player } from "./PlayerComponent";
|
||||
|
||||
function toggleHoverControls(value: boolean) {
|
||||
(value ? enableStyle : disableStyle)(hoverOnlyStyle);
|
||||
}
|
||||
|
||||
function isUrlValid(value: string) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol !== "" && url.host !== "";
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
function InstallInstructions() {
|
||||
return (
|
||||
<Forms.FormSection>
|
||||
<Forms.FormTitle tag="h3">How to install</Forms.FormTitle>
|
||||
<Forms.FormText>Instructions on how to install YouTube music with websocket plugin</Forms.FormText>
|
||||
|
||||
<Forms.FormText>
|
||||
|
||||
<span>Software needed: <MaskedLink href="https://git-scm.com/downloads">git</MaskedLink>,<MaskedLink href="https://nodejs.org/en/download">node.js</MaskedLink> >= 22.12.0,<MaskedLink href="https://pnpm.io/installation">pnpm</MaskedLink></span>
|
||||
|
||||
<code className="hljs" style={{ userSelect: "text", cursor: "text" }}>
|
||||
git clone https://github.com/th-ch/youtube-music<br />
|
||||
cd .\youtube-music\<br />
|
||||
git remote add johannes https://github.com/Johannes7k75/youtube-music.git<br />
|
||||
git fetch johannes<br />
|
||||
git merge johannes/feat/api-websocket<br />
|
||||
pnpm install --frozen-lockfile<br />
|
||||
pnpm dist:win<br />
|
||||
</code>
|
||||
</Forms.FormText>
|
||||
</Forms.FormSection>
|
||||
);
|
||||
}
|
||||
*/
|
||||
|
||||
function InstallInstructions() {
|
||||
return (
|
||||
<Forms.FormSection>
|
||||
<Forms.FormTitle tag="h3">How to install</Forms.FormTitle>
|
||||
<Forms.FormText>Instructions on how to install YouTube music with websocket plugin</Forms.FormText>
|
||||
|
||||
<Forms.FormText>
|
||||
|
||||
<span>Software needed: <MaskedLink href="https://git-scm.com/downloads">git</MaskedLink>,<MaskedLink href="https://nodejs.org/en/download">node.js</MaskedLink> >= 22.12.0,<MaskedLink href="https://pnpm.io/installation">pnpm</MaskedLink></span>
|
||||
|
||||
<code className="hljs" style={{ userSelect: "text", cursor: "text" }}>
|
||||
git clone https://github.com/thororen1234/youtube-music.git<br />
|
||||
cd .\youtube-music\<br />
|
||||
pnpm install --frozen-lockfile<br />
|
||||
pnpm dist:win<br />
|
||||
</code>
|
||||
</Forms.FormText>
|
||||
</Forms.FormSection>
|
||||
);
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
installYtmWithWebsocket: {
|
||||
type: OptionType.COMPONENT,
|
||||
component: () => <InstallInstructions />
|
||||
},
|
||||
hoverControls: {
|
||||
description: "Show controls on hover",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
onChange: v => toggleHoverControls(v)
|
||||
},
|
||||
websocketUrl: {
|
||||
description: "The websocket url",
|
||||
type: OptionType.STRING,
|
||||
placeholder: "ws://localhost:26539",
|
||||
default: "ws://localhost:26539",
|
||||
isValid: isUrlValid,
|
||||
}
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
name: "YouTubeMusicControls",
|
||||
description: "Adds a YouTube Music player above the account panel, requires `https://github.com/th-ch/youtube-music` with an custom websocket plugin. See Readme of ths Plugin for instructions",
|
||||
authors: [EquicordDevs.Johannes7k75],
|
||||
settings,
|
||||
patches: [
|
||||
{
|
||||
find: "this.isCopiedStreakGodlike",
|
||||
replacement: [
|
||||
{
|
||||
match: /(?<=\i\.jsxs?\)\()(\i),{(?=[^}]*?userTag:\i,hidePrivateData:)/,
|
||||
replace: "$self.PanelWrapper,{VencordOriginal:$1,",
|
||||
predicate: () => !Settings.plugins.SpotifyControls.enabled,
|
||||
noWarn: true,
|
||||
},
|
||||
{
|
||||
match: /Vencord\.Plugins\.plugins\["SpotifyLyrics"\]\.FakePanelWrapper/,
|
||||
replace: "$self.PanelWrapper",
|
||||
predicate: () => Settings.plugins.SpotifyLyrics.enabled,
|
||||
noWarn: true,
|
||||
},
|
||||
{
|
||||
match: /Vencord\.Plugins\.plugins\["SpotifyControls"\]\.PanelWrapper/,
|
||||
replace: "$self.PanelWrapper",
|
||||
predicate: () => Settings.plugins.SpotifyControls.enabled && !Settings.plugins.SpotifyLyrics.enabled,
|
||||
noWarn: true,
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
start: () => toggleHoverControls(Settings.plugins.YouTubeMusicControls.hoverControls),
|
||||
|
||||
PanelWrapper({ VencordOriginal, ...props }) {
|
||||
const showSpotifyControls = Settings.plugins.SpotifyControls.enabled;
|
||||
const showSpotifyLyrics = Settings.plugins.SpotifyLyrics.enabled;
|
||||
const LyricsPosition = showSpotifyLyrics ? Settings.plugins.SpotifyLyrics.LyricsPosition : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ErrorBoundary
|
||||
fallback={() => (
|
||||
<div className="vc-ytmusic-fallback">
|
||||
<p>Failed to render YouTube Music Modal :(</p>
|
||||
<p>Check the console for errors</p>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Player />
|
||||
|
||||
{showSpotifyLyrics && LyricsPosition === "above" && <Lyrics />}
|
||||
{showSpotifyControls && <SpotifyPlayer />}
|
||||
{showSpotifyLyrics && LyricsPosition === "below" && <Lyrics />}
|
||||
</ErrorBoundary>
|
||||
|
||||
<VencordOriginal {...props} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
#vc-ytm-player {
|
||||
padding: 12px;
|
||||
background: var(--bg-overlay-floating, var(--background-base-low, var(--background-base-lower-alt)));
|
||||
margin: 0;
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
|
||||
--vc-ytm-slider-gradient: linear-gradient(to right, #f03 80%, #ff2791 100%);
|
||||
--vc-ytm-slider-grabber: #f03;
|
||||
|
||||
/* so custom themes can easily change it */
|
||||
--vc-ytm-red-90: color-mix(in hsl, var(--vc-ytm-slider-grabber), transparent 90%);
|
||||
--vc-ytm-red-80: color-mix(in hsl, var(--vc-ytm-slider-grabber), transparent 80%);
|
||||
}
|
||||
|
||||
.vc-ytm-song-info-prefix {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vc-ytm-artist,
|
||||
.vc-ytm-album {
|
||||
color: var(--header-primary);
|
||||
}
|
||||
|
||||
.vc-ytm-secondary-song-info {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#vc-ytm-progress-bar {
|
||||
position: relative;
|
||||
color: var(--text-default);
|
||||
width: 100%;
|
||||
margin: 0.5em 0;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
#vc-ytm-progress-bar > [class^="slider"] {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
#vc-ytm-progress-bar > [class^="slider"] [class^="bar"] {
|
||||
height: 3px !important;
|
||||
top: calc(12px - 4px / 2 + var(--bar-offset));
|
||||
}
|
||||
|
||||
#vc-ytm-progress-bar > [class^="slider"] [class^="barFill"] {
|
||||
background-image: var(--vc-ytm-slider-gradient);
|
||||
}
|
||||
|
||||
#vc-ytm-progress-bar > [class^="slider"]:hover [class^="barFill"] {
|
||||
background-image: var(--vc-ytm-slider-gradient);
|
||||
}
|
||||
|
||||
#vc-ytm-progress-bar > [class^="slider"] [class^="grabber"] {
|
||||
background-color: var(--vc-ytm-slider-grabber);
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
margin-top: calc(13px/-2 + var(--bar-offset)/2);
|
||||
margin-left: -0.5px;
|
||||
border: unset;
|
||||
color: var(--interactive-normal);
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
#vc-ytm-progress-bar:hover > [class^="slider"] [class^="grabber"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.vc-ytm-progress-time {
|
||||
margin-top: 8px;
|
||||
font-family: var(--font-code);
|
||||
font-size: 12px;
|
||||
top: 10px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.vc-ytm-button-row {
|
||||
justify-content: center;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.vc-ytm-button {
|
||||
margin: 0 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--interactive-normal);
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vc-ytm-button:hover {
|
||||
color: var(--interactive-hover);
|
||||
background-color: var(--background-modifier-selected);
|
||||
}
|
||||
|
||||
.vc-ytm-button svg {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
[class*="shuffle"] > svg,
|
||||
[class*="repeat"] > svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.vc-ytm-button svg path {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
.vc-ytm-repeat-context,
|
||||
.vc-ytm-repeat-track,
|
||||
.vc-ytm-shuffle-on {
|
||||
background-color: var(--vc-ytm-red-90);
|
||||
}
|
||||
|
||||
.vc-ytm-repeat-context:hover,
|
||||
.vc-ytm-repeat-track:hover,
|
||||
.vc-ytm-shuffle-on:hover {
|
||||
background-color: var(--vc-ytm-red-80);
|
||||
}
|
||||
|
||||
.vc-ytm-repeat-1 {
|
||||
font-size: 70%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
#vc-ytm-info-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 3em;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
#vc-ytm-info-wrapper img {
|
||||
height: 90%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
#vc-ytm-album-expanded-wrapper img {
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
#vc-ytm-titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.2rem;
|
||||
align-items: flex-start;
|
||||
place-content: flex-start center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#vc-ytm-song-title {
|
||||
color: var(--header-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vc-ytm-ellipoverflow {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.vc-ytm-artist[role="link"]:hover,
|
||||
#vc-ytm-album-title[role="link"]:hover,
|
||||
#vc-ytm-song-title[role="link"]:hover {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#vc-ytm-album-image {
|
||||
border-radius: 3px;
|
||||
transition: filter 0.2s;
|
||||
}
|
||||
|
||||
#vc-ytm-album-image:hover {
|
||||
filter: brightness(1.2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#vc-ytm-progress-text {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.vc-ytm-time-left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.vc-ytm-time-right {
|
||||
right: 0;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue