Basic functionality is here with a crappy ai generated frontend.
This commit is contained in:
commit
e9fdc05c2d
14 changed files with 1600 additions and 0 deletions
30
src/commandBuilder.ts
Normal file
30
src/commandBuilder.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
export type BaseCommand = {
|
||||
c: string,
|
||||
// deno-lint-ignore no-explicit-any
|
||||
d: any | undefined
|
||||
}
|
||||
export type Command<D> = {
|
||||
c: string
|
||||
d: D
|
||||
}
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
export function buildCommand(command: string, data?: any): string {
|
||||
return JSON.stringify({c: command, d: data})
|
||||
}
|
||||
export function buildError(error: number, info?: string): string {
|
||||
return JSON.stringify({e: error, info})
|
||||
}
|
||||
export function parseCommand(command: string): BaseCommand | null {
|
||||
let parsed: BaseCommand = {"c": "parse_error", "d": undefined};
|
||||
try {
|
||||
parsed = JSON.parse(command);
|
||||
if (!("c" in parsed) || !(typeof(parsed.d) === "object" || typeof(parsed.d) === "undefined"))
|
||||
return null
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed as BaseCommand
|
||||
|
||||
}
|
||||
53
src/heartbeat.ts
Normal file
53
src/heartbeat.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { buildError, parseCommand } from "./commandBuilder.ts";
|
||||
|
||||
const HEARTBEAT_INTERVAL = 30_000;
|
||||
const HEARTBEAT_TIMEOUT = 5_000;
|
||||
const S_PING = JSON.stringify({ "c": "ping" });
|
||||
|
||||
export class HeartbeatWS {
|
||||
heartbeatTimer: number | undefined;
|
||||
pongTimer: number | undefined;
|
||||
ws: WebSocket;
|
||||
|
||||
constructor(socket: WebSocket) {
|
||||
this.ws = socket;
|
||||
socket.addEventListener("open", (_) => this.setHeartBeat());
|
||||
socket.addEventListener("close", _ => this.clearTimers());
|
||||
socket.addEventListener("error", _ => this.clearTimers());
|
||||
socket.addEventListener("message", (msg) => this.onMessage(msg));
|
||||
}
|
||||
|
||||
|
||||
private setPong(state: boolean) {
|
||||
if (state) {
|
||||
clearTimeout(this.pongTimer);
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.send(S_PING);
|
||||
this.pongTimer = setTimeout(() => {
|
||||
this.ws.send(buildError(1, "Pong missed."));
|
||||
this.ws.close();
|
||||
}, HEARTBEAT_TIMEOUT);
|
||||
}
|
||||
private setHeartBeat() {
|
||||
this.setPong(false);
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
this.setPong(false);
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
}
|
||||
private clearTimers() {
|
||||
clearTimeout(this.pongTimer);
|
||||
clearTimeout(this.heartbeatTimer);
|
||||
}
|
||||
|
||||
private onMessage(msg: MessageEvent<string>) {
|
||||
const text = msg.data;
|
||||
const parsed = parseCommand(text);
|
||||
|
||||
if (parsed === null || parsed.c !== "pong") return;
|
||||
|
||||
this.setPong(true);
|
||||
}
|
||||
}
|
||||
|
||||
43
src/main.ts
Normal file
43
src/main.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { SpotifyWS } from "./spotify.ts"
|
||||
import { HeartbeatWS } from "./heartbeat.ts";
|
||||
import { PlayerManager } from "./playerManager.ts";
|
||||
import { serveFile } from "@std/http/file-server";
|
||||
import { UserWS } from "./user.ts";
|
||||
|
||||
const USER_PATTERN = new URLPattern({pathname: "/ws/user"})
|
||||
const SPOTIFY_PATTERN = new URLPattern({pathname: "/ws/spotify"})
|
||||
const song = "spotify:track:2VxJVGiTsK6UyrxPJJ2lR9";
|
||||
|
||||
function commonWs(req: Request) {
|
||||
const { socket, response } = Deno.upgradeWebSocket(req);
|
||||
return { socket, response };
|
||||
}
|
||||
|
||||
declare global {
|
||||
var spotify: SpotifyWS | undefined;
|
||||
var playerManager: PlayerManager
|
||||
}
|
||||
globalThis.spotify = undefined;
|
||||
globalThis.playerManager = new PlayerManager()
|
||||
|
||||
Deno.serve((req) => {
|
||||
if (USER_PATTERN.exec(req.url)) {
|
||||
const { socket, response } = commonWs(req);
|
||||
new HeartbeatWS(socket);
|
||||
new UserWS(socket)
|
||||
|
||||
return response
|
||||
} else if (SPOTIFY_PATTERN.exec(req.url)) {
|
||||
const { socket, response } = commonWs(req);
|
||||
|
||||
const spState = spotify?.ws.readyState;
|
||||
if (spState !== undefined && spState !== WebSocket.CLOSED) spotify?.ws.close();
|
||||
|
||||
spotify = new SpotifyWS(socket, () => song);
|
||||
new HeartbeatWS(socket);
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
return serveFile(req, "./static/index.html")
|
||||
})
|
||||
94
src/playerManager.ts
Normal file
94
src/playerManager.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { Song } from "./spotify.ts";
|
||||
import { UserWS } from "./user.ts";
|
||||
|
||||
export type UserQueue = {
|
||||
queue: Song[];
|
||||
userWS: UserWS;
|
||||
};
|
||||
export type MergedQueue = { user: string; song: Song }[];
|
||||
|
||||
export class PlayerManager {
|
||||
private userQueue: { [x: string]: UserQueue } = {};
|
||||
private nextUserIndex = 0;
|
||||
public mergedQueue: MergedQueue = [];
|
||||
|
||||
public login(name: string, userWS: UserWS): UserQueue {
|
||||
if (name in this.userQueue) {
|
||||
const user = this.userQueue[name];
|
||||
|
||||
user.userWS.disconnect();
|
||||
user.userWS = userWS;
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
this.userQueue[name] = {
|
||||
queue: [],
|
||||
userWS,
|
||||
};
|
||||
|
||||
return this.userQueue[name];
|
||||
}
|
||||
public logout(name: string) {
|
||||
if (!(name in this.userQueue)) return;
|
||||
|
||||
const userQueue = this.userQueue[name];
|
||||
userQueue.userWS.disconnect();
|
||||
|
||||
delete this.userQueue[name];
|
||||
}
|
||||
public getNext(): Song | null {
|
||||
const song = this.mergedQueue.shift();
|
||||
if (song === undefined) return null;
|
||||
|
||||
if (song.user in this.userQueue) {
|
||||
const user = this.userQueue[song.user];
|
||||
const users = Object.keys(this.userQueue);
|
||||
this.nextUserIndex = (this.nextUserIndex + 1) % users.length;
|
||||
|
||||
user.queue.shift();
|
||||
user.userWS.updateQueue();
|
||||
}
|
||||
|
||||
this.broadcastMergedQueue();
|
||||
return song.song;
|
||||
}
|
||||
|
||||
public updateMergedQueue() {
|
||||
this.generateMergedQueue();
|
||||
this.broadcastMergedQueue();
|
||||
}
|
||||
private broadcastMergedQueue() {
|
||||
for (const user of Object.values(this.userQueue)) {
|
||||
user.userWS.broadcastQueue(this.mergedQueue);
|
||||
}
|
||||
}
|
||||
|
||||
private generateMergedQueue() {
|
||||
const users = Object.keys(this.userQueue);
|
||||
if (!users.length) {
|
||||
this.mergedQueue = [];
|
||||
this.nextUserIndex = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
this.nextUserIndex = this.nextUserIndex % users.length;
|
||||
const out: MergedQueue = [];
|
||||
let idx = 0;
|
||||
|
||||
while (true) {
|
||||
let added = 0;
|
||||
for (let i = 0; i < users.length; i++) {
|
||||
const u = users[(this.nextUserIndex + i) % users.length];
|
||||
const list = this.userQueue[u].queue;
|
||||
if (idx < list.length) {
|
||||
out.push({ user: u, song: list[idx] });
|
||||
added++;
|
||||
}
|
||||
}
|
||||
if (!added) break;
|
||||
idx++;
|
||||
}
|
||||
|
||||
this.mergedQueue = out;
|
||||
}}
|
||||
3
src/queue.ts
Normal file
3
src/queue.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export class Queue {
|
||||
private userCount = 0;
|
||||
}
|
||||
82
src/spotify.ts
Normal file
82
src/spotify.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { buildCommand, type Command, type BaseCommand, parseCommand } from "./commandBuilder.ts";
|
||||
|
||||
type GetSong = () => string | null;
|
||||
export type Song = {
|
||||
name: string;
|
||||
uri: string;
|
||||
artists: string[];
|
||||
album: {
|
||||
name: string;
|
||||
coverUrl: string | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export class SpotifyWS {
|
||||
public ws: WebSocket;
|
||||
private queryList: {
|
||||
[x: string]: (value: Song[] | PromiseLike<Song[]>) => void;
|
||||
} = {};
|
||||
|
||||
constructor(ws: WebSocket) {
|
||||
this.ws = ws;
|
||||
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.ws.addEventListener("message", this.onMessage);
|
||||
this.ws.addEventListener("close", this.onClose);
|
||||
}
|
||||
|
||||
public search(query: string): Promise<Song[]> {
|
||||
return new Promise<Song[]>((resolve, reject) => {
|
||||
if (this.ws.readyState != this.ws.OPEN) reject("WS Isn't connected.");
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
this.send(buildCommand("search", { query, id }));
|
||||
this.queryList[id] = resolve;
|
||||
|
||||
setTimeout(() => {
|
||||
reject("Timeout.");
|
||||
delete this.queryList[id];
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
public sendSong(uri?: string) {
|
||||
const song = uri ?? playerManager.getNext()?.uri;
|
||||
if (!song) return;
|
||||
|
||||
this.send(buildCommand("next_song", { song }));
|
||||
}
|
||||
|
||||
private send(data: string) {
|
||||
if (this.ws.readyState != this.ws.OPEN) return;
|
||||
this.ws.send(data);
|
||||
}
|
||||
|
||||
private onClose() {
|
||||
globalThis.spotify = undefined;
|
||||
}
|
||||
private onMessage(msg: MessageEvent<string>) {
|
||||
const text = msg.data;
|
||||
const parsed = parseCommand(text);
|
||||
|
||||
if (parsed === null) return;
|
||||
|
||||
switch (parsed.c) {
|
||||
case "next_song":
|
||||
this.sendSong();
|
||||
break;
|
||||
|
||||
case "search": {
|
||||
if (!parsed.d || !parsed.d.id || !parsed.d.results) break;
|
||||
const cmd = parsed as Command<{id: string, results: Song[]}>
|
||||
|
||||
const { id, results } = cmd.d;
|
||||
if (!(id in this.queryList)) break;
|
||||
|
||||
this.queryList[id](results);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
113
src/user.ts
Normal file
113
src/user.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { buildCommand, buildError, Command, parseCommand } from "./commandBuilder.ts";
|
||||
import { MergedQueue, UserQueue } from "./playerManager.ts";
|
||||
import { Song } from "./spotify.ts";
|
||||
|
||||
export class UserWS {
|
||||
private ws: WebSocket;
|
||||
|
||||
private loggedIn: boolean = false;
|
||||
private name: string = "";
|
||||
private playerQueue: UserQueue | undefined;
|
||||
|
||||
constructor(ws: WebSocket) {
|
||||
this.ws = ws;
|
||||
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
this.onClose = this.onClose.bind(this);
|
||||
ws.addEventListener("message", this.onMessage);
|
||||
ws.addEventListener("close", this.onClose);
|
||||
}
|
||||
|
||||
public disconnect() {
|
||||
this.loggedIn = false;
|
||||
this.ws.send(buildCommand("logout"))
|
||||
this.ws.close();
|
||||
}
|
||||
public broadcastQueue(queue: MergedQueue) {
|
||||
this.ws.send(buildCommand("updatemergedqueue", {queue}))
|
||||
}
|
||||
public updateQueue() {
|
||||
const queue = this.playerQueue?.queue;
|
||||
if (!queue) return;
|
||||
|
||||
this.ws.send(buildCommand("getqueue", {queue}))
|
||||
}
|
||||
|
||||
private spotifyConnected(): boolean {
|
||||
return spotify === undefined;
|
||||
}
|
||||
private login(name: string) {
|
||||
this.name = name;
|
||||
this.loggedIn = true;
|
||||
|
||||
this.playerQueue = playerManager.login(name, this);
|
||||
}
|
||||
private async onMessage(msg: MessageEvent<string>) {
|
||||
const command = parseCommand(msg.data);
|
||||
if (command === null || (command.c != "login" && !this.loggedIn)) {
|
||||
console.log("User not logged in but sent", command)
|
||||
return
|
||||
};
|
||||
|
||||
switch (command.c) {
|
||||
case "login": {
|
||||
if (command.d === undefined || typeof(command.d.name) !== "string") {
|
||||
this.ws.send(buildError(0))
|
||||
return
|
||||
};
|
||||
|
||||
const cmd = command as Command<{name: string}>;
|
||||
const name = cmd.d.name.trim();
|
||||
|
||||
if (name === "") {
|
||||
this.ws.send(buildError(0))
|
||||
return
|
||||
}
|
||||
|
||||
this.login(command.d.name);
|
||||
break;
|
||||
};
|
||||
|
||||
case "search": {
|
||||
if (command.d === undefined || typeof(command.d.query) !== "string") return
|
||||
const cmd = command as Command<{query: string}>;
|
||||
|
||||
|
||||
const songs = await spotify?.search(cmd.d.query)
|
||||
if (songs === undefined) {
|
||||
this.ws.send(buildCommand("search", {songs: [], connected: false}))
|
||||
} else {
|
||||
this.ws.send(buildCommand("search", {songs, connected: true}))
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case "getqueue": {
|
||||
if (!this.playerQueue) return;
|
||||
|
||||
const queue = this.playerQueue.queue;
|
||||
this.ws.send(buildCommand("getqueue", {queue}))
|
||||
|
||||
break
|
||||
}
|
||||
case "setqueue": {
|
||||
if (command.d === undefined || typeof(command.d.queue) !== "object" || !this.playerQueue) return
|
||||
const cmd = command as Command<{queue: Song[]}>
|
||||
|
||||
this.playerQueue.queue = cmd.d.queue;
|
||||
playerManager.updateMergedQueue();
|
||||
break
|
||||
}
|
||||
case "queuesong": {
|
||||
if (command.d === undefined || typeof(command.d.song) !== "object" || !this.playerQueue) return
|
||||
const cmd = command as Command<{song: Song}>
|
||||
|
||||
this.playerQueue.queue.push(cmd.d.song);
|
||||
playerManager.updateMergedQueue();
|
||||
}
|
||||
}
|
||||
}
|
||||
private onClose() {
|
||||
this.loggedIn = false;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue