saving
This commit is contained in:
commit
46391fd060
|
|
@ -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/
|
||||
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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=="],
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"streams": [
|
||||
{
|
||||
"id": "main",
|
||||
"name": "Main Stream",
|
||||
"tracks": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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");
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["bun-types"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue