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.
@@ -0,0 +1,591 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>LocalVibe</title>
7
+ <link rel="icon" href="/bg.jpg" type="image/jpeg" />
8
+ <style>
9
+ @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;900&display=swap');
10
+
11
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
12
+
13
+ :root {
14
+ --accent: #6c63ff;
15
+ --accent2: #ff6584;
16
+ --bg: #0b0b14;
17
+ --card: rgba(255,255,255,0.05);
18
+ --border: rgba(255,255,255,0.08);
19
+ --text: #f0eeff;
20
+ --muted: rgba(240,238,255,0.45);
21
+ }
22
+
23
+ html, body { height: 100%; }
24
+
25
+ body {
26
+ font-family: 'Outfit', sans-serif;
27
+ background: var(--bg);
28
+ color: var(--text);
29
+ display: flex;
30
+ align-items: center;
31
+ justify-content: center;
32
+ min-height: 100dvh;
33
+ overflow: hidden;
34
+ position: relative;
35
+ }
36
+
37
+ /* ── Background photo + dark overlay ── */
38
+ .bg-photo {
39
+ position: fixed;
40
+ inset: 0;
41
+ z-index: 0;
42
+ background: url('/bg.jpg') center center / cover no-repeat;
43
+ }
44
+ .bg-photo::after {
45
+ content: '';
46
+ position: absolute;
47
+ inset: 0;
48
+ background: rgba(8, 8, 18, 0.82);
49
+ }
50
+
51
+ /* ── Gradient tint on top of photo ── */
52
+ .bg {
53
+ position: fixed;
54
+ inset: 0;
55
+ z-index: 1;
56
+ background:
57
+ radial-gradient(ellipse 80% 60% at 20% 40%, rgba(108,99,255,0.14) 0%, transparent 60%),
58
+ radial-gradient(ellipse 60% 50% at 80% 70%, rgba(255,101,132,0.10) 0%, transparent 55%);
59
+ }
60
+
61
+ /* Floating blobs */
62
+ .blob {
63
+ position: fixed;
64
+ border-radius: 50%;
65
+ filter: blur(80px);
66
+ opacity: 0;
67
+ animation: float 20s ease-in-out infinite;
68
+ z-index: 2;
69
+ transition: opacity 1.5s;
70
+ }
71
+ .blob1 { width: 400px; height: 400px; background: var(--accent); top: -10%; left: -10%; animation-delay: 0s; }
72
+ .blob2 { width: 300px; height: 300px; background: var(--accent2); bottom: -5%; right: -5%; animation-delay: -7s; }
73
+ .blob3 { width: 250px; height: 250px; background: #00d2ff; top: 50%; left: 60%; animation-delay: -14s; }
74
+ body.playing .blob { opacity: 0.5; }
75
+
76
+ @keyframes float {
77
+ 0%, 100% { transform: translate(0, 0) scale(1); }
78
+ 33% { transform: translate(30px, -20px) scale(1.05); }
79
+ 66% { transform: translate(-20px, 30px) scale(0.95); }
80
+ }
81
+
82
+ /* ── Grid of people illustrations (CSS art) ── */
83
+ .people-row {
84
+ position: fixed;
85
+ bottom: 0;
86
+ left: 0;
87
+ right: 0;
88
+ height: 110px;
89
+ display: flex;
90
+ align-items: flex-end;
91
+ justify-content: center;
92
+ gap: 18px;
93
+ padding-bottom: 0;
94
+ opacity: 0.22;
95
+ z-index: 3;
96
+ transition: opacity 1s;
97
+ }
98
+ body.playing .people-row { opacity: 0.35; }
99
+
100
+ .person {
101
+ display: flex;
102
+ flex-direction: column;
103
+ align-items: center;
104
+ gap: 0;
105
+ animation: bop 1.8s ease-in-out infinite;
106
+ }
107
+ .person:nth-child(2) { animation-delay: -0.3s; }
108
+ .person:nth-child(3) { animation-delay: -0.6s; }
109
+ .person:nth-child(4) { animation-delay: -0.9s; }
110
+ .person:nth-child(5) { animation-delay: -1.2s; }
111
+ .person:nth-child(6) { animation-delay: -0.45s; }
112
+ body:not(.playing) .person { animation-play-state: paused; }
113
+
114
+ @keyframes bop {
115
+ 0%, 100% { transform: translateY(0); }
116
+ 50% { transform: translateY(-6px); }
117
+ }
118
+
119
+ /* SVG-based person silhouette with headphones */
120
+ .person svg { width: 44px; height: 90px; }
121
+
122
+ /* ── Main card ── */
123
+ .card {
124
+ position: relative;
125
+ z-index: 4;
126
+ width: 360px;
127
+ max-width: 94vw;
128
+ background: var(--card);
129
+ border: 1px solid var(--border);
130
+ border-radius: 28px;
131
+ padding: 40px 36px 36px;
132
+ backdrop-filter: blur(24px);
133
+ -webkit-backdrop-filter: blur(24px);
134
+ box-shadow: 0 8px 60px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.08);
135
+ }
136
+
137
+ /* ── On-air badge ── */
138
+ .onair {
139
+ display: inline-flex;
140
+ align-items: center;
141
+ gap: 6px;
142
+ font-size: 0.65rem;
143
+ font-weight: 600;
144
+ letter-spacing: 0.18em;
145
+ text-transform: uppercase;
146
+ color: var(--muted);
147
+ margin-bottom: 18px;
148
+ }
149
+ .onair .pulse {
150
+ width: 6px; height: 6px;
151
+ border-radius: 50%;
152
+ background: var(--muted);
153
+ transition: background 0.4s, box-shadow 0.4s;
154
+ }
155
+ body.playing .onair .pulse {
156
+ background: #22c55e;
157
+ box-shadow: 0 0 8px #22c55e;
158
+ animation: blink 2s infinite;
159
+ }
160
+ @keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.4} }
161
+
162
+ /* ── Station name ── */
163
+ .station-name {
164
+ font-size: 2rem;
165
+ font-weight: 900;
166
+ line-height: 1.1;
167
+ letter-spacing: -0.02em;
168
+ margin-bottom: 4px;
169
+ background: linear-gradient(135deg, var(--text) 0%, rgba(240,238,255,0.6) 100%);
170
+ -webkit-background-clip: text;
171
+ -webkit-text-fill-color: transparent;
172
+ background-clip: text;
173
+ transition: all 0.4s;
174
+ }
175
+ body.playing .station-name {
176
+ background: linear-gradient(135deg, #fff 0%, var(--accent) 100%);
177
+ -webkit-background-clip: text;
178
+ background-clip: text;
179
+ }
180
+
181
+ .dj-name {
182
+ font-size: 0.82rem;
183
+ font-weight: 500;
184
+ color: var(--muted);
185
+ margin-bottom: 2px;
186
+ }
187
+
188
+ .tagline {
189
+ font-size: 0.75rem;
190
+ color: rgba(240,238,255,0.3);
191
+ margin-bottom: 28px;
192
+ }
193
+
194
+ /* ── Now Playing ── */
195
+ .nowplaying {
196
+ background: rgba(255,255,255,0.04);
197
+ border: 1px solid var(--border);
198
+ border-radius: 14px;
199
+ padding: 12px 16px;
200
+ margin-bottom: 28px;
201
+ min-height: 52px;
202
+ display: flex;
203
+ align-items: center;
204
+ gap: 12px;
205
+ }
206
+ .np-icon {
207
+ font-size: 1.4rem;
208
+ flex-shrink: 0;
209
+ opacity: 0.7;
210
+ }
211
+ .np-text { flex: 1; min-width: 0; }
212
+ .np-label {
213
+ font-size: 0.58rem;
214
+ font-weight: 600;
215
+ letter-spacing: 0.18em;
216
+ text-transform: uppercase;
217
+ color: var(--accent);
218
+ margin-bottom: 3px;
219
+ }
220
+ .np-title {
221
+ font-size: 0.85rem;
222
+ font-weight: 500;
223
+ white-space: nowrap;
224
+ overflow: hidden;
225
+ text-overflow: ellipsis;
226
+ color: var(--text);
227
+ }
228
+ .np-empty {
229
+ font-size: 0.82rem;
230
+ color: var(--muted);
231
+ }
232
+
233
+ /* ── Visualizer bars ── */
234
+ .viz {
235
+ display: flex;
236
+ align-items: flex-end;
237
+ justify-content: center;
238
+ gap: 3px;
239
+ height: 40px;
240
+ margin-bottom: 24px;
241
+ }
242
+ .bar {
243
+ width: 4px;
244
+ border-radius: 2px;
245
+ background: var(--accent);
246
+ height: 4px;
247
+ opacity: 0.3;
248
+ transition: height 0.08s ease, opacity 0.08s ease;
249
+ }
250
+ body.playing .bar { opacity: 1; }
251
+
252
+ /* ── Play button ── */
253
+ .controls {
254
+ display: flex;
255
+ align-items: center;
256
+ justify-content: center;
257
+ gap: 20px;
258
+ margin-bottom: 28px;
259
+ }
260
+
261
+ .play-btn {
262
+ width: 68px;
263
+ height: 68px;
264
+ border-radius: 50%;
265
+ border: none;
266
+ background: var(--accent);
267
+ cursor: pointer;
268
+ display: flex;
269
+ align-items: center;
270
+ justify-content: center;
271
+ box-shadow: 0 4px 24px rgba(108,99,255,0.4);
272
+ transition: transform 0.15s, box-shadow 0.3s, background 0.3s;
273
+ outline: none;
274
+ }
275
+ .play-btn:hover { transform: scale(1.07); box-shadow: 0 6px 32px rgba(108,99,255,0.6); }
276
+ .play-btn:active { transform: scale(0.95); }
277
+ body.playing .play-btn { background: var(--accent2); box-shadow: 0 4px 24px rgba(255,101,132,0.45); }
278
+
279
+ .play-btn svg { width: 26px; height: 26px; fill: #fff; }
280
+
281
+ /* ── Status row ── */
282
+ .status-row {
283
+ display: flex;
284
+ align-items: center;
285
+ justify-content: space-between;
286
+ font-size: 0.72rem;
287
+ color: var(--muted);
288
+ }
289
+
290
+ .status-left { display: flex; align-items: center; gap: 6px; }
291
+
292
+ .dot {
293
+ width: 6px; height: 6px;
294
+ border-radius: 50%;
295
+ background: var(--muted);
296
+ transition: background 0.4s;
297
+ }
298
+ .dot.live { background: #22c55e; }
299
+ .dot.offline { background: #ef4444; }
300
+
301
+ .listeners-badge {
302
+ font-size: 0.68rem;
303
+ font-weight: 600;
304
+ background: rgba(255,255,255,0.06);
305
+ border: 1px solid var(--border);
306
+ border-radius: 20px;
307
+ padding: 3px 10px;
308
+ color: var(--muted);
309
+ }
310
+
311
+ .dbg {
312
+ text-align: center;
313
+ margin-top: 10px;
314
+ font-size: 0.62rem;
315
+ color: rgba(240,238,255,0.25);
316
+ min-height: 1em;
317
+ }
318
+
319
+ @media (max-width: 400px) {
320
+ .card { padding: 32px 24px 28px; }
321
+ .station-name { font-size: 1.7rem; }
322
+ }
323
+ </style>
324
+ </head>
325
+ <body>
326
+ <!-- Background -->
327
+ <div class="bg-photo"></div>
328
+ <div class="bg"></div>
329
+ <div class="blob blob1"></div>
330
+ <div class="blob blob2"></div>
331
+ <div class="blob blob3"></div>
332
+
333
+ <!-- People silhouettes with headphones -->
334
+ <div class="people-row" id="people">
335
+ <!-- injected by JS -->
336
+ </div>
337
+
338
+ <!-- Player card -->
339
+ <div class="card">
340
+ <div class="onair">
341
+ <span class="pulse"></span>
342
+ <span id="onairLabel">On Air</span>
343
+ </div>
344
+
345
+ <div class="station-name" id="stationName">LocalVibe FM</div>
346
+ <div class="dj-name" id="djName"></div>
347
+ <div class="tagline" id="tagline"></div>
348
+
349
+ <div class="nowplaying" id="nowPlayingBox">
350
+ <span class="np-icon">🎵</span>
351
+ <span class="np-empty">Waiting for broadcast…</span>
352
+ </div>
353
+
354
+ <div class="viz" id="viz"></div>
355
+
356
+ <div class="controls">
357
+ <button class="play-btn" id="playBtn" aria-label="Play / Pause">
358
+ <svg id="iconPlay" viewBox="0 0 24 24"><polygon points="6,3 20,12 6,21"/></svg>
359
+ <svg id="iconPause" viewBox="0 0 24 24" style="display:none">
360
+ <rect x="5" y="3" width="4" height="18" rx="1"/>
361
+ <rect x="15" y="3" width="4" height="18" rx="1"/>
362
+ </svg>
363
+ </button>
364
+ </div>
365
+
366
+ <div class="status-row">
367
+ <div class="status-left">
368
+ <span class="dot" id="dot"></span>
369
+ <span id="statusText">Checking…</span>
370
+ </div>
371
+ <span class="listeners-badge" id="listenersBadge" style="display:none"></span>
372
+ </div>
373
+
374
+ <div class="dbg" id="dbg"></div>
375
+ </div>
376
+
377
+ <audio id="radio" preload="none" crossorigin="anonymous"></audio>
378
+ <script src="/hls.light.min.js"></script>
379
+ <script>
380
+ // ── People silhouettes ─────────────────────────────
381
+ const PERSON_SVG = `<svg viewBox="0 0 44 90" fill="none" xmlns="http://www.w3.org/2000/svg">
382
+ <!-- head -->
383
+ <circle cx="22" cy="16" r="9" fill="white"/>
384
+ <!-- headphone arc -->
385
+ <path d="M13 16 Q13 7 22 7 Q31 7 31 16" stroke="white" stroke-width="2.5" fill="none" stroke-linecap="round"/>
386
+ <!-- headphone cups -->
387
+ <rect x="10" y="14" width="6" height="9" rx="3" fill="white"/>
388
+ <rect x="28" y="14" width="6" height="9" rx="3" fill="white"/>
389
+ <!-- body -->
390
+ <path d="M13 36 Q10 28 22 26 Q34 28 31 36 L33 65 Q33 68 30 68 L14 68 Q11 68 11 65 Z" fill="white"/>
391
+ <!-- legs -->
392
+ <rect x="13" y="66" width="7" height="24" rx="3.5" fill="white"/>
393
+ <rect x="24" y="66" width="7" height="24" rx="3.5" fill="white"/>
394
+ </svg>`;
395
+
396
+ const peopleRow = document.getElementById('people');
397
+ const COLORS = ['#6c63ff','#ff6584','#00d2ff','#ffbe76','#a29bfe','#fd79a8'];
398
+ for (let i = 0; i < 6; i++) {
399
+ const p = document.createElement('div');
400
+ p.className = 'person';
401
+ p.innerHTML = PERSON_SVG.replace(/fill="white"/g, `fill="${COLORS[i % COLORS.length]}"`);
402
+ peopleRow.appendChild(p);
403
+ }
404
+
405
+ // ── Viz bars ──────────────────────────────────────
406
+ const NUM_BARS = 28;
407
+ const vizEl = document.getElementById('viz');
408
+ const bars = [];
409
+ for (let i = 0; i < NUM_BARS; i++) {
410
+ const b = document.createElement('div');
411
+ b.className = 'bar';
412
+ vizEl.appendChild(b);
413
+ bars.push(b);
414
+ }
415
+
416
+ // ── Elements ──────────────────────────────────────
417
+ const playBtn = document.getElementById('playBtn');
418
+ const iconPlay = document.getElementById('iconPlay');
419
+ const iconPause = document.getElementById('iconPause');
420
+ const dot = document.getElementById('dot');
421
+ const statusText = document.getElementById('statusText');
422
+ const dbgEl = document.getElementById('dbg');
423
+
424
+ let audioCtx = null, analyser = null, sourceNode = null, dataArray = null;
425
+ let playing = false, vizRAF = null;
426
+ let wantPlay = false, currentlyBroadcasting = false;
427
+
428
+ function dbg(msg) { dbgEl.textContent = msg; }
429
+
430
+ // ── Branding from server ───────────────────────────
431
+ fetch('/api/info').then(r => r.json()).then(info => {
432
+ document.getElementById('stationName').textContent = info.stationName || 'LocalVibe FM';
433
+ document.title = info.stationName || 'LocalVibe FM';
434
+ document.getElementById('djName').textContent = info.djName ? `DJ ${info.djName}` : '';
435
+ document.getElementById('tagline').textContent = info.tagline || '';
436
+ document.getElementById('onairLabel').textContent = info.djName
437
+ ? `${info.djName} · On Air` : 'On Air';
438
+ if (info.accent) {
439
+ const c = info.accent;
440
+ document.documentElement.style.setProperty('--accent', c);
441
+ const m = c.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
442
+ if (m) {
443
+ const [r,g,b] = m.slice(1).map(h => parseInt(h,16));
444
+ document.documentElement.style.setProperty('--accent2', `rgb(${255-r},${Math.round(g*0.6)},${Math.round(b*1.3)})`);
445
+ }
446
+ }
447
+ }).catch(() => {});
448
+
449
+ // ── HLS playback ──────────────────────────────────
450
+ const SRC = '/hls/live.m3u8';
451
+ let hls = null;
452
+
453
+ playBtn.addEventListener('click', () => wantPlay ? stop() : userPlay());
454
+
455
+ function userPlay() {
456
+ wantPlay = true;
457
+ if (currentlyBroadcasting) {
458
+ play();
459
+ } else {
460
+ dbg('waiting for broadcast to start…');
461
+ statusText.textContent = 'Waiting for broadcast…';
462
+ }
463
+ }
464
+
465
+ function play() {
466
+ dbg('connecting…');
467
+ if (window.Hls && Hls.isSupported()) {
468
+ if (hls) { hls.destroy(); hls = null; }
469
+ hls = new Hls({ liveSyncDurationCount: 3, maxBufferLength: 20 });
470
+ hls.loadSource(SRC);
471
+ hls.attachMedia(document.getElementById('radio'));
472
+ hls.on(Hls.Events.ERROR, (_, data) => {
473
+ if (!data.fatal) return;
474
+ hls.destroy(); hls = null;
475
+ if (wantPlay) { dbg('reconnecting…'); setTimeout(() => wantPlay && play(), 2000); }
476
+ });
477
+ } else if (document.getElementById('radio').canPlayType('application/vnd.apple.mpegurl')) {
478
+ document.getElementById('radio').src = SRC;
479
+ } else {
480
+ dbg('browser lacks HLS support'); return;
481
+ }
482
+ document.getElementById('radio').play().catch(() => dbg('tap play to allow audio'));
483
+ }
484
+
485
+ function stop() {
486
+ wantPlay = false; playing = false;
487
+ document.body.classList.remove('playing');
488
+ iconPlay.style.display = 'block';
489
+ iconPause.style.display = 'none';
490
+ if (hls) { hls.destroy(); hls = null; }
491
+ const audio = document.getElementById('radio');
492
+ audio.pause(); audio.removeAttribute('src'); audio.load();
493
+ if (vizRAF) cancelAnimationFrame(vizRAF);
494
+ bars.forEach(b => { b.style.height = '4px'; b.style.opacity = '0.3'; });
495
+ dbg('');
496
+ }
497
+
498
+ const audio = document.getElementById('radio');
499
+ audio.addEventListener('playing', () => {
500
+ playing = true;
501
+ document.body.classList.add('playing');
502
+ iconPlay.style.display = 'none';
503
+ iconPause.style.display = 'block';
504
+ dbg('');
505
+ setupAnalyser(); animateViz();
506
+ });
507
+ audio.addEventListener('waiting', () => dbg('buffering…'));
508
+
509
+ // ── Visualizer ────────────────────────────────────
510
+ function setupAnalyser() {
511
+ try {
512
+ if (!audioCtx) {
513
+ audioCtx = new (window.AudioContext || window.webkitAudioContext)();
514
+ sourceNode = audioCtx.createMediaElementSource(audio);
515
+ analyser = audioCtx.createAnalyser();
516
+ analyser.fftSize = 64;
517
+ sourceNode.connect(analyser);
518
+ analyser.connect(audioCtx.destination);
519
+ dataArray = new Uint8Array(analyser.frequencyBinCount);
520
+ }
521
+ audioCtx.resume();
522
+ } catch(_) {}
523
+ }
524
+
525
+ function animateViz() {
526
+ if (!playing) return;
527
+ vizRAF = requestAnimationFrame(animateViz);
528
+ if (analyser && dataArray) {
529
+ analyser.getByteFrequencyData(dataArray);
530
+ for (let i = 0; i < NUM_BARS; i++) {
531
+ const idx = Math.floor(i * dataArray.length / NUM_BARS);
532
+ const v = dataArray[idx] / 255;
533
+ bars[i].style.height = `${4 + v * 36}px`;
534
+ }
535
+ }
536
+ }
537
+
538
+ // ── Status polling ────────────────────────────────
539
+ function escapeHtml(s) {
540
+ return String(s).replace(/[&<>"']/g, c =>
541
+ ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
542
+ }
543
+
544
+ async function pollStatus() {
545
+ try {
546
+ const data = await fetch('/status').then(r => r.json());
547
+ const wasBroadcasting = currentlyBroadcasting;
548
+ currentlyBroadcasting = data.broadcasting;
549
+
550
+ if (data.broadcasting) {
551
+ dot.className = 'dot live';
552
+ statusText.textContent = playing ? 'Live' : 'On air — tap play';
553
+ if (!wasBroadcasting && wantPlay && !playing) play();
554
+ } else {
555
+ dot.className = 'dot offline';
556
+ statusText.textContent = 'Off air';
557
+ if (playing) stop();
558
+ }
559
+
560
+ // Now Playing box
561
+ const box = document.getElementById('nowPlayingBox');
562
+ if (data.nowPlaying) {
563
+ box.innerHTML = `<span class="np-icon">🎵</span>
564
+ <span class="np-text">
565
+ <div class="np-label">Now Playing</div>
566
+ <div class="np-title">${escapeHtml(data.nowPlaying)}</div>
567
+ </span>`;
568
+ } else {
569
+ box.innerHTML = `<span class="np-icon">🎵</span>
570
+ <span class="np-empty">${data.broadcasting ? 'Live audio streaming…' : 'Waiting for broadcast…'}</span>`;
571
+ }
572
+
573
+ // Listeners badge
574
+ const badge = document.getElementById('listenersBadge');
575
+ if (data.listeners > 0) {
576
+ badge.style.display = 'inline';
577
+ badge.textContent = `${data.listeners} listening`;
578
+ } else {
579
+ badge.style.display = 'none';
580
+ }
581
+ } catch(_) {
582
+ dot.className = 'dot offline';
583
+ statusText.textContent = 'Server unreachable';
584
+ }
585
+ }
586
+
587
+ pollStatus();
588
+ setInterval(pollStatus, 3000);
589
+ </script>
590
+ </body>
591
+ </html>
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ // Runs once after `npm install -g lanradio` — checks the environment and
3
+ // shows exactly what to do next, tailored to the user's OS.
4
+
5
+ const { execSync } = require('child_process');
6
+
7
+ function hasFfmpeg() {
8
+ try { execSync('ffmpeg -version', { stdio: 'ignore' }); return true; }
9
+ catch (_) { return false; }
10
+ }
11
+
12
+ function ffmpegCmd() {
13
+ switch (process.platform) {
14
+ case 'darwin': return 'brew install ffmpeg';
15
+ case 'win32': return 'winget install ffmpeg';
16
+ default: return 'sudo apt install ffmpeg';
17
+ }
18
+ }
19
+
20
+ const nodeMajor = Number(process.versions.node.split('.')[0]);
21
+ const ffmpegOk = hasFfmpeg();
22
+
23
+ console.log(`
24
+ ┌─────────────────────────────────────────────────┐
25
+ │ 📻 localvibe installed successfully! │
26
+ └─────────────────────────────────────────────────┘
27
+ `);
28
+
29
+ if (nodeMajor < 18) {
30
+ console.log(` ⚠️ Node.js ${process.versions.node} detected — lanradio needs v18+.`);
31
+ console.log(` Update at https://nodejs.org\n`);
32
+ }
33
+
34
+ if (ffmpegOk) {
35
+ console.log(' ✅ FFmpeg found\n');
36
+ } else {
37
+ console.log(' ⚠️ FFmpeg not found — install it first:\n');
38
+ console.log(` ${ffmpegCmd()}\n`);
39
+ }
40
+
41
+ console.log(` Get started:
42
+
43
+ localvibe first launch runs setup automatically
44
+ localvibe setup update station name, DJ, tagline
45
+ `);
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ // Usage: node scripts/sign-update.js <version> [warning] [--force]
3
+ // Outputs a signed version.json to stdout. Commit it to your repo.
4
+ //
5
+ // Examples:
6
+ // node scripts/sign-update.js 2.1.0
7
+ // node scripts/sign-update.js 2.1.0 "Critical security fix, please update"
8
+ // node scripts/sign-update.js 2.1.0 "Breaking change" --force
9
+
10
+ const crypto = require('crypto');
11
+ const UPDATE_KEY = process.env.LOCALVIBE_UPDATE_KEY || 'localvibe-update-v1';
12
+
13
+ const [,, version, warning, flag] = process.argv;
14
+ if (!version) { console.error('Usage: sign-update.js <version> [warning] [--force]'); process.exit(1); }
15
+
16
+ const payload = {
17
+ version,
18
+ ...(warning && warning !== '--force' ? { warning } : {}),
19
+ ...(flag === '--force' || warning === '--force' ? { forceUpdate: true } : {}),
20
+ };
21
+
22
+ const sig = crypto.createHmac('sha256', UPDATE_KEY)
23
+ .update(JSON.stringify(payload))
24
+ .digest('hex');
25
+
26
+ console.log(JSON.stringify({ ...payload, sig }, null, 2));