blastoise-archive/stream.ts

143 lines
3.3 KiB
TypeScript

import type { ServerWebSocket } from "bun";
export interface Track {
filename: string;
title: string;
duration: number;
}
export interface StreamConfig {
id: string;
name: string;
tracks: Track[];
}
export class Stream {
id: string;
name: string;
playlist: Track[];
currentIndex: number = 0;
startedAt: number = Date.now();
clients: Set<ServerWebSocket<{ streamId: string }>> = new Set();
paused: boolean = false;
pausedAt: number = 0;
private lastPlaylistBroadcast: number = 0;
private playlistDirty: boolean = false;
constructor(config: StreamConfig) {
this.id = config.id;
this.name = config.name;
this.playlist = config.tracks;
}
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,
streamName: this.name,
paused: this.paused,
currentIndex: this.currentIndex,
};
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));
for (const ws of this.clients) {
ws.send(msg);
}
}
addClient(ws: ServerWebSocket<{ streamId: string }>) {
this.clients.add(ws);
// 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<{ streamId: string }>) {
this.clients.delete(ws);
}
}