diff --git a/src/equicordplugins/youtubeMusicControls/PlayerComponent.tsx b/src/equicordplugins/youtubeMusicControls/PlayerComponent.tsx new file mode 100644 index 000000000..4bf3a61ee --- /dev/null +++ b/src/equicordplugins/youtubeMusicControls/PlayerComponent.tsx @@ -0,0 +1,369 @@ +/* + * 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 . +*/ + +import "./ytmStyles.css"; + +import { ImageIcon, LinkIcon, OpenExternalIcon } from "@components/Icons"; +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"; +import { SeekBar } from "@plugins/spotifyControls/SeekBar"; + +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 () => ( + + {label} + + + ); +} + +// 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) { + return ( + + {props.children} + + ); +} + +function CopyContextMenu({ name, path }: { name: string; path: string; }) { + const copyId = `ytm-copy-${name}`; + const openId = `ytm-open-${name}`; + + return ( + FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })} + aria-label={`YouTube Music ${name} Menu`} + > + copyWithToast(`https://music.youtube.com${path}`)} + icon={LinkIcon} + /> + YoutubeMusicStore.openExternal(path)} + icon={OpenExternalIcon} + /> + + ); +} + +function makeContextMenu(name: string, path: string) { + return (e: React.MouseEvent) => + ContextMenuApi.openContextMenu(e, () => ); +} + +function Controls() { + const [isPlaying, repeat] = useStateFromStores<[boolean, Repeat]>( + [YoutubeMusicStore], + () => [YoutubeMusicStore.isPlaying, YoutubeMusicStore.repeat] + ); + + const [nextRepeat, repeatClassName] = (() => { + switch (repeat) { + case "NONE": return ["context", "repeat-off"] as const; + case "ALL": return ["track", "repeat-context"] as const; + case "ONE": return ["off", "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 ( + + YoutubeMusicStore.shuffle()} + > + + + YoutubeMusicStore.prev()}> + + + YoutubeMusicStore.setPlaying(!isPlaying)}> + {isPlaying ? : } + + YoutubeMusicStore.next()}> + + + YoutubeMusicStore.switchRepeat()} + style={{ position: "relative" }} + > + {repeat === "ONE" && 1} + + + + ); +} + +const seek = debounce((v: number) => { + YoutubeMusicStore.seek(v); +}); + +function YtmSeekBar() { + const { songDuration } = YoutubeMusicStore.song!; + + const [storePosition, isSettingPosition, isPlaying] = useStateFromStores( + [YoutubeMusicStore], + () => [YoutubeMusicStore.mPosition, YoutubeMusicStore.isSettingPosition, YoutubeMusicStore.isPlaying] + ); + + const [position, setPosition] = useState(storePosition); + + // eslint-disable-next-line consistent-return + useEffect(() => { + if (isPlaying && !isSettingPosition) { + setPosition(YoutubeMusicStore.position); + const interval = setInterval(() => { + setPosition(p => p + 1000); + }, 1000); + + return () => clearInterval(interval); + } + }, [storePosition, isSettingPosition, isPlaying]); + + const onChange = (v: number) => { + if (isSettingPosition) return; + setPosition(v); + seek(v); + }; + + return ( + + + {msToHuman(position)} + + + + {msToHuman(songDuration * 1000)} + + + ); +} + +function AlbumContextMenu({ track }: { track: PlayerState["song"]; }) { + const volume = useStateFromStores([YoutubeMusicStore], () => YoutubeMusicStore.volume); + + return ( + FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" })} + aria-label="YouTube Music Album Menu" + > + {/* YoutubeMusicStore.openExternal(`/album/${track?.album}`)} + icon={OpenExternalIcon} + /> */} + track?.imageSrc && openImageModal({ url: track.imageSrc })} + icon={ImageIcon} + /> + ( + YoutubeMusicStore.setVolume(v))} + /> + )} + /> + + ); +} + +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; +} + +function Info({ track }: { track: NonNullable; }) { + const img = track?.imageSrc; + + const [coverExpanded, setCoverExpanded] = useState(false); + + const i = ( + <> + {img && ( + setCoverExpanded(!coverExpanded)} + onContextMenu={e => { + ContextMenuApi.openContextMenu(e, () => ); + }} + /> + )} + > + ); + + if (coverExpanded && img) return ( + + {i} + + ); + + return ( + + {i} + + + {track?.title} + + {track.artist && ( + + by + + {track.artist} + + + )} + {track.album && ( + + on + + {track.album} + + + )} + + + ); +} + +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 ( + + + + + + ); +} + diff --git a/src/equicordplugins/youtubeMusicControls/README.md b/src/equicordplugins/youtubeMusicControls/README.md new file mode 100644 index 000000000..a26ecc3a7 --- /dev/null +++ b/src/equicordplugins/youtubeMusicControls/README.md @@ -0,0 +1,17 @@ +# 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 + +See the [forum thread](https://discord.com/channels/1015060230222131221/1257038407503446176/1257038407503446176) / [Vencord docs](https://docs.vencord.dev/installing/custom-plugins/) \ No newline at end of file diff --git a/src/equicordplugins/youtubeMusicControls/YtmStore.ts b/src/equicordplugins/youtubeMusicControls/YtmStore.ts new file mode 100644 index 000000000..4c23dcc91 --- /dev/null +++ b/src/equicordplugins/youtubeMusicControls/YtmStore.ts @@ -0,0 +1,293 @@ +/* + * 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 nextRepeat = (repeat: Repeat) => { + switch (repeat) { + case 'NONE': + return 'ALL' as const; + case 'ALL': + return 'ONE' as const; + case 'ONE': + return 'NONE' as const; + } +}; + +const logger = new Logger("YoutubeMusicControls"); + +type Message = { type: "PLAYER_STATE"; } & PlayerState; + +class YoutubemusicSocket { + private PORT = 26539; + public onChange: (e: Message) => void; + private ready = false; + + private 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" })), + "prev": () => this.socket?.send(JSON.stringify({ type: "ACTION", action: "prev" })), + "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", () => { + // logger.info("Connected to YouTube Music WebSocket"); + 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 => { + // logger.info("YouTube Music Socket Disconnected:", e.code, e.reason); + this.ready = false; + if (!this.ready) setTimeout(() => this.reconnect(), 10_000); + // this.tryConnect(); + + 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); + console.log(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; + private start = 0; + + public song: Song | null = null; + public isPlaying = false; + public repeat: Repeat = "NONE"; + public volume = 0; + + public isSettingPosition = false; + + 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.isSettingPosition = false; + 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; + + console.log("Open", url); + + // https://music.youtube.com/watch?v=BSHYPb15W-Y + 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; + } + + prev() { + // this.socket.routes.prev(); + this.req("post", "/api/v1/previous"); + } + next() { + // this.socket.routes.next(); + this.req("post", "/api/v1/next"); + } + setVolume(percent: number) { + // this.socket.routes.setVolume(percent); + console.log(Math.floor(percent)); + this.req("post", "/api/v1/volume", { + headers: { + "Content-Type": "application/json" + }, + body: { + volume: Math.floor(percent) + }, + }); + } + setPlaying(playing: boolean) { + if (playing) { + // YoutubemusicAPI.routes.pause.actions.do(); + this.req("post", "/api/v1/play"); + // this.socket.routes.play(); + } else { + // YoutubemusicAPI.routes.play.actions.do(); + this.req("post", "/api/v1/pause"); + // this.socket.routes.pause(); + } + } + switchRepeat() { + // this.socket.routes.repeat(nextRepeat(this.repeat)); + this.req("post", "/api/v1/switch-repeat", { + headers: { + "Content-Type": "application/json" + }, + body: { + iteration: 1 + } + }); + } + shuffle() { + // this.socket.routes.shuffle(); + this.req("post", "/api/v1/shuffle"); + } + seek(ms: number) { + // this.socket.routes.seek(ms / 1000); + this.req("post", "/api/v1/seek-to", { + headers: { + "Content-Type": "application/json" + }, + body: { + seconds: Math.floor(ms / 1000) + } + }); + } + + private req(method: "post" | "get" | "put", route: string, data: any = {}) { + const apiServerUrl = Settings.plugins.YouTubeMusicControls.apiServerUrl; + if (apiServerUrl === "") return; + console.log(fetch(apiServerUrl + route, { + method, + ...data, + ...(data.body && { body: JSON.stringify(data.body) }) + })); + } + } + + const store = new YoutubeMusicStore(FluxDispatcher); + + logger.info("Youtube Music Controls initialized"); + + return store; +}); + +function restartSocket() { + YoutubeMusicStore.socket.reconnect(); +} \ No newline at end of file diff --git a/src/equicordplugins/youtubeMusicControls/hoverOnly.css b/src/equicordplugins/youtubeMusicControls/hoverOnly.css new file mode 100644 index 000000000..29b14da0b --- /dev/null +++ b/src/equicordplugins/youtubeMusicControls/hoverOnly.css @@ -0,0 +1,16 @@ +.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; +} diff --git a/src/equicordplugins/youtubeMusicControls/index.tsx b/src/equicordplugins/youtubeMusicControls/index.tsx new file mode 100644 index 000000000..0dc22181e --- /dev/null +++ b/src/equicordplugins/youtubeMusicControls/index.tsx @@ -0,0 +1,101 @@ +/* + * 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 . +*/ + +import { definePluginSettings, Settings } from "@api/Settings"; +import { disableStyle, enableStyle } from "@api/Styles"; +import ErrorBoundary from "@components/ErrorBoundary"; +import definePlugin, { OptionType } from "@utils/types"; + +import hoverOnlyStyle from "./hoverOnly.css?managed"; +import { Player } from "./PlayerComponent"; +import { EquicordDevs } from "@utils/constants"; + +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; + } +} + +const settings = definePluginSettings({ + 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, + }, + apiServerUrl: { + description: "The api server url", + type: OptionType.STRING, + placeholder: "http://localhost:26538", + default: "http://localhost:26538", + isValid: isUrlValid, + } +}); + +export default definePlugin({ + name: "YouTubeMusicControls", + description: "Adds a YouTube Music player above the account panel", + authors: [EquicordDevs.Johannes7k75], + settings, + patches: [ + { + 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," + } + }, + ], + + start: () => toggleHoverControls(Settings.plugins.YouTubeMusicControls.hoverControls), + + PanelWrapper({ VencordOriginal, ...props }) { + return ( + <> + ( + + Failed to render YouTube Music Modal :( + Check the console for errors + + )} + > + + + + + > + ); + } +}); diff --git a/src/equicordplugins/youtubeMusicControls/ytmStyles.css b/src/equicordplugins/youtubeMusicControls/ytmStyles.css new file mode 100644 index 000000000..fefe41c5d --- /dev/null +++ b/src/equicordplugins/youtubeMusicControls/ytmStyles.css @@ -0,0 +1,206 @@ +#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); + + /* background-color: var(--interactive-active); */ + 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); +} + +.vc-ytm-button-row { + margin-top: 14px; + justify-content: center; +} + +.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; +} + +.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; +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index d495b6dc9..3d675ec2e 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1120,6 +1120,10 @@ export const EquicordDevs = Object.freeze({ Etorix: { name: "Etorix", id: 94597845868355584n, + }, + Johannes7k75: { + name: "Johannes7k75", + id: 587701169103699994n } } satisfies Record);
Failed to render YouTube Music Modal :(
Check the console for errors