diff --git a/src/equicordplugins/musicControls/hoverOnly.css b/src/equicordplugins/musicControls/hoverOnly.css index 9df7594ae..14f7402f7 100644 --- a/src/equicordplugins/musicControls/hoverOnly.css +++ b/src/equicordplugins/musicControls/hoverOnly.css @@ -14,8 +14,17 @@ transition-property: height; } +.vc-ytm-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 { +#vc-spotify-player:hover .vc-spotify-button-row, +#vc-ytm-player:hover .vc-ytm-button-row { opacity: 1; height: 32px; pointer-events: auto; diff --git a/src/equicordplugins/musicControls/index.tsx b/src/equicordplugins/musicControls/index.tsx index db1801eb2..d76237c33 100644 --- a/src/equicordplugins/musicControls/index.tsx +++ b/src/equicordplugins/musicControls/index.tsx @@ -29,6 +29,7 @@ import { SpotifyLyrics } from "./spotify/lyrics/components/lyrics"; import { SpotifyPlayer } from "./spotify/PlayerComponent"; import { TidalLyrics } from "./tidal/lyrics/components/lyrics"; import { TidalPlayer } from "./tidal/TidalPlayer"; +import { YtmPlayer } from "./youtubeMusic/PlayerComponent"; export default definePlugin({ name: "MusicControls", @@ -45,11 +46,9 @@ export default definePlugin({ "TidalControls", "TidalLyrics", // Youtube - /* Deprecated RN "Youtube", "YoutubeMusic", "YoutubeMusicControls" - */ ], patches: [ @@ -94,7 +93,7 @@ export default definePlugin({ PanelWrapper({ VencordOriginal, ...props }) { - const { showTidalControls, showTidalLyrics, showSpotifyLyrics, showSpotifyControls, LyricsPosition } = settings.store; + const { showTidalControls, showTidalLyrics, showSpotifyLyrics, showSpotifyControls, LyricsPosition, showYoutubeMusicControls } = settings.store; return ( <> } {showSpotifyControls && } {showSpotifyLyrics && LyricsPosition === "below" && } + {showYoutubeMusicControls && } diff --git a/src/equicordplugins/musicControls/settings.tsx b/src/equicordplugins/musicControls/settings.tsx index a3b94de87..f14155ec1 100644 --- a/src/equicordplugins/musicControls/settings.tsx +++ b/src/equicordplugins/musicControls/settings.tsx @@ -189,4 +189,33 @@ export const settings = definePluginSettings({ ], default: "line", }, + + YtmSectionTitle: { + type: OptionType.COMPONENT, + component: () => ( + + Youtube Music + + ) + }, + showYoutubeMusicControls: { + description: "Show Youtube Music Controls", + type: OptionType.BOOLEAN, + default: false + }, + YoutubeMusicApiUrl: { + description: "Custom URL for the Api Server plugin", + type: OptionType.STRING, + default: "http://localhost:26538", + placeholder: "http://localhost:26538", + onChange: (value: string) => { + if (URL.canParse(value)) { + settings.store.YoutubeMusicApiUrl = value; + } else { + showToast("Invalid URL format for Custom Api Server URL: " + value, Toasts.Type.FAILURE); + settings.store.YoutubeMusicApiUrl = settings.def.YoutubeMusicApiUrl.default; + } + } + } + }); diff --git a/src/equicordplugins/musicControls/youtubeMusic/PlayerComponent.tsx b/src/equicordplugins/musicControls/youtubeMusic/PlayerComponent.tsx new file mode 100644 index 000000000..543509273 --- /dev/null +++ b/src/equicordplugins/musicControls/youtubeMusic/PlayerComponent.tsx @@ -0,0 +1,368 @@ +/* + * 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 Song, type Repeat, YoutubeMusicStore } from "./YtmStore"; +import { SeekBar } from "@equicordplugins/musicControls/spotify/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 ( + + ); +} + +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, isShuffled, repeat] = useStateFromStores<[boolean, boolean, Repeat]>( + [YoutubeMusicStore], + () => [YoutubeMusicStore.isPlaying, YoutubeMusicStore.isShuffled, 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 ( + + + + + + + + ); +} + +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); + + useEffect(() => { + setPosition(YoutubeMusicStore.position); + }, [storePosition]); + + const onChange = (v: number) => { + if (isSettingPosition) return; + setPosition(v); + seek(v); + }; + + return ( +
+ + {msToHuman(position)} + + + + {msToHuman(songDuration * 1000)} + +
+ ); +} + +function AlbumContextMenu({ track }: { track: Song; }) { + const [volume, muted] = useStateFromStores([YoutubeMusicStore], () => [YoutubeMusicStore.volume, YoutubeMusicStore.muted]); + + 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))} + /> + )} + /> + YoutubeMusicStore.toggleMute()} + /> + + ); +} + +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 && ( + Album Image 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 YtmPlayer() { + 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/musicControls/youtubeMusic/YtmStore.ts b/src/equicordplugins/musicControls/youtubeMusic/YtmStore.ts new file mode 100644 index 000000000..39572267c --- /dev/null +++ b/src/equicordplugins/musicControls/youtubeMusic/YtmStore.ts @@ -0,0 +1,299 @@ +/* + * 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", +} + +export type RepeatMode = 'NONE' | 'ONE' | 'ALL'; + + +export 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; +} + +enum DataTypes { + PlayerInfo = 'PLAYER_INFO', + VideoChanged = 'VIDEO_CHANGED', + PlayerStateChanged = 'PLAYER_STATE_CHANGED', + PositionChanged = 'POSITION_CHANGED', + VolumeChanged = 'VOLUME_CHANGED', + RepeatChanged = 'REPEAT_CHANGED', + ShuffleChanged = 'SHUFFLE_CHANGED', +} + +type PlayerInfo = { + type: DataTypes.PlayerInfo; + song: Song | undefined; + volume: number, + muted: boolean; + repeat: RepeatMode; + position: number; + isPlaying: boolean; + shuffle: boolean; +}; + +type VideoChanged = { + type: DataTypes.VideoChanged; + song: Song; + position: number; +}; + +type PlayerStateChanged = { + type: DataTypes.PlayerStateChanged; + isPlaying: boolean, + position: number; +}; + +type PositionChanged = { + type: DataTypes.PositionChanged, + position: number; +}; + +type VolumeChanged = { + type: DataTypes.VolumeChanged; + volume: number; + muted: boolean; +}; + +type RepeatChanged = { + type: DataTypes.RepeatChanged; + repeat: RepeatMode; +}; + +type ShuffleChanged = { + type: DataTypes.ShuffleChanged; + shuffle: boolean; +}; + +export type Repeat = "NONE" | "ONE" | "ALL"; + +export const logger = new Logger("MusicControls-Ytm"); + +type Message = PlayerInfo | VideoChanged | PlayerStateChanged | PositionChanged | VolumeChanged | RepeatChanged | ShuffleChanged; + +type PlayerState = Partial>; + +class YoutubemusicSocket { + public onChange: (e: PlayerState) => void; + private ready = false; + private connecting = false; + + private socket: WebSocket | undefined; + + constructor(onChange: typeof this.onChange) { + this.reconnect(); + this.onChange = onChange; + } + + public scheduleReconnect(ms: number) { + setTimeout(() => this.reconnect(), ms); + } + + public reconnect() { + if (this.ready || this.connecting) return; + this.connecting = true; + this.initWs(); + } + + private async initWs() { + const url = Settings.plugins.MusicControls.YoutubeMusicApiUrl; + if (!url) { + this.connecting = false; + return; + } + + try { + this.socket = new WebSocket(new URL("/api/v1/ws", url)); + } catch (e) { + logger.error("Connection failed"); + return; + } + + this.socket.addEventListener("open", () => { + this.ready = true; + this.connecting = false; + }); + + this.socket.addEventListener("error", e => { + this.ready = false; + this.connecting = false; + if (!this.ready) this.scheduleReconnect(5_000); + this.onChange({ position: 0, isPlaying: false, song: undefined }); + + }); + + this.socket.addEventListener("close", e => { + this.ready = false; + this.connecting = false; + if (!this.ready) this.scheduleReconnect(10_000); + this.onChange({ position: 0, isPlaying: false, song: undefined }); + }); + + + this.socket.addEventListener("message", e => { + let message: Message; + try { + message = JSON.parse(e.data) as Message; + this.onChange(message); + } 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 isShuffled = false; + public repeat: Repeat = "NONE"; + public volume = 0; + public muted = false; + + public isSettingPosition = false; + + public socket = new YoutubemusicSocket((message: PlayerState) => { + if (message.song) { + store.song = message.song; + store.isPlaying = !(message.song?.isPaused ?? false); + }; + if (message.isPlaying != null && !message.song) store.isPlaying = message.isPlaying; + if (message.shuffle != null) store.isShuffled = message.shuffle; + 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; + if (message.muted != null) store.muted = message.muted; + + store.isSettingPosition = false; + store.emitChange(); + }); + + public openExternal(path: string) { + const videoId = path.match(/watch\?v=([\w-]+)/); + + const url = Vencord.Plugins.isPluginEnabled("OpenInApp") && videoId + ? encodeURI("youtubemusic://openVideo " + videoId[1]) + : "https://music.youtube.com" + path; + + logger.info("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.req("post", "/api/v1/previous"); + } + next() { + this.req("post", "/api/v1/next"); + } + setVolume(percent: number) { + this.req("post", "/api/v1/volume", { + body: { + volume: Math.floor(percent) + }, + }); + } + setPlaying(playing: boolean) { + if (playing) { + this.req("post", "/api/v1/play"); + } else { + this.req("post", "/api/v1/pause"); + } + } + switchRepeat() { + this.req("post", "/api/v1/switch-repeat", { + body: { + iteration: 1 + } + }); + } + shuffle() { + this.req("post", "/api/v1/shuffle"); + } + seek(ms: number) { + this.req("post", "/api/v1/seek-to", { + body: { + seconds: Math.floor(ms / 1000) + } + }); + } + toggleMute() { + this.req("post", "/api/v1/toggle-mute"); + } + + private req(method: "post" | "get" | "put", route: string, data: any = {}) { + const apiServerUrl = Settings.plugins.MusicControls.YoutubeMusicApiUrl; + if (apiServerUrl === "") return; + const url = apiServerUrl + route; + + fetch(url, { + method, + ...data, + ...(data.body && { body: JSON.stringify(data.body), headers: { "Content-Type": "application/json" } }) + }); + } + } + + const store = new YoutubeMusicStore(FluxDispatcher); + + return store; +}); diff --git a/src/equicordplugins/musicControls/youtubeMusic/ytmStyles.css b/src/equicordplugins/musicControls/youtubeMusic/ytmStyles.css new file mode 100644 index 000000000..652f90bf3 --- /dev/null +++ b/src/equicordplugins/musicControls/youtubeMusic/ytmStyles.css @@ -0,0 +1,208 @@ +#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; +} \ No newline at end of file