178 lines
4.6 KiB
TypeScript
178 lines
4.6 KiB
TypeScript
import type { ServerWebSocket } from "bun";
|
|
|
|
export interface Track {
|
|
id: string; // Content hash (primary key)
|
|
filename: string; // Original filename
|
|
title: string; // Display title
|
|
duration: number;
|
|
}
|
|
|
|
export interface ChannelConfig {
|
|
id: string;
|
|
name: string;
|
|
description?: string;
|
|
tracks: Track[];
|
|
createdBy?: number | null;
|
|
isDefault?: boolean;
|
|
}
|
|
|
|
export type WsData = { channelId: string; userId: number | null };
|
|
|
|
export class Channel {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
playlist: Track[];
|
|
currentIndex: number = 0;
|
|
startedAt: number = Date.now();
|
|
clients: Set<ServerWebSocket<WsData>> = new Set();
|
|
paused: boolean = false;
|
|
pausedAt: number = 0;
|
|
createdBy: number | null;
|
|
createdAt: number;
|
|
isDefault: boolean;
|
|
private lastPlaylistBroadcast: number = 0;
|
|
private playlistDirty: boolean = false;
|
|
|
|
constructor(config: ChannelConfig) {
|
|
this.id = config.id;
|
|
this.name = config.name;
|
|
this.description = config.description || "";
|
|
this.playlist = config.tracks;
|
|
this.createdBy = config.createdBy ?? null;
|
|
this.createdAt = Date.now();
|
|
this.isDefault = config.isDefault ?? false;
|
|
}
|
|
|
|
get currentTrack(): Track | null {
|
|
if (this.playlist.length === 0) return null;
|
|
return this.playlist[this.currentIndex];
|
|
}
|
|
|
|
get currentTimestamp(): number {
|
|
if (this.paused) return this.pausedAt;
|
|
return (Date.now() - this.startedAt) / 1000;
|
|
}
|
|
|
|
tick(): boolean {
|
|
if (this.paused) return false;
|
|
const track = this.currentTrack;
|
|
if (!track) return false;
|
|
if (this.currentTimestamp >= track.duration) {
|
|
this.advance();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
advance() {
|
|
if (this.playlist.length === 0) return;
|
|
this.currentIndex = (this.currentIndex + 1) % this.playlist.length;
|
|
this.startedAt = Date.now();
|
|
this.broadcast();
|
|
}
|
|
|
|
getState(includePlaylist: boolean = false) {
|
|
const state: Record<string, unknown> = {
|
|
track: this.currentTrack,
|
|
currentTimestamp: this.currentTimestamp,
|
|
channelName: this.name,
|
|
channelId: this.id,
|
|
description: this.description,
|
|
paused: this.paused,
|
|
currentIndex: this.currentIndex,
|
|
listenerCount: this.clients.size,
|
|
isDefault: this.isDefault,
|
|
};
|
|
if (includePlaylist) {
|
|
state.playlist = this.playlist;
|
|
}
|
|
return state;
|
|
}
|
|
|
|
pause() {
|
|
if (this.paused) return;
|
|
this.pausedAt = this.currentTimestamp;
|
|
this.paused = true;
|
|
this.broadcast();
|
|
}
|
|
|
|
unpause() {
|
|
if (!this.paused) return;
|
|
this.paused = false;
|
|
this.startedAt = Date.now() - this.pausedAt * 1000;
|
|
this.broadcast();
|
|
}
|
|
|
|
jumpTo(index: number) {
|
|
if (index < 0 || index >= this.playlist.length) return;
|
|
this.currentIndex = index;
|
|
if (this.paused) {
|
|
this.pausedAt = 0;
|
|
} else {
|
|
this.startedAt = Date.now();
|
|
}
|
|
this.broadcast();
|
|
}
|
|
|
|
seek(timestamp: number) {
|
|
const track = this.currentTrack;
|
|
if (!track) return;
|
|
const clamped = Math.max(0, Math.min(timestamp, track.duration));
|
|
if (this.paused) {
|
|
this.pausedAt = clamped;
|
|
} else {
|
|
this.startedAt = Date.now() - clamped * 1000;
|
|
}
|
|
this.broadcast();
|
|
}
|
|
|
|
markPlaylistDirty() {
|
|
this.playlistDirty = true;
|
|
}
|
|
|
|
broadcast() {
|
|
const now = Date.now();
|
|
const includePlaylist = this.playlistDirty || (now - this.lastPlaylistBroadcast >= 60000);
|
|
if (includePlaylist) {
|
|
this.lastPlaylistBroadcast = now;
|
|
this.playlistDirty = false;
|
|
}
|
|
const msg = JSON.stringify(this.getState(includePlaylist));
|
|
|
|
const clientIds = Array.from(this.clients).map(ws => ws.data?.sessionId ?? 'unknown');
|
|
console.log(`[Channel] "${this.name}" broadcasting to ${this.clients.size} clients: [${clientIds.join(', ')}]`);
|
|
|
|
for (const ws of this.clients) {
|
|
ws.send(msg);
|
|
}
|
|
}
|
|
|
|
addClient(ws: ServerWebSocket<WsData>) {
|
|
this.clients.add(ws);
|
|
console.log(`[Channel] "${this.name}" added client, now ${this.clients.size} clients`);
|
|
|
|
// Always send full state with playlist on connect
|
|
ws.send(JSON.stringify(this.getState(true)));
|
|
// Reset timer so next playlist broadcast is in 60s
|
|
this.lastPlaylistBroadcast = Date.now();
|
|
}
|
|
|
|
removeClient(ws: ServerWebSocket<WsData>) {
|
|
this.clients.delete(ws);
|
|
console.log(`[Channel] "${this.name}" removed client, now ${this.clients.size} clients`);
|
|
}
|
|
|
|
getListInfo() {
|
|
return {
|
|
id: this.id,
|
|
name: this.name,
|
|
description: this.description,
|
|
trackCount: this.playlist.length,
|
|
listenerCount: this.clients.size,
|
|
isDefault: this.isDefault,
|
|
createdBy: this.createdBy,
|
|
};
|
|
}
|
|
}
|