localvibe 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/bin/cli.js +151 -0
- package/lib/config.js +34 -0
- package/lib/server.js +303 -0
- package/package.json +23 -0
- package/public/bg.jpg +0 -0
- package/public/hls.light.min.js +2 -0
- package/public/index.html +591 -0
- package/scripts/postinstall.js +45 -0
- package/scripts/sign-update.js +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# wavecast
|
|
2
|
+
|
|
3
|
+
Stream audio from your browser tab to every device on your Wi-Fi — one command, no config.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g wavecast
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
You also need [FFmpeg](https://ffmpeg.org):
|
|
12
|
+
|
|
13
|
+
| OS | Command |
|
|
14
|
+
|---|---|
|
|
15
|
+
| macOS | `brew install ffmpeg` |
|
|
16
|
+
| Windows | `winget install ffmpeg` |
|
|
17
|
+
| Linux | `sudo apt install ffmpeg` |
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
wavecast
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
First launch asks for your station name, DJ name and tagline.
|
|
26
|
+
A session key is printed in the terminal — paste it into the extension and click **Start Broadcast**.
|
|
27
|
+
Share the listener URL with anyone on the same Wi-Fi.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
wavecast setup # update station name, DJ, tagline
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Extension
|
|
34
|
+
|
|
35
|
+
Load the `extension/` folder as an unpacked Chrome extension:
|
|
36
|
+
|
|
37
|
+
1. Open `chrome://extensions`
|
|
38
|
+
2. Enable **Developer mode**
|
|
39
|
+
3. Click **Load unpacked** → select the `extension` folder
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- Plays on any device — Android, iPhone, desktop, smart TV
|
|
44
|
+
- No virtual audio driver — captures any browser tab directly
|
|
45
|
+
- Station name, DJ name and tagline on every listener's screen
|
|
46
|
+
- Live "Now Playing" auto-pulled from the tab title (YouTube, Spotify, etc.)
|
|
47
|
+
- Listener count shown on the player
|
|
48
|
+
- Session key rotates on every restart — no credentials stored on disk
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
MIT
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const readline = require('readline');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const https = require('https');
|
|
5
|
+
const http = require('http');
|
|
6
|
+
const { execSync } = require('child_process');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const config = require('../lib/config.js');
|
|
9
|
+
const { createServer } = require('../lib/server.js');
|
|
10
|
+
|
|
11
|
+
const PKG_VERSION = require('../package.json').version;
|
|
12
|
+
const cmd = process.argv[2] || 'start';
|
|
13
|
+
|
|
14
|
+
// ── Remote update check ───────────────────────────────────────────────────────
|
|
15
|
+
// The developer publishes version.json to this URL and signs it with UPDATE_KEY.
|
|
16
|
+
// CLI verifies the signature before trusting the payload — no one else can push
|
|
17
|
+
// a fake forceUpdate even if they intercept the URL.
|
|
18
|
+
const UPDATE_URL = process.env.LOCALVIBE_UPDATE_URL || 'https://gist.githubusercontent.com/JahidDev24/03697454cf67054981b8213598bc0b85/raw/1af98afafa8f02b68ba83d518ebae2d814a20bff/version.json';
|
|
19
|
+
const UPDATE_KEY = process.env.LOCALVIBE_UPDATE_KEY || 'localvibe-update-v1';
|
|
20
|
+
|
|
21
|
+
function verifyPayload(body) {
|
|
22
|
+
try {
|
|
23
|
+
const obj = JSON.parse(body);
|
|
24
|
+
const { sig, ...payload } = obj;
|
|
25
|
+
if (!sig) return null; // unsigned → ignore
|
|
26
|
+
const expected = crypto.createHmac('sha256', UPDATE_KEY)
|
|
27
|
+
.update(JSON.stringify(payload))
|
|
28
|
+
.digest('hex');
|
|
29
|
+
if (!crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))) return null;
|
|
30
|
+
return payload;
|
|
31
|
+
} catch (_) { return null; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fetchUpdate() {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
const get = UPDATE_URL.startsWith('https') ? https.get : http.get;
|
|
37
|
+
const req = get(UPDATE_URL, { timeout: 4000 }, (res) => {
|
|
38
|
+
let body = '';
|
|
39
|
+
res.on('data', d => { body += d; if (body.length > 4096) req.destroy(); });
|
|
40
|
+
res.on('end', () => resolve(verifyPayload(body)));
|
|
41
|
+
});
|
|
42
|
+
req.on('error', () => resolve(null));
|
|
43
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function semverGt(a, b) {
|
|
48
|
+
// returns true if a > b (simple major.minor.patch compare)
|
|
49
|
+
const pa = String(a).split('.').map(Number);
|
|
50
|
+
const pb = String(b).split('.').map(Number);
|
|
51
|
+
for (let i = 0; i < 3; i++) {
|
|
52
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return true;
|
|
53
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return false;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function checkUpdate() {
|
|
59
|
+
const update = await fetchUpdate();
|
|
60
|
+
if (!update) return; // network unavailable or bad signature → skip silently
|
|
61
|
+
|
|
62
|
+
if (update.forceUpdate && semverGt(update.version, PKG_VERSION)) {
|
|
63
|
+
console.error(`\n╔═══════════════════════════════════════════════════════╗`);
|
|
64
|
+
console.error(`║ 🚨 WAVECAST UPDATE REQUIRED ║`);
|
|
65
|
+
console.error(`║ Your version : ${PKG_VERSION.padEnd(38)}║`);
|
|
66
|
+
console.error(`║ Required : ${update.version.padEnd(38)}║`);
|
|
67
|
+
if (update.warning)
|
|
68
|
+
console.error(`║ ${update.warning.slice(0,52).padEnd(53)}║`);
|
|
69
|
+
console.error(`║ Run: npm install -g localvibe ║`);
|
|
70
|
+
console.error(`╚═══════════════════════════════════════════════════════╝\n`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (update.warning) {
|
|
75
|
+
console.warn(`\n⚠️ ${update.warning}\n`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (semverGt(update.version, PKG_VERSION)) {
|
|
79
|
+
console.log(`💡 New version ${update.version} available — npm install -g localvibe\n`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
84
|
+
function ensureFfmpeg() {
|
|
85
|
+
try { execSync('ffmpeg -version', { stdio: 'ignore' }); return true; }
|
|
86
|
+
catch (_) {
|
|
87
|
+
const install = process.platform === 'darwin' ? 'brew install ffmpeg'
|
|
88
|
+
: process.platform === 'win32' ? 'winget install ffmpeg'
|
|
89
|
+
: 'sudo apt install ffmpeg';
|
|
90
|
+
console.error(`\n⚠️ FFmpeg not found. Install it:\n\n ${install}\n`);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function ask(rl, question, fallback) {
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
rl.question(` ${question} (${fallback}): `, (a) => resolve(a.trim() || fallback));
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function runSetup(existing = {}) {
|
|
102
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
103
|
+
console.log('\n🎙 Welcome to Wavecast! Quick setup — press Enter to keep defaults.\n');
|
|
104
|
+
|
|
105
|
+
const cfg = {
|
|
106
|
+
stationName: await ask(rl, 'Station name', existing.stationName || 'Wavecast FM'),
|
|
107
|
+
djName: await ask(rl, 'Your name (DJ)', existing.djName || os.userInfo().username),
|
|
108
|
+
tagline: await ask(rl, 'Tagline', existing.tagline || 'Live from my desk'),
|
|
109
|
+
port: Number(await ask(rl, 'Port', String(existing.port || 8012))),
|
|
110
|
+
accent: existing.accent || '#6c63ff',
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
rl.close();
|
|
114
|
+
config.save(cfg);
|
|
115
|
+
console.log('\n✅ Saved!\n');
|
|
116
|
+
return cfg;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Commands ──────────────────────────────────────────────────────────────────
|
|
120
|
+
async function start() {
|
|
121
|
+
if (!ensureFfmpeg()) process.exit(1);
|
|
122
|
+
|
|
123
|
+
// Run update check in background — don't block startup unless forceUpdate
|
|
124
|
+
checkUpdate().catch(() => {});
|
|
125
|
+
|
|
126
|
+
let cfg = config.isFirstRun()
|
|
127
|
+
? await runSetup()
|
|
128
|
+
: config.load();
|
|
129
|
+
|
|
130
|
+
// Fresh key every session — never stored on disk
|
|
131
|
+
const sessionKey = crypto.randomBytes(20).toString('base64url');
|
|
132
|
+
|
|
133
|
+
createServer(cfg, sessionKey).start(sessionKey);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function setup() {
|
|
137
|
+
runSetup(config.isFirstRun() ? {} : config.load()).catch(console.error);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
switch (cmd) {
|
|
141
|
+
case 'start': start().catch(console.error); break;
|
|
142
|
+
case 'setup':
|
|
143
|
+
case 'init': setup(); break;
|
|
144
|
+
default:
|
|
145
|
+
console.log(`
|
|
146
|
+
🎙 localvibe — Wi-Fi audio streaming
|
|
147
|
+
|
|
148
|
+
localvibe start your station
|
|
149
|
+
localvibe setup change station name, DJ, tagline
|
|
150
|
+
`);
|
|
151
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = path.join(os.homedir(), '.localvibe');
|
|
6
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
7
|
+
|
|
8
|
+
const DEFAULTS = {
|
|
9
|
+
stationName: 'LocalVibe FM',
|
|
10
|
+
djName: '',
|
|
11
|
+
tagline: 'Live from my desk',
|
|
12
|
+
accent: '#6c63ff',
|
|
13
|
+
port: 8012,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function isFirstRun() {
|
|
17
|
+
return !fs.existsSync(CONFIG_FILE);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function load() {
|
|
21
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
22
|
+
let cfg = {};
|
|
23
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
24
|
+
try { cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch (_) {}
|
|
25
|
+
}
|
|
26
|
+
return { ...DEFAULTS, ...cfg };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function save(cfg) {
|
|
30
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
31
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { load, save, isFirstRun, CONFIG_FILE, CONFIG_DIR };
|
package/lib/server.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// server.js — LAN Radio core.
|
|
2
|
+
//
|
|
3
|
+
// Security model:
|
|
4
|
+
// • Mutating endpoints (/live, /live-chunk, /live-end, /api/nowplaying)
|
|
5
|
+
// require X-Broadcast-Key, verified as SHA-256 timing-safe compare.
|
|
6
|
+
// • Broadcast endpoints accept loopback connections only unless
|
|
7
|
+
// config.allowRemoteBroadcast is set.
|
|
8
|
+
// • Optional TLS (self-signed, SAN includes LAN IPs) for encrypted packets.
|
|
9
|
+
// • Failed-auth rate limiting, body-size caps, hardened response headers.
|
|
10
|
+
// • Public surface is read-only: player page, HLS files, status JSON.
|
|
11
|
+
|
|
12
|
+
const express = require('express');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const http = require('http');
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
const { spawn } = require('child_process');
|
|
19
|
+
|
|
20
|
+
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
|
|
21
|
+
const MAX_CHUNK_BYTES = 2 * 1024 * 1024; // one MediaRecorder chunk is ~32 KB
|
|
22
|
+
const MAX_BODY_BYTES = 16 * 1024; // for small JSON endpoints
|
|
23
|
+
|
|
24
|
+
function createServer(config, sessionKey) {
|
|
25
|
+
const tls = null; // HTTPS removed — key auth is the security boundary
|
|
26
|
+
const app = express();
|
|
27
|
+
app.disable('x-powered-by');
|
|
28
|
+
|
|
29
|
+
const HLS_DIR = path.join(os.tmpdir(), `localvibe-hls-${process.pid}`);
|
|
30
|
+
fs.mkdirSync(HLS_DIR, { recursive: true });
|
|
31
|
+
|
|
32
|
+
const keyHash = crypto.createHash('sha256').update(sessionKey).digest();
|
|
33
|
+
|
|
34
|
+
// ── State ────────────────────────────────────────────
|
|
35
|
+
let broadcasterConnected = false;
|
|
36
|
+
let broadcastStartTime = null;
|
|
37
|
+
let totalBytesIn = 0;
|
|
38
|
+
let chunkTimeout = null;
|
|
39
|
+
let ffmpegProc = null;
|
|
40
|
+
let nowPlaying = '';
|
|
41
|
+
const listenerSeen = new Map();
|
|
42
|
+
const authFailures = new Map(); // ip -> [timestamps]
|
|
43
|
+
|
|
44
|
+
// ── Hardened headers on everything ───────────────────
|
|
45
|
+
app.use((req, res, next) => {
|
|
46
|
+
res.header('Access-Control-Allow-Origin', '*');
|
|
47
|
+
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
48
|
+
res.header('Access-Control-Allow-Headers', 'Content-Type, X-Broadcast-Key');
|
|
49
|
+
res.header('X-Content-Type-Options', 'nosniff');
|
|
50
|
+
res.header('X-Frame-Options', 'DENY');
|
|
51
|
+
res.header('Referrer-Policy', 'no-referrer');
|
|
52
|
+
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
|
53
|
+
next();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ── Auth: SHA-256 timing-safe broadcast key ──────────
|
|
57
|
+
function rateLimited(ip) {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
const fails = (authFailures.get(ip) || []).filter((t) => now - t < 60000);
|
|
60
|
+
authFailures.set(ip, fails);
|
|
61
|
+
return fails.length >= 5;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function requireKey(req, res, next) {
|
|
65
|
+
const ip = req.socket.remoteAddress || '';
|
|
66
|
+
|
|
67
|
+
if (rateLimited(ip)) return res.status(429).json({ error: 'too many attempts' });
|
|
68
|
+
|
|
69
|
+
const provided = req.headers['x-broadcast-key'] || '';
|
|
70
|
+
const providedHash = crypto.createHash('sha256').update(String(provided)).digest();
|
|
71
|
+
if (!crypto.timingSafeEqual(providedHash, keyHash)) {
|
|
72
|
+
(authFailures.get(ip) || authFailures.set(ip, []).get(ip)).push(Date.now());
|
|
73
|
+
return res.status(401).json({ error: 'invalid broadcast key' });
|
|
74
|
+
}
|
|
75
|
+
next();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Collect a request body with a hard size cap
|
|
79
|
+
function readBody(req, res, cap, cb) {
|
|
80
|
+
const parts = [];
|
|
81
|
+
let size = 0;
|
|
82
|
+
req.on('data', (d) => {
|
|
83
|
+
size += d.length;
|
|
84
|
+
if (size > cap) { res.status(413).json({ error: 'payload too large' }); req.destroy(); return; }
|
|
85
|
+
parts.push(d);
|
|
86
|
+
});
|
|
87
|
+
req.on('end', () => { if (!res.headersSent) cb(Buffer.concat(parts)); });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── ffmpeg transcoder: audio in → HLS (fMP4/AAC) ─────
|
|
91
|
+
function startTranscoder() {
|
|
92
|
+
ffmpegProc = spawn('ffmpeg', [
|
|
93
|
+
'-loglevel', 'error',
|
|
94
|
+
'-analyzeduration', '0',
|
|
95
|
+
'-i', 'pipe:0',
|
|
96
|
+
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100', '-ac', '2',
|
|
97
|
+
'-af', 'asetpts=N/SR/TB',
|
|
98
|
+
'-f', 'hls',
|
|
99
|
+
'-hls_time', '2',
|
|
100
|
+
'-hls_list_size', '6',
|
|
101
|
+
'-hls_flags', 'delete_segments+append_list+discont_start',
|
|
102
|
+
'-hls_segment_type', 'fmp4',
|
|
103
|
+
'-hls_fmp4_init_filename', 'init.mp4',
|
|
104
|
+
'-hls_segment_filename', path.join(HLS_DIR, 'seg%06d.m4s'),
|
|
105
|
+
path.join(HLS_DIR, 'live.m3u8'),
|
|
106
|
+
]);
|
|
107
|
+
ffmpegProc.stderr.on('data', (d) => console.error(' ffmpeg:', d.toString().trim()));
|
|
108
|
+
ffmpegProc.stdin.on('error', () => {});
|
|
109
|
+
ffmpegProc.on('close', () => { ffmpegProc = null; });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function stopTranscoder() {
|
|
113
|
+
if (ffmpegProc) {
|
|
114
|
+
const proc = ffmpegProc;
|
|
115
|
+
ffmpegProc = null;
|
|
116
|
+
try { proc.stdin.end(); } catch (_) {}
|
|
117
|
+
setTimeout(() => { try { proc.kill('SIGKILL'); } catch (_) {} }, 500);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function wipeHlsDir() {
|
|
122
|
+
for (const f of fs.readdirSync(HLS_DIR)) {
|
|
123
|
+
try { fs.unlinkSync(path.join(HLS_DIR, f)); } catch (_) {}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function resetBroadcast() {
|
|
128
|
+
broadcasterConnected = false;
|
|
129
|
+
broadcastStartTime = null;
|
|
130
|
+
stopTranscoder();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isWebmHeader(chunk) {
|
|
134
|
+
return chunk.length >= 4 &&
|
|
135
|
+
chunk[0] === 0x1a && chunk[1] === 0x45 &&
|
|
136
|
+
chunk[2] === 0xdf && chunk[3] === 0xa3;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Broadcast endpoints (key-protected) ──────────────
|
|
140
|
+
app.post('/live', requireKey, (req, res) => {
|
|
141
|
+
if (ffmpegProc) stopTranscoder();
|
|
142
|
+
wipeHlsDir();
|
|
143
|
+
broadcasterConnected = true;
|
|
144
|
+
broadcastStartTime = Date.now();
|
|
145
|
+
totalBytesIn = 0;
|
|
146
|
+
startTranscoder();
|
|
147
|
+
console.log('\n🎙 Broadcaster connected (direct stream)');
|
|
148
|
+
|
|
149
|
+
req.on('data', (chunk) => {
|
|
150
|
+
totalBytesIn += chunk.length;
|
|
151
|
+
try { ffmpegProc?.stdin.write(chunk); } catch (_) {}
|
|
152
|
+
});
|
|
153
|
+
req.on('end', () => { resetBroadcast(); console.log('\n🔇 Broadcaster disconnected'); res.status(200).end(); });
|
|
154
|
+
req.on('error', resetBroadcast);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
app.post('/live-chunk', requireKey, (req, res) => {
|
|
158
|
+
readBody(req, res, MAX_CHUNK_BYTES, (chunk) => {
|
|
159
|
+
if (isWebmHeader(chunk)) {
|
|
160
|
+
if (ffmpegProc) stopTranscoder();
|
|
161
|
+
if (!broadcasterConnected) {
|
|
162
|
+
wipeHlsDir();
|
|
163
|
+
broadcasterConnected = true;
|
|
164
|
+
broadcastStartTime = Date.now();
|
|
165
|
+
totalBytesIn = 0;
|
|
166
|
+
console.log('\n🎙 Broadcaster connected (extension)');
|
|
167
|
+
}
|
|
168
|
+
startTranscoder();
|
|
169
|
+
} else if (!ffmpegProc) {
|
|
170
|
+
return res.status(200).json({ needInit: true });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
clearTimeout(chunkTimeout);
|
|
174
|
+
chunkTimeout = setTimeout(() => {
|
|
175
|
+
resetBroadcast();
|
|
176
|
+
console.log('\n🔇 Broadcaster timed out');
|
|
177
|
+
}, 3000);
|
|
178
|
+
|
|
179
|
+
totalBytesIn += chunk.length;
|
|
180
|
+
try { ffmpegProc?.stdin.write(chunk); } catch (_) {}
|
|
181
|
+
res.status(200).json({ ok: true });
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
app.post('/live-end', requireKey, (_req, res) => {
|
|
186
|
+
clearTimeout(chunkTimeout);
|
|
187
|
+
resetBroadcast();
|
|
188
|
+
nowPlaying = '';
|
|
189
|
+
console.log('\n🔇 Broadcaster disconnected');
|
|
190
|
+
res.status(200).end();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
app.post('/api/nowplaying', requireKey, (req, res) => {
|
|
194
|
+
readBody(req, res, MAX_BODY_BYTES, (body) => {
|
|
195
|
+
try {
|
|
196
|
+
const { text } = JSON.parse(body.toString('utf8'));
|
|
197
|
+
nowPlaying = String(text || '').slice(0, 120);
|
|
198
|
+
res.json({ ok: true, nowPlaying });
|
|
199
|
+
} catch (_) {
|
|
200
|
+
res.status(400).json({ error: 'bad json' });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ── Public read-only endpoints ───────────────────────
|
|
206
|
+
app.use('/hls', (req, res, next) => {
|
|
207
|
+
if (req.path.endsWith('.m3u8')) {
|
|
208
|
+
listenerSeen.set(req.ip, Date.now());
|
|
209
|
+
res.setHeader('Cache-Control', 'no-cache, no-store');
|
|
210
|
+
} else {
|
|
211
|
+
res.setHeader('Cache-Control', 'max-age=60');
|
|
212
|
+
}
|
|
213
|
+
next();
|
|
214
|
+
}, express.static(HLS_DIR));
|
|
215
|
+
|
|
216
|
+
app.get('/status', (_req, res) => {
|
|
217
|
+
const now = Date.now();
|
|
218
|
+
let n = 0;
|
|
219
|
+
for (const [ip, t] of listenerSeen) {
|
|
220
|
+
if (now - t < 15000) n++;
|
|
221
|
+
else listenerSeen.delete(ip);
|
|
222
|
+
}
|
|
223
|
+
res.json({
|
|
224
|
+
broadcasting: broadcasterConnected,
|
|
225
|
+
listeners: n,
|
|
226
|
+
uptimeSeconds: broadcastStartTime ? Math.floor((now - broadcastStartTime) / 1000) : 0,
|
|
227
|
+
nowPlaying,
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
app.get('/api/info', (_req, res) => {
|
|
232
|
+
const ip = getLocalIP();
|
|
233
|
+
res.json({
|
|
234
|
+
stationName: config.stationName,
|
|
235
|
+
djName: config.djName,
|
|
236
|
+
tagline: config.tagline,
|
|
237
|
+
accent: config.accent,
|
|
238
|
+
urls: {
|
|
239
|
+
listen: `http://${ip}:${config.port}`,
|
|
240
|
+
listenSecure: config.https ? `https://${ip}:${config.httpsPort}` : null,
|
|
241
|
+
// mDNS name — resolves on Apple devices, Windows 10+, Android 12+
|
|
242
|
+
local: `http://${os.hostname()}:${config.port}`,
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
app.get('/', (_req, res) => {
|
|
248
|
+
res.set('Cache-Control', 'no-store');
|
|
249
|
+
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
|
|
250
|
+
});
|
|
251
|
+
app.get('/hls.light.min.js', (_req, res) => {
|
|
252
|
+
res.set('Cache-Control', 'no-store');
|
|
253
|
+
res.sendFile(path.join(PUBLIC_DIR, 'hls.light.min.js'));
|
|
254
|
+
});
|
|
255
|
+
app.get('/bg.jpg', (_req, res) => {
|
|
256
|
+
res.set('Cache-Control', 'max-age=86400');
|
|
257
|
+
res.sendFile(path.join(PUBLIC_DIR, 'bg.jpg'));
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ── Boot ─────────────────────────────────────────────
|
|
261
|
+
function start(sessionKey) {
|
|
262
|
+
const ip = getLocalIP();
|
|
263
|
+
const host = os.hostname();
|
|
264
|
+
|
|
265
|
+
http.createServer(app).listen(config.port, '0.0.0.0').on('error', (e) => {
|
|
266
|
+
if (e.code === 'EADDRINUSE') {
|
|
267
|
+
console.error(`\n❌ Port ${config.port} is already in use.`);
|
|
268
|
+
console.error(` Kill it: lsof -ti :${config.port} | xargs kill -9\n`);
|
|
269
|
+
} else { console.error(e.message); }
|
|
270
|
+
process.exit(1);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const W = 54;
|
|
274
|
+
const pad = (s) => ('║ ' + s).padEnd(W - 1) + '║';
|
|
275
|
+
const line = '═'.repeat(W - 2);
|
|
276
|
+
console.log(`\n╔${line}╗`);
|
|
277
|
+
console.log(pad(`📻 ${config.stationName}`));
|
|
278
|
+
if (config.djName) console.log(pad(`🎧 DJ ${config.djName}`));
|
|
279
|
+
if (config.tagline) console.log(pad(`✨ ${config.tagline}`));
|
|
280
|
+
console.log(`╠${line}╣`);
|
|
281
|
+
console.log(pad(`Listen: http://${ip}:${config.port}`));
|
|
282
|
+
console.log(pad(` http://${host}:${config.port}`));
|
|
283
|
+
console.log(`╠${line}╣`);
|
|
284
|
+
console.log(pad(`🔑 Session key (paste into extension):`));
|
|
285
|
+
console.log(pad(` ${sessionKey}`));
|
|
286
|
+
console.log(`╚${line}╝`);
|
|
287
|
+
console.log('\n ↑ Copy the key → open extension → paste → Start Broadcast\n');
|
|
288
|
+
console.log('Waiting for broadcaster…\n');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { app, start };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function getLocalIP() {
|
|
295
|
+
for (const ifaces of Object.values(os.networkInterfaces())) {
|
|
296
|
+
for (const iface of ifaces) {
|
|
297
|
+
if (iface.family === 'IPv4' && !iface.internal) return iface.address;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return 'localhost';
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
module.exports = { createServer };
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "localvibe",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Stream audio from your browser tab to every device on your Wi-Fi — one command, no config.",
|
|
5
|
+
"keywords": ["radio", "streaming", "hls", "lan", "audio", "broadcast", "wifi", "ffmpeg"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "",
|
|
8
|
+
"main": "lib/server.js",
|
|
9
|
+
"bin": {
|
|
10
|
+
"localvibe": "bin/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"files": ["bin", "lib", "public", "scripts", "README.md"],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"start": "node bin/cli.js",
|
|
18
|
+
"postinstall": "node scripts/postinstall.js"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"express": "^4.19.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/public/bg.jpg
ADDED
|
Binary file
|