This commit is contained in:
peterino2 2026-02-02 16:00:26 -08:00
commit 46391fd060
65 changed files with 664 additions and 0 deletions

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
tmp/

50
AGENTS.md Normal file
View File

@ -0,0 +1,50 @@
# MusicRoom
Synchronized music streaming server built with Bun. Manages "streams" (virtual radio stations) that play through playlists sequentially. Clients connect, receive now-playing state, download audio, and sync playback locally.
## Architecture
The server does NOT decode or play audio. It tracks time:
- `currentTimestamp = (Date.now() - stream.startedAt) / 1000`
- When `currentTimestamp >= track.duration`, advance to next track, reset `startedAt`
- A 1s `setInterval` checks if tracks need advancing and broadcasts state every 30s
## Routes
```
GET / → Serves public/index.html
GET /api/streams → List active streams (id, name, trackCount)
GET /api/streams/:id → Current stream state (track, currentTimestamp, streamName)
WS /api/streams/:id/ws → WebSocket: pushes state on connect, every 30s, and on track change
GET /api/tracks/:filename → Serve audio file from ./music/ with Range request support
```
## Files
- **server.ts** — Bun entrypoint. Loads playlist config, reads track metadata via `music-metadata`, sets up HTTP routes and WebSocket handlers. Auto-discovers audio files in `./music/` when playlist tracks array is empty.
- **stream.ts**`Stream` class. Holds playlist, current index, startedAt timestamp, connected WebSocket clients. Manages time tracking, track advancement, and broadcasting state to clients.
- **playlist.json** — Config file. Array of stream definitions, each with id, name, and tracks array (empty = auto-discover).
- **public/index.html** — Single-file client with inline JS/CSS. Connects via WebSocket, receives state updates, fetches audio, syncs playback. Has progress bar, track info, play/pause button, volume slider.
- **music/** — Directory for audio files (.mp3, .ogg, .flac, .wav, .m4a, .aac).
## Key types
```ts
interface Track { filename: string; title: string; duration: number }
// Stream.getState() returns:
{ track: Track | null, currentTimestamp: number, streamName: string }
```
## Client sync logic
On WebSocket message:
1. New track → load audio, seek to server timestamp, play
2. Same track, drift < 2s ignore
3. Same track, drift >= 2s → seek to server timestamp
Progress bar updates from `audio.currentTime` when playing, from extrapolated server time when not playing (grey vs green color).
## Config
Default port 3001 (override with `PORT` env var). Track durations read from file metadata on startup with `music-metadata` (`duration: true` for full-file scan, needed for accurate OGG durations).

15
README.md Normal file
View File

@ -0,0 +1,15 @@
# musicroom
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run
```
This project was created using `bun init` in bun v1.3.8. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

57
bun.lock Normal file
View File

@ -0,0 +1,57 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "musicroom",
"dependencies": {
"music-metadata": "^11.11.2",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"file-type": ["file-type@21.3.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"music-metadata": ["music-metadata@11.11.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "content-type": "^1.0.5", "debug": "^4.4.3", "file-type": "^21.3.0", "media-typer": "^1.1.0", "strtok3": "^10.3.4", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0", "win-guid": "^0.2.1" } }, "sha512-tJx+lsDg1bGUOxojKKj12BIvccBBUcVa6oWrvOchCF0WAQ9E5t/hK35ILp1z3wWrUSYtgg57LfRbvVMkxGIyzA=="],
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"win-guid": ["win-guid@0.2.1", "", {}, "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A=="],
}
}

BIN
music/53-x.ogg Normal file

Binary file not shown.

BIN
music/air_traffic_3.ogg Normal file

Binary file not shown.

BIN
music/armageddon.ogg Normal file

Binary file not shown.

BIN
music/awakening.ogg Normal file

Binary file not shown.

BIN
music/back_in_time.ogg Normal file

Binary file not shown.

BIN
music/backwards.ogg Normal file

Binary file not shown.

BIN
music/bayou.ogg Normal file

Binary file not shown.

BIN
music/bb-5022.ogg Normal file

Binary file not shown.

BIN
music/bb-6666.ogg Normal file

Binary file not shown.

BIN
music/bitbashed.ogg Normal file

Binary file not shown.

BIN
music/block_city.ogg Normal file

Binary file not shown.

BIN
music/bounce.ogg Normal file

Binary file not shown.

BIN
music/bubble.ogg Normal file

Binary file not shown.

BIN
music/chill_vibez.ogg Normal file

Binary file not shown.

BIN
music/chrono_courier.ogg Normal file

Binary file not shown.

BIN
music/cruisin.ogg Normal file

Binary file not shown.

BIN
music/deflective.ogg Normal file

Binary file not shown.

BIN
music/doo_wop_ghosts.ogg Normal file

Binary file not shown.

BIN
music/dubtime_5.ogg Normal file

Binary file not shown.

BIN
music/duck_dodgers.ogg Normal file

Binary file not shown.

BIN
music/electric_knights.ogg Normal file

Binary file not shown.

BIN
music/emergency.ogg Normal file

Binary file not shown.

BIN
music/faustianreverie.ogg Normal file

Binary file not shown.

BIN
music/firewalker.ogg Normal file

Binary file not shown.

BIN
music/friendly_fire.ogg Normal file

Binary file not shown.

BIN
music/funky_ninja.ogg Normal file

Binary file not shown.

BIN
music/indigo_entrance.ogg Normal file

Binary file not shown.

BIN
music/lane_6.ogg Normal file

Binary file not shown.

BIN
music/lean.ogg Normal file

Binary file not shown.

BIN
music/light.ogg Normal file

Binary file not shown.

BIN
music/look_deeper.ogg Normal file

Binary file not shown.

BIN
music/m_paint.ogg Normal file

Binary file not shown.

Binary file not shown.

BIN
music/moon.ogg Normal file

Binary file not shown.

BIN
music/motive.ogg Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
music/obversions.ogg Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
music/rook.ogg Normal file

Binary file not shown.

BIN
music/sailing.ogg Normal file

Binary file not shown.

BIN
music/scissor_kick.ogg Normal file

Binary file not shown.

BIN
music/search.ogg Normal file

Binary file not shown.

BIN
music/slime.ogg Normal file

Binary file not shown.

BIN
music/stranded.ogg Normal file

Binary file not shown.

BIN
music/tesla.ogg Normal file

Binary file not shown.

BIN
music/the_bounce.ogg Normal file

Binary file not shown.

BIN
music/the_great_freeze.ogg Normal file

Binary file not shown.

BIN
music/the_machine.ogg Normal file

Binary file not shown.

BIN
music/thelight_2.ogg Normal file

Binary file not shown.

Binary file not shown.

BIN
music/venus.ogg Normal file

Binary file not shown.

BIN
music/ytinrete.ogg Normal file

Binary file not shown.

BIN
music/zoom.ogg Normal file

Binary file not shown.

17
package.json Normal file
View File

@ -0,0 +1,17 @@
{
"name": "musicroom",
"version": "1.0.0",
"scripts": {
"start": "bun run server.ts"
},
"dependencies": {
"music-metadata": "^11.11.2"
},
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}

9
playlist.json Normal file
View File

@ -0,0 +1,9 @@
{
"streams": [
{
"id": "main",
"name": "Main Stream",
"tracks": []
}
]
}

193
public/index.html Normal file
View File

@ -0,0 +1,193 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MusicRoom</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #111; color: #eee; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
#app { width: 100%; max-width: 480px; padding: 2rem; }
h1 { font-size: 1.2rem; color: #888; margin-bottom: 1.5rem; }
#stream-select { margin-bottom: 1.5rem; }
#stream-select select { background: #222; color: #eee; border: 1px solid #333; padding: 0.4rem 0.8rem; border-radius: 4px; font-size: 0.9rem; }
#now-playing { margin-bottom: 1rem; }
#track-name { font-size: 1.4rem; font-weight: 600; margin-bottom: 0.3rem; }
#stream-name { font-size: 0.85rem; color: #888; }
#progress-container { background: #222; border-radius: 4px; height: 6px; margin: 1rem 0; cursor: pointer; }
#progress-bar { background: #555; height: 100%; border-radius: 4px; width: 0%; transition: width 0.3s linear; }
#progress-bar.playing { background: #4e8; }
#time { display: flex; justify-content: space-between; font-size: 0.8rem; color: #888; margin-bottom: 1rem; }
#controls { display: flex; gap: 1rem; align-items: center; }
#volume { width: 100px; accent-color: #4e8; }
button { background: #222; color: #eee; border: 1px solid #333; padding: 0.5rem 1.2rem; border-radius: 4px; cursor: pointer; font-size: 0.9rem; }
button:hover { background: #333; }
#status { margin-top: 1.5rem; font-size: 0.8rem; color: #666; }
.empty { color: #666; font-style: italic; }
</style>
</head>
<body>
<div id="app">
<h1>MusicRoom</h1>
<div id="stream-select"></div>
<div id="now-playing">
<div id="track-name" class="empty">Loading...</div>
<div id="stream-name"></div>
</div>
<div id="progress-container"><div id="progress-bar"></div></div>
<div id="time"><span id="time-current">0:00</span><span id="time-total">0:00</span></div>
<div id="controls">
<button id="btn-listen">Unlisten</button>
<button id="btn-play">Play</button>
<button id="btn-pause" disabled>Pause</button>
<button id="btn-stop">Stop</button>
<button id="btn-server-pause">Server Pause</button>
<input type="range" id="volume" min="0" max="1" step="0.01" value="1">
</div>
<div id="status"></div>
</div>
<script>
(function() {
const audio = new Audio();
let ws = null;
let currentStreamId = null;
let currentFilename = null;
let serverTimestamp = 0;
let serverTrackDuration = 0;
let lastServerUpdate = 0;
let serverPaused = false;
let listening = true;
const $ = (s) => document.querySelector(s);
function fmt(sec) {
if (!sec || !isFinite(sec)) return "0:00";
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return m + ":" + String(s).padStart(2, "0");
}
function getServerTime() {
if (serverPaused) return serverTimestamp;
return serverTimestamp + (Date.now() - lastServerUpdate) / 1000;
}
// Update progress bar from server time (always), use audio time when playing
setInterval(() => {
if (!listening || serverTrackDuration <= 0) return;
const t = audio.paused ? getServerTime() : audio.currentTime;
const dur = audio.paused ? serverTrackDuration : (audio.duration || serverTrackDuration);
const pct = Math.min((t / dur) * 100, 100);
$("#progress-bar").style.width = pct + "%";
$("#time-current").textContent = fmt(t);
$("#time-total").textContent = fmt(dur);
}, 250);
// Load streams
async function loadStreams() {
const res = await fetch("/api/streams");
const streams = await res.json();
if (streams.length === 0) {
$("#track-name").textContent = "No streams available";
return;
}
if (streams.length > 1) {
const sel = document.createElement("select");
for (const s of streams) {
const opt = document.createElement("option");
opt.value = s.id;
opt.textContent = s.name;
sel.appendChild(opt);
}
sel.onchange = () => connectStream(sel.value);
$("#stream-select").appendChild(sel);
}
connectStream(streams[0].id);
}
function connectStream(id) {
if (ws) ws.close();
currentStreamId = id;
listening = true;
$("#btn-listen").textContent = "Unlisten";
const proto = location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(proto + "//" + location.host + "/api/streams/" + id + "/ws");
ws.onmessage = (e) => handleUpdate(JSON.parse(e.data));
ws.onclose = () => { $("#status").textContent = "Disconnected. Reconnecting..."; setTimeout(() => { if (listening) connectStream(id); }, 3000); };
ws.onopen = () => { $("#status").textContent = "Connected"; };
}
function handleUpdate(data) {
if (!data.track) {
$("#track-name").textContent = "No tracks";
return;
}
$("#stream-name").textContent = data.streamName || "";
serverTimestamp = data.currentTimestamp;
serverTrackDuration = data.track.duration;
lastServerUpdate = Date.now();
serverPaused = data.paused || false;
if (data.track.filename !== currentFilename) {
// New track
currentFilename = data.track.filename;
$("#track-name").textContent = data.track.title;
if (!audio.paused) {
audio.src = "/api/tracks/" + encodeURIComponent(data.track.filename);
audio.currentTime = data.currentTimestamp;
audio.play().catch(() => {});
}
} else if (!audio.paused) {
// Same track - check drift
const drift = Math.abs(audio.currentTime - data.currentTimestamp);
if (drift >= 2) {
audio.currentTime = data.currentTimestamp;
}
}
}
$("#btn-listen").onclick = () => {
if (listening) {
listening = false;
if (ws) ws.close();
$("#btn-listen").textContent = "Listen";
$("#status").textContent = "Not listening";
} else {
connectStream(currentStreamId);
}
};
$("#btn-play").onclick = () => {
if (!currentFilename) return;
if (!audio.src) {
audio.src = "/api/tracks/" + encodeURIComponent(currentFilename);
}
audio.currentTime = getServerTime();
audio.play();
};
$("#btn-pause").onclick = () => {
audio.pause();
};
$("#btn-stop").onclick = () => {
audio.pause();
audio.src = "";
};
$("#btn-server-pause").onclick = () => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: serverPaused ? "unpause" : "pause" }));
}
};
$("#volume").oninput = (e) => { audio.volume = e.target.value; };
audio.onplay = () => { $("#btn-play").disabled = true; $("#btn-pause").disabled = false; $("#progress-bar").classList.add("playing"); };
audio.onpause = () => { $("#btn-play").disabled = false; $("#btn-pause").disabled = true; $("#progress-bar").classList.remove("playing"); };
loadStreams();
})();
</script>
</body>
</html>

180
server.ts Normal file
View File

@ -0,0 +1,180 @@
import { file, serve, type ServerWebSocket } from "bun";
import { parseFile } from "music-metadata";
import { Stream, type Track } from "./stream";
import { readdir, stat } from "fs/promises";
import { join } from "path";
const MUSIC_DIR = join(import.meta.dir, "music");
const PLAYLIST_PATH = join(import.meta.dir, "playlist.json");
const PUBLIC_DIR = join(import.meta.dir, "public");
// Load track metadata
async function loadTrack(filename: string): Promise<Track> {
const filepath = join(MUSIC_DIR, filename);
try {
const metadata = await parseFile(filepath, { duration: true });
const duration = metadata.format.duration ?? 0;
const title = metadata.common.title ?? filename.replace(/\.[^.]+$/, "");
console.log(`Track: ${filename} | duration: ${duration}s | title: ${title}`);
return { filename, title, duration };
} catch (e) {
console.warn(`Could not read metadata for ${filename}, skipping`);
return { filename, title: filename.replace(/\.[^.]+$/, ""), duration: 0 };
}
}
// Auto-discover tracks if playlist is empty
async function discoverTracks(): Promise<string[]> {
try {
const files = await readdir(MUSIC_DIR);
return files.filter((f) => /\.(mp3|ogg|flac|wav|m4a|aac)$/i.test(f)).sort();
} catch {
return [];
}
}
// Initialize streams
async function init(): Promise<Map<string, Stream>> {
const playlistData = await file(PLAYLIST_PATH).json();
const streams = new Map<string, Stream>();
for (const cfg of playlistData.streams) {
let trackFiles: string[] = cfg.tracks;
if (trackFiles.length === 0) {
trackFiles = await discoverTracks();
console.log(`Stream "${cfg.id}": auto-discovered ${trackFiles.length} tracks`);
}
const tracks = await Promise.all(trackFiles.map(loadTrack));
const validTracks = tracks.filter((t) => t.duration > 0);
if (validTracks.length === 0) {
console.warn(`Stream "${cfg.id}" has no valid tracks, skipping`);
continue;
}
const stream = new Stream({ id: cfg.id, name: cfg.name, tracks: validTracks });
streams.set(cfg.id, stream);
console.log(`Stream "${cfg.id}": ${validTracks.length} tracks loaded`);
}
return streams;
}
const streams = await init();
// Tick interval: advance tracks when needed, broadcast every 30s
let tickCount = 0;
setInterval(() => {
tickCount++;
for (const stream of streams.values()) {
const changed = stream.tick();
if (!changed && tickCount % 30 === 0) {
stream.broadcast();
}
}
}, 1000);
type WsData = { streamId: string };
serve({
port: parseInt("3001"),
async fetch(req, server) {
const url = new URL(req.url);
const path = url.pathname;
// WebSocket upgrade
if (path.match(/^\/api\/streams\/([^/]+)\/ws$/)) {
const id = path.split("/")[3];
if (!streams.has(id)) return new Response("Stream not found", { status: 404 });
const ok = server.upgrade(req, { data: { streamId: id } });
if (ok) return undefined;
return new Response("WebSocket upgrade failed", { status: 500 });
}
// API: list streams
if (path === "/api/streams") {
const list = [...streams.values()].map((s) => ({
id: s.id,
name: s.name,
trackCount: s.playlist.length,
}));
return Response.json(list);
}
// API: stream state
const streamMatch = path.match(/^\/api\/streams\/([^/]+)$/);
if (streamMatch) {
const stream = streams.get(streamMatch[1]);
if (!stream) return new Response("Not found", { status: 404 });
return Response.json(stream.getState());
}
// API: serve audio file
const trackMatch = path.match(/^\/api\/tracks\/(.+)$/);
if (trackMatch) {
const filename = decodeURIComponent(trackMatch[1]);
if (filename.includes("..")) return new Response("Forbidden", { status: 403 });
const filepath = join(MUSIC_DIR, filename);
const f = file(filepath);
if (!(await f.exists())) return new Response("Not found", { status: 404 });
const size = f.size;
const range = req.headers.get("range");
if (range) {
const match = range.match(/bytes=(\d+)-(\d*)/);
if (match) {
const start = parseInt(match[1]);
const end = match[2] ? parseInt(match[2]) : size - 1;
const chunk = f.slice(start, end + 1);
return new Response(chunk, {
status: 206,
headers: {
"Content-Range": `bytes ${start}-${end}/${size}`,
"Accept-Ranges": "bytes",
"Content-Length": String(end - start + 1),
"Content-Type": f.type || "audio/mpeg",
},
});
}
}
return new Response(f, {
headers: {
"Accept-Ranges": "bytes",
"Content-Length": String(size),
"Content-Type": f.type || "audio/mpeg",
},
});
}
// Serve static client
if (path === "/" || path === "/index.html") {
return new Response(file(join(PUBLIC_DIR, "index.html")), {
headers: { "Content-Type": "text/html" },
});
}
return new Response("Not found", { status: 404 });
},
websocket: {
open(ws: ServerWebSocket<WsData>) {
const stream = streams.get(ws.data.streamId);
if (stream) stream.addClient(ws);
},
close(ws: ServerWebSocket<WsData>) {
const stream = streams.get(ws.data.streamId);
if (stream) stream.removeClient(ws);
},
message(ws: ServerWebSocket<WsData>, message: string | Buffer) {
const stream = streams.get(ws.data.streamId);
if (!stream) return;
try {
const data = JSON.parse(String(message));
if (data.action === "pause") stream.pause();
else if (data.action === "unpause") stream.unpause();
} catch {}
},
},
});
console.log("MusicRoom running on http://localhost:3001");

97
stream.ts Normal file
View File

@ -0,0 +1,97 @@
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;
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() {
return {
track: this.currentTrack,
currentTimestamp: this.currentTimestamp,
streamName: this.name,
paused: this.paused,
};
}
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();
}
broadcast() {
const msg = JSON.stringify(this.getState());
for (const ws of this.clients) {
ws.send(msg);
}
}
addClient(ws: ServerWebSocket<{ streamId: string }>) {
this.clients.add(ws);
ws.send(JSON.stringify(this.getState()));
}
removeClient(ws: ServerWebSocket<{ streamId: string }>) {
this.clients.delete(ws);
}
}

10
tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["bun-types"],
"strict": true,
"esModuleInterop": true
}
}