blastoise-archive/channel.ts

337 lines
9.1 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 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<ServerWebSocket<WsData>> = 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<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,
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();
}
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();
}
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<WsData>) {
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<WsData>) {
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,
};
}
}