commit 46391fd0604f26369c4b66ed279c9ca47ffc7622 Author: peterino2 Date: Mon Feb 2 16:00:26 2026 -0800 saving diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90d6690 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..15db987 --- /dev/null +++ b/AGENTS.md @@ -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). diff --git a/README.md b/README.md new file mode 100644 index 0000000..a22fda5 --- /dev/null +++ b/README.md @@ -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. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..6e038d0 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/music/53-x.ogg b/music/53-x.ogg new file mode 100644 index 0000000..d8570fe Binary files /dev/null and b/music/53-x.ogg differ diff --git a/music/air_traffic_3.ogg b/music/air_traffic_3.ogg new file mode 100644 index 0000000..491c974 Binary files /dev/null and b/music/air_traffic_3.ogg differ diff --git a/music/armageddon.ogg b/music/armageddon.ogg new file mode 100644 index 0000000..70a8875 Binary files /dev/null and b/music/armageddon.ogg differ diff --git a/music/awakening.ogg b/music/awakening.ogg new file mode 100644 index 0000000..282fc60 Binary files /dev/null and b/music/awakening.ogg differ diff --git a/music/back_in_time.ogg b/music/back_in_time.ogg new file mode 100644 index 0000000..710d212 Binary files /dev/null and b/music/back_in_time.ogg differ diff --git a/music/backwards.ogg b/music/backwards.ogg new file mode 100644 index 0000000..419e57e Binary files /dev/null and b/music/backwards.ogg differ diff --git a/music/bayou.ogg b/music/bayou.ogg new file mode 100644 index 0000000..bd64fa7 Binary files /dev/null and b/music/bayou.ogg differ diff --git a/music/bb-5022.ogg b/music/bb-5022.ogg new file mode 100644 index 0000000..8db4e1b Binary files /dev/null and b/music/bb-5022.ogg differ diff --git a/music/bb-6666.ogg b/music/bb-6666.ogg new file mode 100644 index 0000000..29df96c Binary files /dev/null and b/music/bb-6666.ogg differ diff --git a/music/bitbashed.ogg b/music/bitbashed.ogg new file mode 100644 index 0000000..7a6ca30 Binary files /dev/null and b/music/bitbashed.ogg differ diff --git a/music/block_city.ogg b/music/block_city.ogg new file mode 100644 index 0000000..89e1544 Binary files /dev/null and b/music/block_city.ogg differ diff --git a/music/bounce.ogg b/music/bounce.ogg new file mode 100644 index 0000000..df33013 Binary files /dev/null and b/music/bounce.ogg differ diff --git a/music/bubble.ogg b/music/bubble.ogg new file mode 100644 index 0000000..6ee37c8 Binary files /dev/null and b/music/bubble.ogg differ diff --git a/music/chill_vibez.ogg b/music/chill_vibez.ogg new file mode 100644 index 0000000..611d001 Binary files /dev/null and b/music/chill_vibez.ogg differ diff --git a/music/chrono_courier.ogg b/music/chrono_courier.ogg new file mode 100644 index 0000000..bebdf9e Binary files /dev/null and b/music/chrono_courier.ogg differ diff --git a/music/cruisin.ogg b/music/cruisin.ogg new file mode 100644 index 0000000..7d1b9b2 Binary files /dev/null and b/music/cruisin.ogg differ diff --git a/music/deflective.ogg b/music/deflective.ogg new file mode 100644 index 0000000..ef33702 Binary files /dev/null and b/music/deflective.ogg differ diff --git a/music/doo_wop_ghosts.ogg b/music/doo_wop_ghosts.ogg new file mode 100644 index 0000000..353f024 Binary files /dev/null and b/music/doo_wop_ghosts.ogg differ diff --git a/music/dubtime_5.ogg b/music/dubtime_5.ogg new file mode 100644 index 0000000..7475bb8 Binary files /dev/null and b/music/dubtime_5.ogg differ diff --git a/music/duck_dodgers.ogg b/music/duck_dodgers.ogg new file mode 100644 index 0000000..951051f Binary files /dev/null and b/music/duck_dodgers.ogg differ diff --git a/music/electric_knights.ogg b/music/electric_knights.ogg new file mode 100644 index 0000000..cfa8e7c Binary files /dev/null and b/music/electric_knights.ogg differ diff --git a/music/emergency.ogg b/music/emergency.ogg new file mode 100644 index 0000000..1474a9f Binary files /dev/null and b/music/emergency.ogg differ diff --git a/music/faustianreverie.ogg b/music/faustianreverie.ogg new file mode 100644 index 0000000..84fcfca Binary files /dev/null and b/music/faustianreverie.ogg differ diff --git a/music/firewalker.ogg b/music/firewalker.ogg new file mode 100644 index 0000000..c07d2de Binary files /dev/null and b/music/firewalker.ogg differ diff --git a/music/friendly_fire.ogg b/music/friendly_fire.ogg new file mode 100644 index 0000000..f4a50d4 Binary files /dev/null and b/music/friendly_fire.ogg differ diff --git a/music/funky_ninja.ogg b/music/funky_ninja.ogg new file mode 100644 index 0000000..7fdd142 Binary files /dev/null and b/music/funky_ninja.ogg differ diff --git a/music/indigo_entrance.ogg b/music/indigo_entrance.ogg new file mode 100644 index 0000000..2ce19e3 Binary files /dev/null and b/music/indigo_entrance.ogg differ diff --git a/music/lane_6.ogg b/music/lane_6.ogg new file mode 100644 index 0000000..586774a Binary files /dev/null and b/music/lane_6.ogg differ diff --git a/music/lean.ogg b/music/lean.ogg new file mode 100644 index 0000000..88f5723 Binary files /dev/null and b/music/lean.ogg differ diff --git a/music/light.ogg b/music/light.ogg new file mode 100644 index 0000000..f061c9d Binary files /dev/null and b/music/light.ogg differ diff --git a/music/look_deeper.ogg b/music/look_deeper.ogg new file mode 100644 index 0000000..2483022 Binary files /dev/null and b/music/look_deeper.ogg differ diff --git a/music/m_paint.ogg b/music/m_paint.ogg new file mode 100644 index 0000000..38e12cb Binary files /dev/null and b/music/m_paint.ogg differ diff --git a/music/march_of_the_undead.ogg b/music/march_of_the_undead.ogg new file mode 100644 index 0000000..f1877b9 Binary files /dev/null and b/music/march_of_the_undead.ogg differ diff --git a/music/moon.ogg b/music/moon.ogg new file mode 100644 index 0000000..2055873 Binary files /dev/null and b/music/moon.ogg differ diff --git a/music/motive.ogg b/music/motive.ogg new file mode 100644 index 0000000..634c080 Binary files /dev/null and b/music/motive.ogg differ diff --git a/music/necromancers_laboratory.ogg b/music/necromancers_laboratory.ogg new file mode 100644 index 0000000..dfaea63 Binary files /dev/null and b/music/necromancers_laboratory.ogg differ diff --git a/music/nnoeirpwxmc.oeirp_3.ogg b/music/nnoeirpwxmc.oeirp_3.ogg new file mode 100644 index 0000000..391b743 Binary files /dev/null and b/music/nnoeirpwxmc.oeirp_3.ogg differ diff --git a/music/obversions.ogg b/music/obversions.ogg new file mode 100644 index 0000000..dc7512c Binary files /dev/null and b/music/obversions.ogg differ diff --git a/music/order_of_the_dragon.ogg b/music/order_of_the_dragon.ogg new file mode 100644 index 0000000..737fb85 Binary files /dev/null and b/music/order_of_the_dragon.ogg differ diff --git a/music/reverse_the_polarity.ogg b/music/reverse_the_polarity.ogg new file mode 100644 index 0000000..7579e51 Binary files /dev/null and b/music/reverse_the_polarity.ogg differ diff --git a/music/rook.ogg b/music/rook.ogg new file mode 100644 index 0000000..bc46ad7 Binary files /dev/null and b/music/rook.ogg differ diff --git a/music/sailing.ogg b/music/sailing.ogg new file mode 100644 index 0000000..9405120 Binary files /dev/null and b/music/sailing.ogg differ diff --git a/music/scissor_kick.ogg b/music/scissor_kick.ogg new file mode 100644 index 0000000..0fa3201 Binary files /dev/null and b/music/scissor_kick.ogg differ diff --git a/music/search.ogg b/music/search.ogg new file mode 100644 index 0000000..3ed2db6 Binary files /dev/null and b/music/search.ogg differ diff --git a/music/slime.ogg b/music/slime.ogg new file mode 100644 index 0000000..9951633 Binary files /dev/null and b/music/slime.ogg differ diff --git a/music/stranded.ogg b/music/stranded.ogg new file mode 100644 index 0000000..62990d4 Binary files /dev/null and b/music/stranded.ogg differ diff --git a/music/tesla.ogg b/music/tesla.ogg new file mode 100644 index 0000000..98f3fcf Binary files /dev/null and b/music/tesla.ogg differ diff --git a/music/the_bounce.ogg b/music/the_bounce.ogg new file mode 100644 index 0000000..ca5b452 Binary files /dev/null and b/music/the_bounce.ogg differ diff --git a/music/the_great_freeze.ogg b/music/the_great_freeze.ogg new file mode 100644 index 0000000..f7eae80 Binary files /dev/null and b/music/the_great_freeze.ogg differ diff --git a/music/the_machine.ogg b/music/the_machine.ogg new file mode 100644 index 0000000..4fda4dd Binary files /dev/null and b/music/the_machine.ogg differ diff --git a/music/thelight_2.ogg b/music/thelight_2.ogg new file mode 100644 index 0000000..8a2dd82 Binary files /dev/null and b/music/thelight_2.ogg differ diff --git a/music/transcendental_fire.ogg b/music/transcendental_fire.ogg new file mode 100644 index 0000000..5dd971c Binary files /dev/null and b/music/transcendental_fire.ogg differ diff --git a/music/venus.ogg b/music/venus.ogg new file mode 100644 index 0000000..8a7691a Binary files /dev/null and b/music/venus.ogg differ diff --git a/music/ytinrete.ogg b/music/ytinrete.ogg new file mode 100644 index 0000000..38c0579 Binary files /dev/null and b/music/ytinrete.ogg differ diff --git a/music/zoom.ogg b/music/zoom.ogg new file mode 100644 index 0000000..52733a2 Binary files /dev/null and b/music/zoom.ogg differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..7ac39ca --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/playlist.json b/playlist.json new file mode 100644 index 0000000..7b1f394 --- /dev/null +++ b/playlist.json @@ -0,0 +1,9 @@ +{ + "streams": [ + { + "id": "main", + "name": "Main Stream", + "tracks": [] + } + ] +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..b42c9c6 --- /dev/null +++ b/public/index.html @@ -0,0 +1,193 @@ + + + + + +MusicRoom + + + +
+

MusicRoom

+
+
+
Loading...
+
+
+
+
0:000:00
+
+ + + + + + +
+
+
+ + + diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..596b934 --- /dev/null +++ b/server.ts @@ -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 { + 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 { + 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> { + const playlistData = await file(PLAYLIST_PATH).json(); + const streams = new Map(); + + 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) { + const stream = streams.get(ws.data.streamId); + if (stream) stream.addClient(ws); + }, + close(ws: ServerWebSocket) { + const stream = streams.get(ws.data.streamId); + if (stream) stream.removeClient(ws); + }, + message(ws: ServerWebSocket, 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"); diff --git a/stream.ts b/stream.ts new file mode 100644 index 0000000..13f8315 --- /dev/null +++ b/stream.ts @@ -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> = 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); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e7541a2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["bun-types"], + "strict": true, + "esModuleInterop": true + } +}