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 type PlaybackMode = "once" | "repeat-all" | "repeat-one" | "shuffle"; export interface ChannelConfig { id: string; name: string; description?: string; tracks: Track[]; createdBy?: number | null; isDefault?: boolean; currentIndex?: number; startedAt?: number; paused?: boolean; pausedAt?: number; playbackMode?: PlaybackMode; } export type WsData = { channelId: string; userId: number | null; username: string }; export type PersistenceCallback = (channel: Channel, type: "state" | "queue") => void; export class Channel { id: string; name: string; description: string; queue: 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; playbackMode: PlaybackMode = "repeat-all"; private lastQueueBroadcast: number = 0; private queueDirty: boolean = false; private onPersist: PersistenceCallback | null = null; constructor(config: ChannelConfig) { this.id = config.id; this.name = config.name; this.description = config.description || ""; this.queue = config.tracks; this.createdBy = config.createdBy ?? null; this.createdAt = Date.now(); this.isDefault = config.isDefault ?? false; this.currentIndex = config.currentIndex ?? 0; this.startedAt = config.startedAt ?? Date.now(); this.paused = config.paused ?? false; this.pausedAt = config.pausedAt ?? 0; this.playbackMode = config.playbackMode ?? "repeat-all"; } setPersistenceCallback(callback: PersistenceCallback) { this.onPersist = callback; } private persistState() { this.onPersist?.(this, "state"); } private persistQueue() { this.onPersist?.(this, "queue"); } get currentTrack(): Track | null { if (this.queue.length === 0) return null; return this.queue[this.currentIndex]; } get currentTimestamp(): number { if (this.queue.length === 0) return 0; 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.queue.length === 0) return; switch (this.playbackMode) { case "once": // Play through once, stop at end if (this.currentIndex < this.queue.length - 1) { this.currentIndex++; } else { // At end of playlist - pause this.paused = true; } break; case "repeat-one": // Stay on same track, just reset timestamp break; case "shuffle": // Pick a random track (different from current if possible) if (this.queue.length > 1) { let newIndex; do { newIndex = Math.floor(Math.random() * this.queue.length); } while (newIndex === this.currentIndex); this.currentIndex = newIndex; } break; case "repeat-all": default: this.currentIndex = (this.currentIndex + 1) % this.queue.length; break; } this.startedAt = Date.now(); this.persistState(); this.broadcast(); } setPlaybackMode(mode: PlaybackMode) { this.playbackMode = mode; this.persistState(); this.broadcast(); } getState(includeQueue: 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, playbackMode: this.playbackMode, }; if (includeQueue) { state.queue = this.queue; } return state; } pause() { if (this.paused) return; this.pausedAt = this.currentTimestamp; this.paused = true; this.persistState(); this.broadcast(); } unpause() { if (!this.paused) return; this.paused = false; this.startedAt = Date.now() - this.pausedAt * 1000; this.persistState(); this.broadcast(); } jumpTo(index: number) { if (index < 0 || index >= this.queue.length) return; this.currentIndex = index; if (this.paused) { this.pausedAt = 0; } else { this.startedAt = Date.now(); } this.persistState(); 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.persistState(); this.broadcast(); } markQueueDirty() { this.queueDirty = true; } setQueue(tracks: Track[]) { // Remember current track and timestamp to preserve playback position const currentTrackId = this.currentTrack?.id; const currentTimestampValue = this.currentTimestamp; const wasPaused = this.paused; this.queue = tracks; // Try to find the current track in the new queue if (currentTrackId) { const newIndex = this.queue.findIndex(t => t.id === currentTrackId); if (newIndex !== -1) { // Found the track - preserve playback position this.currentIndex = newIndex; if (wasPaused) { this.pausedAt = currentTimestampValue; } else { this.startedAt = Date.now() - currentTimestampValue * 1000; } } else { // Track not found in new queue - reset to start this.currentIndex = 0; this.startedAt = Date.now(); this.pausedAt = 0; } } else { // No current track - reset to start this.currentIndex = 0; this.startedAt = Date.now(); this.pausedAt = 0; } this.queueDirty = true; this.persistQueue(); this.persistState(); this.broadcast(); } addTracks(tracks: Track[]) { if (tracks.length === 0) return; this.queue.push(...tracks); this.queueDirty = true; this.persistQueue(); this.broadcast(); } insertTracksAt(tracks: Track[], position: number) { if (tracks.length === 0) return; // Clamp position to valid range const insertPos = Math.max(0, Math.min(position, this.queue.length)); this.queue.splice(insertPos, 0, ...tracks); // Adjust currentIndex if insertion is at or before current if (insertPos <= this.currentIndex) { this.currentIndex += tracks.length; } this.queueDirty = true; this.persistQueue(); this.broadcast(); } removeTracksByIndex(indices: number[]) { if (indices.length === 0) return; // Sort descending to remove from end first (preserve indices) const sorted = [...indices].sort((a, b) => b - a); const currentTrackId = this.currentTrack?.id; for (const idx of sorted) { if (idx >= 0 && idx < this.queue.length) { this.queue.splice(idx, 1); // Adjust currentIndex if we removed a track before it if (idx < this.currentIndex) { this.currentIndex--; } else if (idx === this.currentIndex) { // Removed currently playing track - stay at same index (next track slides in) // If we removed the last track, wrap to start if (this.currentIndex >= this.queue.length) { this.currentIndex = 0; this.startedAt = Date.now(); this.pausedAt = 0; } } } } // If queue is now empty, reset state if (this.queue.length === 0) { this.currentIndex = 0; this.startedAt = Date.now(); this.pausedAt = 0; } // If current track changed, reset playback position if (this.queue.length > 0 && this.currentTrack?.id !== currentTrackId) { this.startedAt = Date.now(); this.pausedAt = 0; } this.queueDirty = true; this.persistQueue(); this.persistState(); this.broadcast(); } moveTracks(indices: number[], targetIndex: number) { if (indices.length === 0) return; // Get the tracks being moved const sorted = [...indices].sort((a, b) => a - b); const tracksToMove = sorted.map(i => this.queue[i]).filter(Boolean); if (tracksToMove.length === 0) return; const currentTrackId = this.currentTrack?.id; // Remove tracks from their current positions (from end to preserve indices) for (let i = sorted.length - 1; i >= 0; i--) { this.queue.splice(sorted[i], 1); } // Adjust target index for removed items that were before it let adjustedTarget = targetIndex; for (const idx of sorted) { if (idx < targetIndex) adjustedTarget--; } // Insert at new position this.queue.splice(adjustedTarget, 0, ...tracksToMove); // Update currentIndex to follow the currently playing track if (currentTrackId) { const newIndex = this.queue.findIndex(t => t.id === currentTrackId); if (newIndex !== -1) { this.currentIndex = newIndex; } } this.queueDirty = true; this.persistQueue(); this.persistState(); this.broadcast(); } broadcast() { const now = Date.now(); const includeQueue = this.queueDirty || (now - this.lastQueueBroadcast >= 60000); if (includeQueue) { this.lastQueueBroadcast = now; this.queueDirty = false; } const msg = JSON.stringify(this.getState(includeQueue)); 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 queue on connect ws.send(JSON.stringify(this.getState(true))); // Reset timer so next queue broadcast is in 60s this.lastQueueBroadcast = 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.queue.length, listenerCount: this.clients.size, listeners, isDefault: this.isDefault, createdBy: this.createdBy, }; } }