blastoise-archive/channel.ts

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,
};
}
}