accelr8 3.8.1 → 4.0.0-next.1

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.
@@ -1,18 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- detectVoiceTools,
4
3
  tzCity,
5
4
  tzToCoords
6
- } from "./chunk-HY6ULYGH.js";
5
+ } from "./chunk-CUUNLVID.js";
6
+ import {
7
+ detectVoiceTools
8
+ } from "./chunk-UMPU6SAD.js";
7
9
  import {
8
10
  DEFAULT_SERVER,
9
11
  VERSION
10
- } from "./chunk-UTWXIX5N.js";
12
+ } from "./chunk-5QXGOLQ3.js";
13
+ import {
14
+ accelr8Dir
15
+ } from "./chunk-62O2F73J.js";
11
16
 
12
17
  // src/radio/app.ts
13
18
  import tk from "terminal-kit";
14
19
  import fs from "node:fs";
15
- import os from "node:os";
16
20
  import path from "node:path";
17
21
  import { WebSocket } from "ws";
18
22
 
@@ -318,7 +322,7 @@ var matrix = {
318
322
  frame(tMs, w, h) {
319
323
  const out = new Array(w * h);
320
324
  const t = tMs / 1e3;
321
- const CH = "01<>+*=~^:;|/\\ACELR8#$%&?!";
325
+ const CH2 = "01<>+*=~^:;|/\\ACELR8#$%&?!";
322
326
  for (let x = 0; x < w; x++) {
323
327
  let h0 = 0, l0 = 1, h1 = 0, l1 = 1;
324
328
  for (let d = 0; d < 2; d++) {
@@ -345,7 +349,7 @@ var matrix = {
345
349
  continue;
346
350
  }
347
351
  const rate = 1.5 + 4 * hash2(x, y);
348
- const ch = CH[Math.floor(hashU(x * 31 + y * 57 + Math.floor(t * rate) * 7919) * CH.length)];
352
+ const ch = CH2[Math.floor(hashU(x * 31 + y * 57 + Math.floor(t * rate) * 7919) * CH2.length)];
349
353
  const isHead = q0 > 0 && d0 < 1 || q1 > 0 && d1 < 1;
350
354
  out[y * w + x] = isHead ? { ch, color: col(200 + 55 * q, 252, 252) } : { ch, color: col(18 + 40 * q, 55 + 155 * q, 60 + 195 * q) };
351
355
  }
@@ -5345,123 +5349,273 @@ function renderOverlay(ctx, opts) {
5345
5349
  }
5346
5350
 
5347
5351
  // src/radio/audio.ts
5348
- import { spawn as spawn2 } from "node:child_process";
5349
- function nextAudioAction(sched, serverNowMs, mem, driftBudgetMs = 1500) {
5352
+ import { spawn as spawn2, spawnSync } from "node:child_process";
5353
+ var SR = 44100;
5354
+ var CH = 2;
5355
+ function nextAudioAction(sched, serverNowMs, mem, driftBudgetMs = 1500, stallMs = 4e3) {
5350
5356
  const pos = nowPlaying(sched, serverNowMs);
5351
5357
  if (!pos || !pos.item.url) return { kind: "none" };
5352
- const trackChanged = pos.item.id !== mem.playingTrackId;
5353
- if (trackChanged || !mem.procAlive) {
5354
- return { kind: "play", track: pos.item, offsetMs: pos.offsetMs };
5355
- }
5356
- const impliedOffset = mem.spawnedOffsetMs + (serverNowMs - mem.spawnedAtServerMs);
5357
- if (Math.abs(impliedOffset - pos.offsetMs) > driftBudgetMs) {
5358
- return { kind: "play", track: pos.item, offsetMs: pos.offsetMs };
5359
- }
5358
+ const play = (reason) => ({ kind: "play", track: pos.item, offsetMs: pos.offsetMs, reason });
5359
+ if (pos.item.id !== mem.playingTrackId) return play(mem.playingTrackId ? "track" : "start");
5360
+ if (!mem.procAlive) return play("dead");
5361
+ if (mem.lastDataAtServerMs !== void 0) {
5362
+ const quietFor = serverNowMs - (mem.lastDataAtServerMs || mem.spawnedAtServerMs);
5363
+ if (quietFor > (mem.lastDataAtServerMs ? stallMs : stallMs * 2)) return play("stall");
5364
+ }
5365
+ const implied = mem.spawnedOffsetMs + (serverNowMs - mem.spawnedAtServerMs);
5366
+ if (Math.abs(implied - pos.offsetMs) > driftBudgetMs) return play("drift");
5360
5367
  return { kind: "none" };
5361
5368
  }
5362
- function ffplayArgs(url, offsetMs, volume = 70) {
5363
- const args = [
5369
+ function applyGain(buf, fromGain, target, rampPerSample = 1 / 1400) {
5370
+ let g = fromGain;
5371
+ for (let i = 0; i + 1 < buf.length; i += 2) {
5372
+ if (g < target) g = g + rampPerSample > target ? target : g + rampPerSample;
5373
+ else if (g > target) g = g - rampPerSample < target ? target : g - rampPerSample;
5374
+ let s = Math.round(buf.readInt16LE(i) * g);
5375
+ if (s > 32767) s = 32767;
5376
+ else if (s < -32768) s = -32768;
5377
+ buf.writeInt16LE(s, i);
5378
+ }
5379
+ return g;
5380
+ }
5381
+ function decoderArgs(url, offsetMs) {
5382
+ const args = ["-hide_banner", "-loglevel", "error", "-re"];
5383
+ if (/^https?:/i.test(url)) args.push("-rw_timeout", "8000000");
5384
+ if (offsetMs > 0) args.push("-ss", (offsetMs / 1e3).toFixed(3));
5385
+ args.push("-i", url, "-f", "s16le", "-ar", String(SR), "-ac", String(CH), "-");
5386
+ return args;
5387
+ }
5388
+ var chFlagCache = null;
5389
+ function ffplayStereoFlag(ffplayPath) {
5390
+ if (chFlagCache) return chFlagCache;
5391
+ let modern = true;
5392
+ try {
5393
+ const out = spawnSync(ffplayPath, ["-hide_banner", "-version"], { encoding: "utf8", timeout: 3e3 });
5394
+ const m = ((out.stdout || "") + (out.stderr || "")).match(/version\s+n?(\d+)\.(\d+)/i);
5395
+ if (m) {
5396
+ const major = +m[1], minor = +m[2];
5397
+ modern = major > 5 || major === 5 && minor >= 1;
5398
+ }
5399
+ } catch {
5400
+ }
5401
+ chFlagCache = modern ? ["-ch_layout", "stereo"] : ["-ac", String(CH)];
5402
+ return chFlagCache;
5403
+ }
5404
+ function sinkArgs(ffplayPath) {
5405
+ return [
5364
5406
  "-hide_banner",
5365
5407
  "-loglevel",
5366
5408
  "error",
5367
5409
  "-nodisp",
5368
- "-autoexit",
5369
- "-volume",
5370
- String(Math.max(0, Math.min(100, Math.round(volume))))
5410
+ "-sync",
5411
+ "audio",
5412
+ "-infbuf",
5413
+ "-f",
5414
+ "s16le",
5415
+ "-ar",
5416
+ String(SR),
5417
+ ...ffplayStereoFlag(ffplayPath),
5418
+ "-i",
5419
+ "-"
5371
5420
  ];
5372
- if (offsetMs > 0) args.push("-ss", (offsetMs / 1e3).toFixed(3));
5373
- args.push("-i", url);
5374
- return args;
5421
+ }
5422
+ function coreAudioSinkArgs(ffmpegPath) {
5423
+ return [
5424
+ "-hide_banner",
5425
+ "-loglevel",
5426
+ "error",
5427
+ "-f",
5428
+ "s16le",
5429
+ "-ar",
5430
+ String(SR),
5431
+ ...ffplayStereoFlag(ffmpegPath),
5432
+ "-i",
5433
+ "-",
5434
+ "-f",
5435
+ "audiotoolbox",
5436
+ "-"
5437
+ ];
5438
+ }
5439
+ var coreAudioCache = null;
5440
+ function hasCoreAudioSink(ffmpegPath) {
5441
+ if (coreAudioCache !== null) return coreAudioCache;
5442
+ coreAudioCache = false;
5443
+ if (process.platform !== "darwin") return false;
5444
+ try {
5445
+ const out = spawnSync(ffmpegPath, ["-hide_banner", "-devices"], { encoding: "utf8", timeout: 3e3 });
5446
+ coreAudioCache = /\baudiotoolbox\b/.test((out.stdout || "") + (out.stderr || ""));
5447
+ } catch {
5448
+ }
5449
+ return coreAudioCache;
5450
+ }
5451
+ function pickSink(ffmpegPath, ffplayPath, pref = "auto") {
5452
+ const core = pref === "coreaudio" || pref === "auto" && hasCoreAudioSink(ffmpegPath);
5453
+ return core ? { kind: "coreaudio", bin: ffmpegPath, args: coreAudioSinkArgs(ffmpegPath) } : { kind: "ffplay", bin: ffplayPath, args: sinkArgs(ffplayPath) };
5375
5454
  }
5376
5455
  var SyncedAudio = class {
5377
- proc = null;
5456
+ sink = null;
5457
+ decoder = null;
5378
5458
  mem = { playingTrackId: null, spawnedAtServerMs: 0, spawnedOffsetMs: 0, procAlive: false };
5379
5459
  opts = null;
5380
5460
  timer = null;
5381
- vol = 70;
5382
- // 0..100
5461
+ gain = 0.7;
5462
+ // current (ramping) gain
5463
+ target = 0.7;
5464
+ // desired volume (0..1)
5383
5465
  muted = false;
5466
+ sinkKind = "";
5467
+ lastFeedAt = 0;
5468
+ // wall ms of the last write to the sink (music or silence)
5469
+ written = 0;
5470
+ // bytes into the current sink, to keep whole s16 stereo frames
5471
+ keepalive = null;
5384
5472
  start(opts) {
5385
5473
  this.stop();
5386
5474
  this.opts = opts;
5475
+ this.startSink();
5387
5476
  this.tick();
5388
5477
  this.timer = setInterval(() => this.tick(), 1e3);
5478
+ this.keepalive = setInterval(() => {
5479
+ const quiet = Date.now() - this.lastFeedAt;
5480
+ if (quiet > 60) this.feed(Buffer.alloc(Math.floor(Math.min(quiet, 250) * SR / 1e3) * CH * 2));
5481
+ }, 40);
5482
+ }
5483
+ /** Every write to the sink goes through here. Pads to a whole 4-byte frame
5484
+ * first, so a decoder killed mid-frame can't shift the stream into noise. */
5485
+ feed(buf) {
5486
+ const sink = this.sink;
5487
+ if (!sink || !sink.stdin || sink.stdin.destroyed) return true;
5488
+ const pad = (4 - this.written % 4) % 4;
5489
+ if (pad && buf.length) {
5490
+ sink.stdin.write(Buffer.alloc(pad));
5491
+ this.written += pad;
5492
+ }
5493
+ this.written += buf.length;
5494
+ this.lastFeedAt = Date.now();
5495
+ return sink.stdin.write(buf);
5496
+ }
5497
+ /** Open a fresh sink, so it binds to whatever output is current NOW (the TV
5498
+ * while screen sharing, the speakers after). The old one is dropped first
5499
+ * so its exit handler doesn't treat this as a crash. */
5500
+ restartSink() {
5501
+ const old = this.sink;
5502
+ this.sink = null;
5503
+ this.startSink();
5504
+ if (old) {
5505
+ try {
5506
+ old.kill("SIGKILL");
5507
+ } catch {
5508
+ }
5509
+ }
5389
5510
  }
5390
- /** Swap in a new schedule (worker pushed a new playlist) without a restart. */
5391
5511
  setSchedule(sched) {
5392
5512
  if (this.opts) this.opts.schedule = sched;
5393
5513
  }
5394
5514
  get volume() {
5395
- return this.vol;
5515
+ return Math.round(this.target * 100);
5396
5516
  }
5397
5517
  get isMuted() {
5398
5518
  return this.muted;
5399
5519
  }
5400
- /** Set volume 0..100. Respawns the current track at its live offset so the
5401
- * change is audible immediately (unless muted). */
5520
+ get nowTrackId() {
5521
+ return this.mem.playingTrackId;
5522
+ }
5523
+ /** Smooth: just move the target; the per-chunk gain ramp glides to it. */
5402
5524
  setVolume(v) {
5403
- this.vol = Math.max(0, Math.min(100, Math.round(v)));
5404
- if (!this.muted) this.respawnCurrent();
5525
+ this.target = Math.max(0, Math.min(100, Math.round(v))) / 100;
5526
+ this.muted = false;
5405
5527
  }
5406
- /** Toggle mute. Muting stops the process (no CPU); unmuting resyncs + resumes. */
5407
5528
  toggleMute() {
5408
5529
  this.muted = !this.muted;
5409
- if (this.muted) {
5410
- this.killProc();
5411
- this.mem.playingTrackId = null;
5412
- this.mem.procAlive = false;
5413
- } else this.tick();
5414
5530
  return this.muted;
5415
5531
  }
5416
- respawnCurrent() {
5532
+ // gain ramps to 0 / back, no cut
5533
+ effTarget() {
5534
+ return this.muted ? 0 : this.target;
5535
+ }
5536
+ startSink() {
5417
5537
  if (!this.opts) return;
5418
- const pos = nowPlaying(this.opts.schedule, this.opts.serverNow());
5419
- if (pos && pos.item.url) this.spawn(pos.item, pos.offsetMs);
5538
+ const env = process.env.ACCELR8_RADIO_SINK;
5539
+ const pref = env === "ffplay" || env === "coreaudio" ? env : this.opts.sink ?? "auto";
5540
+ const pick4 = pickSink(this.opts.ffmpegPath, this.opts.ffplayPath, pref);
5541
+ this.opts.onSink?.(pick4.kind);
5542
+ const sink = spawn2(pick4.bin, pick4.args, { stdio: ["pipe", "ignore", "pipe"] });
5543
+ this.sink = sink;
5544
+ this.sinkKind = pick4.kind;
5545
+ this.written = 0;
5546
+ sink.stdin?.on("error", () => {
5547
+ });
5548
+ sink.stdin?.on("drain", () => this.decoder?.stdout?.resume());
5549
+ let err = "";
5550
+ sink.stderr?.on("data", (d) => {
5551
+ err = (err + d.toString()).slice(-200);
5552
+ });
5553
+ sink.on("exit", () => {
5554
+ if (this.sink !== sink) return;
5555
+ this.sink = null;
5556
+ if (err) this.opts?.onError?.(err.split("\n").pop() || err);
5557
+ if (this.opts) {
5558
+ this.startSink();
5559
+ this.mem.procAlive = false;
5560
+ }
5561
+ });
5420
5562
  }
5421
5563
  tick() {
5422
- if (!this.opts || this.muted) return;
5564
+ if (!this.opts) return;
5565
+ if (!this.sink) this.startSink();
5423
5566
  const action = nextAudioAction(this.opts.schedule, this.opts.serverNow(), this.mem);
5424
- if (action.kind === "play") this.spawn(action.track, action.offsetMs);
5567
+ if (action.kind === "play") {
5568
+ if (action.reason !== "track") this.opts.onRespawn?.(action.reason, action.track, action.offsetMs);
5569
+ if (action.reason === "track" && this.sinkKind === "coreaudio") this.restartSink();
5570
+ this.spawnDecoder(action.track, action.offsetMs);
5571
+ }
5425
5572
  }
5426
- spawn(track, offsetMs) {
5573
+ spawnDecoder(track, offsetMs) {
5427
5574
  if (!this.opts) return;
5428
- this.killProc();
5429
- const proc = spawn2(this.opts.ffplayPath, ffplayArgs(track.url, offsetMs, this.vol), { stdio: ["ignore", "ignore", "pipe"] });
5430
- this.proc = proc;
5431
- this.mem = { playingTrackId: track.id, spawnedAtServerMs: this.opts.serverNow(), spawnedOffsetMs: offsetMs, procAlive: true };
5575
+ this.killDecoder();
5576
+ const dec = spawn2(this.opts.ffmpegPath, decoderArgs(track.url, offsetMs), { stdio: ["ignore", "pipe", "ignore"] });
5577
+ this.decoder = dec;
5578
+ this.mem = { playingTrackId: track.id, spawnedAtServerMs: this.opts.serverNow(), spawnedOffsetMs: offsetMs, procAlive: true, lastDataAtServerMs: 0 };
5432
5579
  this.opts.onTrack?.(track);
5433
- let err = "";
5434
- proc.stderr?.on("data", (d) => {
5435
- err = (err + d.toString()).slice(-200);
5580
+ dec.stdout?.on("data", (chunk) => {
5581
+ if (this.decoder !== dec) return;
5582
+ if (this.opts) this.mem.lastDataAtServerMs = this.opts.serverNow();
5583
+ this.gain = applyGain(chunk, this.gain, this.effTarget());
5584
+ if (this.feed(chunk) === false) dec.stdout?.pause();
5436
5585
  });
5437
- proc.on("error", (e) => {
5438
- this.mem.procAlive = false;
5439
- this.opts?.onError?.(e.message);
5586
+ dec.on("error", () => {
5587
+ if (this.decoder === dec) this.mem.procAlive = false;
5440
5588
  });
5441
- proc.on("exit", () => {
5442
- if (this.proc !== proc) return;
5443
- this.mem.procAlive = false;
5444
- if (err) this.opts?.onError?.(err.split("\n").pop() || err);
5589
+ dec.on("exit", () => {
5590
+ if (this.decoder === dec) this.mem.procAlive = false;
5445
5591
  });
5446
5592
  }
5447
- killProc() {
5448
- if (this.proc) {
5593
+ killDecoder() {
5594
+ if (this.decoder) {
5449
5595
  try {
5450
- this.proc.kill("SIGKILL");
5596
+ this.decoder.kill("SIGKILL");
5451
5597
  } catch {
5452
5598
  }
5453
- this.proc = null;
5599
+ this.decoder = null;
5454
5600
  }
5455
5601
  }
5456
- get nowTrackId() {
5457
- return this.mem.playingTrackId;
5458
- }
5459
5602
  stop() {
5460
5603
  if (this.timer) {
5461
5604
  clearInterval(this.timer);
5462
5605
  this.timer = null;
5463
5606
  }
5464
- this.killProc();
5607
+ if (this.keepalive) {
5608
+ clearInterval(this.keepalive);
5609
+ this.keepalive = null;
5610
+ }
5611
+ this.killDecoder();
5612
+ if (this.sink) {
5613
+ try {
5614
+ this.sink.kill("SIGKILL");
5615
+ } catch {
5616
+ }
5617
+ this.sink = null;
5618
+ }
5465
5619
  this.mem = { playingTrackId: null, spawnedAtServerMs: 0, spawnedOffsetMs: 0, procAlive: false };
5466
5620
  }
5467
5621
  };
@@ -5469,7 +5623,7 @@ var SyncedAudio = class {
5469
5623
  // src/radio/app.ts
5470
5624
  function rlog(msg) {
5471
5625
  try {
5472
- const dir = path.join(os.homedir(), ".accelr8");
5626
+ const dir = accelr8Dir();
5473
5627
  fs.mkdirSync(dir, { recursive: true });
5474
5628
  fs.appendFileSync(path.join(dir, "radio.log"), `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
5475
5629
  `);
@@ -5490,6 +5644,7 @@ var frameH = 0;
5490
5644
  var renderTimer = null;
5491
5645
  var running = false;
5492
5646
  var startedAt = Date.now();
5647
+ var VISUAL_EPOCH = Date.UTC(2026, 0, 1);
5493
5648
  var skewMs = 0;
5494
5649
  var serverNow = () => Date.now() + skewMs;
5495
5650
  var W = () => Math.max(20, term.width || process.stdout.columns || 100);
@@ -5535,10 +5690,29 @@ function connectStation(opts) {
5535
5690
  return;
5536
5691
  }
5537
5692
  stationWs = ws;
5693
+ let lastHeard = Date.now();
5694
+ const beat = setInterval(() => {
5695
+ if (ws.readyState !== WebSocket.OPEN) return;
5696
+ if (Date.now() - lastHeard > 25e3) {
5697
+ rlog("station socket silent 25s, reconnecting");
5698
+ ws.terminate();
5699
+ return;
5700
+ }
5701
+ try {
5702
+ ws.ping();
5703
+ } catch {
5704
+ }
5705
+ }, 1e4);
5706
+ ws.on("pong", () => {
5707
+ lastHeard = Date.now();
5708
+ });
5538
5709
  ws.on("open", () => {
5710
+ lastHeard = Date.now();
5539
5711
  ws.send(JSON.stringify({ t: "hello", id: me.id, name: me.name, tz: me.tz, color: me.color }));
5712
+ if (youVoted && skipTrackId) ws.send(JSON.stringify({ t: "voteSkip", trackId: skipTrackId }));
5540
5713
  });
5541
5714
  ws.on("message", (raw) => {
5715
+ lastHeard = Date.now();
5542
5716
  let m;
5543
5717
  try {
5544
5718
  m = JSON.parse(raw.toString());
@@ -5548,6 +5722,7 @@ function connectStation(opts) {
5548
5722
  if (m && m.t === "radioState") applyStation(m);
5549
5723
  });
5550
5724
  ws.on("close", () => {
5725
+ clearInterval(beat);
5551
5726
  if (stationWs === ws) {
5552
5727
  state = { ...state, connected: false };
5553
5728
  scheduleReconnect(opts);
@@ -5555,7 +5730,7 @@ function connectStation(opts) {
5555
5730
  });
5556
5731
  ws.on("error", () => {
5557
5732
  try {
5558
- ws.close();
5733
+ ws.terminate();
5559
5734
  } catch {
5560
5735
  }
5561
5736
  });
@@ -5578,8 +5753,14 @@ function applyStation(m) {
5578
5753
  skipVotes = m.skip.votes ?? 0;
5579
5754
  skipNeeded = m.skip.needed ?? 1;
5580
5755
  if (m.skip.trackId && m.skip.trackId !== skipTrackId) {
5756
+ if (youVoted && skipTrackId) {
5757
+ skipNote = "skipped \u2713";
5758
+ skipNoteUntil = Date.now() + 4e3;
5759
+ }
5581
5760
  skipTrackId = m.skip.trackId;
5582
5761
  youVoted = false;
5762
+ } else if (Array.isArray(m.skip.voters) && Date.now() - lastToggleAt > 2e3) {
5763
+ youVoted = m.skip.voters.includes(me.id);
5583
5764
  }
5584
5765
  }
5585
5766
  }
@@ -5606,9 +5787,16 @@ function startBackground(opts) {
5606
5787
  } else {
5607
5788
  visuals = new Showrunner(ALL_SCENES);
5608
5789
  }
5609
- if (tools.ffplayPath) {
5790
+ if (tools.ffmpegPath && tools.ffplayPath) {
5610
5791
  audio = new SyncedAudio();
5611
- audio.start({ ffplayPath: tools.ffplayPath, schedule: state.schedule, serverNow });
5792
+ audio.start({
5793
+ ffplayPath: tools.ffplayPath,
5794
+ ffmpegPath: tools.ffmpegPath,
5795
+ schedule: state.schedule,
5796
+ serverNow,
5797
+ onRespawn: (reason, track, offsetMs) => rlog(`audio respawn: ${reason} \xB7 ${track.id} @ ${(offsetMs / 1e3).toFixed(1)}s`),
5798
+ onSink: (kind) => rlog(`audio sink: ${kind}`)
5799
+ });
5612
5800
  }
5613
5801
  }
5614
5802
  function makeScreen() {
@@ -5634,9 +5822,18 @@ var skipVotes = 0;
5634
5822
  var skipNeeded = 1;
5635
5823
  var youVoted = false;
5636
5824
  var skipTrackId = "";
5825
+ var skipNote = "";
5826
+ var skipNoteUntil = 0;
5827
+ var lastToggleAt = 0;
5637
5828
  function toggleSkip() {
5829
+ if (!stationWs || stationWs.readyState !== WebSocket.OPEN) {
5830
+ skipNote = "offline, retrying";
5831
+ skipNoteUntil = Date.now() + 3e3;
5832
+ return;
5833
+ }
5638
5834
  youVoted = !youVoted;
5639
- if (stationWs && stationWs.readyState === 1) stationWs.send(JSON.stringify({ t: youVoted ? "voteSkip" : "unvoteSkip" }));
5835
+ lastToggleAt = Date.now();
5836
+ stationWs.send(JSON.stringify({ t: youVoted ? "voteSkip" : "unvoteSkip", trackId: skipTrackId || void 0 }));
5640
5837
  }
5641
5838
  function drawControls() {
5642
5839
  const w = screen.width, y = 1;
@@ -5648,7 +5845,7 @@ function drawControls() {
5648
5845
  const vol = audio ? audio.isMuted ? "MUTED" : `vol ${audio.volume}%` : "no audio";
5649
5846
  put2(1, `\u266A ${vol} [m] mute [+/-] volume [q] quit`);
5650
5847
  const met = skipVotes >= skipNeeded && skipVotes > 0;
5651
- const label = youVoted ? "[s] voted \u2713" : "[s] vote skip";
5848
+ const label = Date.now() < skipNoteUntil ? `[s] ${skipNote}` : youVoted ? "[s] voted \u2713" : "[s] vote skip";
5652
5849
  const right = `${label} skip ${skipVotes}/${skipNeeded} `;
5653
5850
  put2(w - right.length, right, met ? C_GOOD : 231, youVoted);
5654
5851
  }
@@ -5683,7 +5880,7 @@ var renderErrs = 0;
5683
5880
  function renderFrame() {
5684
5881
  if (!running) return;
5685
5882
  try {
5686
- const tMs = Date.now() - startedAt;
5883
+ const tMs = serverNow() - VISUAL_EPOCH;
5687
5884
  if (visuals) {
5688
5885
  lastFrame = visuals.frame(tMs, screen.width, screen.height);
5689
5886
  frameW = screen.width;
@@ -5750,7 +5947,7 @@ function runRadio(opts) {
5750
5947
  const detail = err instanceof Error ? err.stack || err.message : String(err);
5751
5948
  rlog(`CRASH: ${detail}`);
5752
5949
  teardown();
5753
- console.error("\naccelr8 radio crashed:\n" + detail + "\n\nlog: " + path.join(os.homedir(), ".accelr8", "radio.log") + "\n(press any key to close)");
5950
+ console.error("\naccelr8 radio crashed:\n" + detail + "\n\nlog: " + path.join(accelr8Dir(), "radio.log") + "\n(press any key to close)");
5754
5951
  try {
5755
5952
  process.stdin.setRawMode?.(true);
5756
5953
  process.stdin.resume();
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ AuthNetworkError,
4
+ approveSignIn,
5
+ approveUrl,
6
+ deviceLogin,
7
+ ensureSignedIn,
8
+ getAccessToken,
9
+ httpBase,
10
+ legacyProofs,
11
+ logout,
12
+ olderServerMessage,
13
+ pendingSignIn,
14
+ refreshCredentials,
15
+ whoami
16
+ } from "./chunk-3OTVRBTJ.js";
17
+ import "./chunk-FVC2UPMV.js";
18
+ import "./chunk-5QXGOLQ3.js";
19
+ import "./chunk-62O2F73J.js";
20
+ export {
21
+ AuthNetworkError,
22
+ approveSignIn,
23
+ approveUrl,
24
+ deviceLogin,
25
+ ensureSignedIn,
26
+ getAccessToken,
27
+ httpBase,
28
+ legacyProofs,
29
+ logout,
30
+ olderServerMessage,
31
+ pendingSignIn,
32
+ refreshCredentials,
33
+ whoami
34
+ };
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ DIFFERENT_NETWORK,
4
+ ago,
5
+ confirmPage,
6
+ deniedPage,
7
+ donePage,
8
+ enterCodePage,
9
+ esc,
10
+ platformName
11
+ } from "./chunk-RZ4OBBLX.js";
12
+ export {
13
+ DIFFERENT_NETWORK,
14
+ ago,
15
+ confirmPage,
16
+ deniedPage,
17
+ donePage,
18
+ enterCodePage,
19
+ esc,
20
+ platformName
21
+ };