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; username: string }; export class Channel { id: string; name: string; description: string; playlist: Track[]; currentIndex: number = 0; startedAt: number = Date.now(); clients: Set> = 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 = { 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; } setPlaylist(tracks: Track[]) { this.playlist = tracks; this.currentIndex = 0; this.startedAt = Date.now(); this.pausedAt = 0; this.playlistDirty = true; this.broadcast(); } 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)); for (const ws of this.clients) { ws.send(msg); } } addClient(ws: ServerWebSocket) { 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) { this.clients.delete(ws); console.log(`[Channel] "${this.name}" removed client, now ${this.clients.size} clients`); } getListInfo() { const listeners = Array.from(this.clients).map(ws => ws.data?.username ?? 'Unknown'); return { id: this.id, name: this.name, description: this.description, trackCount: this.playlist.length, listenerCount: this.clients.size, listeners, isDefault: this.isDefault, createdBy: this.createdBy, }; } }