Equicord/src/equicordplugins/youtubeMusicControls/YtmStore.ts
2025-08-07 03:58:01 -04:00

241 lines
7.5 KiB
TypeScript

/*
* 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;
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" })),
"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);
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 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));
}
private ensureSocketReady(): boolean {
if (!this.socket || !this.socket.ready) {
return false;
}
return true;
}
}
const store = new YoutubeMusicStore(FluxDispatcher);
return store;
});