Basic functionality is here with a crappy ai generated frontend.

This commit is contained in:
Jurn Wubben 2026-02-01 17:07:39 +01:00
commit e9fdc05c2d
14 changed files with 1600 additions and 0 deletions

30
src/commandBuilder.ts Normal file
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
export class Queue {
private userCount = 0;
}

82
src/spotify.ts Normal file
View 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
View 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;
}
}