351 lines
9.5 KiB
TypeScript
351 lines
9.5 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();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
}
|