@sanohiro/casty 1.1.2 → 1.2.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.
package/README.ja.md CHANGED
@@ -31,6 +31,12 @@ casty は w3m や lynx のようなテキストブラウザではありません
31
31
 
32
32
  SSH でヘッドレスサーバーに入っていて Web ページを確認したいとき、普通は `curl` か `lynx` か X11 転送しかない。casty ならターミナルを離れずに本物のブラウザが使えます。X11 も VNC も Wayland もいらない。Kitty 対応ターミナルさえあれば OK。
33
33
 
34
+ ### Google Meet でカメラ&マイク(実験的)
35
+
36
+ ![casty で Google Meet](docs/screenshot-meet.png)
37
+
38
+ ffmpeg 経由でカメラとマイクをキャプチャし、Google Meet や Zoom 等の WebRTC サイトで使えます。`ffmpeg` のインストールが必要です。映像はデバイスから直接取得するため、背景エフェクトは使えません。有効にするには[設定](#設定)を参照。
39
+
34
40
  ## インストール
35
41
 
36
42
  ```bash
@@ -124,6 +130,7 @@ casty # ホームページを開く
124
130
  | `transport` | 画像転送方式: `auto`, `file`, `inline` | `auto` (bcon/kitty→file、他→inline) |
125
131
  | `format` | キャプチャ形式: `auto`, `png`, `jpeg` | `auto` (file→jpeg adaptive、inline→png) |
126
132
  | `mouseMode` | `1002` (ボタンイベント) or `1003` (全イベント) | 自動 (Ghostty→1003、他→1002) |
133
+ | `media` | WebRTC 用カメラ/マイク有効化(実験的、`ffmpeg` 必要) | `false` |
127
134
 
128
135
  ## 比較
129
136
 
package/README.md CHANGED
@@ -31,6 +31,12 @@ Since it's real Chrome, JavaScript, CSS, Canvas, and WebGL all work. Google logi
31
31
 
32
32
  If you're working over SSH on a headless server and need to check a web page, your options are usually `curl`, `lynx`, or forwarding X11. casty gives you an actual browser without leaving the terminal. No X11, no VNC, no Wayland — just a Kitty-compatible terminal.
33
33
 
34
+ ### Google Meet with camera & mic (experimental)
35
+
36
+ ![Google Meet on casty](docs/screenshot-meet.png)
37
+
38
+ Camera and microphone can be streamed to WebRTC sites like Google Meet, Zoom, etc. via ffmpeg. Requires `ffmpeg` installed. Background effects are not available since the video is captured directly from the device. See [Configuration](#configuration) to enable.
39
+
34
40
  ## Installation
35
41
 
36
42
  ```bash
@@ -124,6 +130,7 @@ Create `~/.casty/bookmarks.json`:
124
130
  | `transport` | Image transfer: `auto`, `file`, `inline` | `auto` (bcon/kitty→file, others→inline) |
125
131
  | `format` | Capture format: `auto`, `png`, `jpeg` | `auto` (file→jpeg adaptive, inline→png) |
126
132
  | `mouseMode` | `1002` (button-event) or `1003` (any-event) | Auto (Ghostty→1003, others→1002) |
133
+ | `media` | Enable camera/mic for WebRTC (experimental, requires `ffmpeg`) | `false` |
127
134
 
128
135
  ## Comparison
129
136
 
package/bin/casty CHANGED
@@ -115,7 +115,12 @@ install_chromium() {
115
115
  mkdir -p "$BROWSERS_DIR"
116
116
  # zip 内は chrome-headless-shell-<platform>/ ディレクトリに入っている
117
117
  local tmp_dir=$(mktemp -d /tmp/casty-extract-XXXXXX)
118
- unzip -q "$tmp_zip" -d "$tmp_dir" 2>/dev/null
118
+ if ! unzip -q "$tmp_zip" -d "$tmp_dir" 2>/dev/null; then
119
+ rm -f "$tmp_zip"
120
+ rm -rf "$tmp_dir"
121
+ echo "casty: Extraction failed (disk full?)" >&2
122
+ return 1
123
+ fi
119
124
  rm -f "$tmp_zip"
120
125
 
121
126
  # 展開されたディレクトリを見つけてリネーム
@@ -130,6 +135,13 @@ install_chromium() {
130
135
  rm -rf "$tmp_dir"
131
136
  chmod +x "$dest_dir/chrome-headless-shell"
132
137
 
138
+ # Verify binary is functional (catches truncated/corrupt downloads)
139
+ if ! "$dest_dir/chrome-headless-shell" --version &>/dev/null; then
140
+ echo "casty: Downloaded headless-shell is broken, removing..." >&2
141
+ rm -rf "$dest_dir"
142
+ return 1
143
+ fi
144
+
133
145
  touch "$STAMP"
134
146
  echo "casty: Installed Chrome Headless Shell ${version}" >&2
135
147
  }
package/bin/casty.js CHANGED
@@ -60,6 +60,7 @@ import { sendFrame, resetFrameCache, clearScreen, hideCursor, showCursor, cleanu
60
60
  import { enableMouse, disableMouse, startInputHandling } from '../lib/input.js';
61
61
  import { loadKeyBindings } from '../lib/keys.js';
62
62
  import { loadConfig } from '../lib/config.js';
63
+ import { startMedia } from '../lib/media.js';
63
64
 
64
65
  const config = loadConfig();
65
66
  const bindings = loadKeyBindings();
@@ -148,11 +149,13 @@ async function getTermInfo({ keepAlive = false } = {}) {
148
149
  }
149
150
 
150
151
  async function main() {
151
- // Phase 1: Launch Chrome and get terminal info in parallel
152
+ // Phase 1: Launch Chrome, get terminal info, and start media in parallel
152
153
  // getTermInfo() must complete fully (prevent CSI 14t response leak)
153
154
  const browserP = startBrowser();
155
+ const mediaP = config.media ? startMedia(config) : null;
154
156
  const term = await getTermInfo();
155
157
  const browser = await browserP;
158
+ const media = mediaP ? await mediaP : null;
156
159
 
157
160
  // Reserve line 1 for URL bar, use the rest for browser display
158
161
  const barHeight = term.cellHeight;
@@ -160,7 +163,7 @@ async function main() {
160
163
  setDisplaySize(term.cols, term.rows - 1);
161
164
 
162
165
  // Phase 2: CDP connection + page setup
163
- const { client, cssWidth, cssHeight } = await setupPage(browser, { ...term, height: viewHeight });
166
+ const { client, cssWidth, cssHeight } = await setupPage(browser, { ...term, height: viewHeight, mediaPort: media?.port || 0 });
164
167
  const chromeProcess = browser.proc;
165
168
 
166
169
  // Log WebSocket errors to stderr (prevent unhandled crash)
@@ -237,6 +240,7 @@ async function main() {
237
240
  } catch {}
238
241
  client.close();
239
242
  chromeProcess.kill();
243
+ media?.cleanup();
240
244
  disableMouse();
241
245
  showCursor();
242
246
  try { process.stdin.setRawMode(false); } catch {}
package/lib/browser.js CHANGED
@@ -33,7 +33,7 @@ const CAPTURE_STUCK_RESET = 5000; // Reset stuck capturing flag (ms)
33
33
 
34
34
  // Build stealth script with locale-dependent language settings
35
35
  // lang: primary language (e.g. "ja", "en-US")
36
- function buildStealthScript(lang, { fakeMedia = false } = {}) {
36
+ function buildStealthScript(lang, { media = false, mediaPort = 0 } = {}) {
37
37
  // Build Accept-Language style list: primary, then en-US/en fallbacks
38
38
  const languages = [lang];
39
39
  if (lang !== 'en-US' && lang !== 'en') {
@@ -93,7 +93,7 @@ navigator.permissions.query = (params) => {
93
93
  if (params.name === 'notifications') {
94
94
  return Promise.resolve({ state: Notification.permission });
95
95
  }
96
- ${fakeMedia ? ` if (params.name === 'camera' || params.name === 'microphone') {
96
+ ${media ? ` if (params.name === 'camera' || params.name === 'microphone') {
97
97
  return Promise.resolve({ state: 'granted' });
98
98
  }` : ''}
99
99
  return origQuery(params);
@@ -115,35 +115,81 @@ if (!navigator.connection) {
115
115
  });
116
116
  }
117
117
 
118
- ${fakeMedia ? `
119
- // getUserMedia emulation (headless-shell lacks media capture)
118
+ ${media && mediaPort ? `
119
+ // getUserMedia with real device capture via ffmpeg WebSocket relay
120
+ if (!navigator.mediaDevices) navigator.mediaDevices = {};
120
121
  if (navigator.mediaDevices) {
121
- const fakeStream = (constraints) => {
122
- const tracks = [];
123
- if (constraints.video) {
124
- const w = constraints.video.width?.ideal || constraints.video.width?.max || 640;
125
- const h = constraints.video.height?.ideal || constraints.video.height?.max || 480;
126
- const canvas = document.createElement('canvas');
127
- canvas.width = w;
128
- canvas.height = h;
129
- const ctx = canvas.getContext('2d');
130
- const draw = () => { ctx.fillStyle = '#000'; ctx.fillRect(0, 0, w, h); requestAnimationFrame(draw); };
131
- draw();
132
- const stream = canvas.captureStream(30);
133
- tracks.push(...stream.getVideoTracks());
134
- }
135
- if (constraints.audio) {
136
- const actx = new AudioContext();
137
- const gain = actx.createGain();
138
- gain.gain.value = 0;
139
- gain.connect(actx.destination);
140
- const dest = actx.createMediaStreamDestination();
141
- gain.connect(dest);
142
- tracks.push(...dest.stream.getAudioTracks());
143
- }
144
- return Promise.resolve(new MediaStream(tracks));
122
+ navigator.mediaDevices.getUserMedia = (constraints) => {
123
+ return new Promise((resolve) => {
124
+ const tracks = [];
125
+ const ws = new WebSocket('ws://127.0.0.1:${mediaPort}');
126
+ ws.binaryType = 'arraybuffer';
127
+
128
+ let canvas, ctx, vstream;
129
+ if (constraints.video) {
130
+ const w = constraints.video.width?.ideal || constraints.video.width?.max || 640;
131
+ const h = constraints.video.height?.ideal || constraints.video.height?.max || 480;
132
+ canvas = document.createElement('canvas');
133
+ canvas.width = w; canvas.height = h;
134
+ ctx = canvas.getContext('2d');
135
+ vstream = canvas.captureStream(15);
136
+ tracks.push(...vstream.getVideoTracks());
137
+ }
138
+
139
+ let pcmQueue;
140
+ if (constraints.audio) {
141
+ const actx = new AudioContext({ sampleRate: 48000 });
142
+ if (actx.state === 'suspended') actx.resume();
143
+ pcmQueue = [];
144
+ // Use 1 input channel to ensure onaudioprocess fires reliably
145
+ const proc = actx.createScriptProcessor(4096, 1, 1);
146
+ // Feed silence into input to keep processor running
147
+ const silence = actx.createBufferSource();
148
+ const silenceBuf = actx.createBuffer(1, 4096, 48000);
149
+ silence.buffer = silenceBuf;
150
+ silence.loop = true;
151
+ silence.connect(proc);
152
+ silence.start();
153
+ proc.onaudioprocess = (e) => {
154
+ const out = e.outputBuffer.getChannelData(0);
155
+ const chunk = pcmQueue.shift();
156
+ if (chunk) {
157
+ const s16 = new Int16Array(chunk);
158
+ for (let i = 0; i < out.length && i < s16.length; i++) out[i] = s16[i] / 32768;
159
+ if (s16.length < out.length) out.fill(0, s16.length);
160
+ } else {
161
+ out.fill(0);
162
+ }
163
+ };
164
+ const dest = actx.createMediaStreamDestination();
165
+ proc.connect(dest);
166
+ tracks.push(...dest.stream.getAudioTracks());
167
+ }
168
+
169
+ ws.onmessage = (e) => {
170
+ const buf = new Uint8Array(e.data);
171
+ const type = buf[0];
172
+ const payload = buf.slice(1);
173
+ if (type === 1 && canvas) {
174
+ createImageBitmap(new Blob([payload], { type: 'image/jpeg' }))
175
+ .then(bmp => { ctx.drawImage(bmp, 0, 0, canvas.width, canvas.height); });
176
+ } else if (type === 2 && pcmQueue) {
177
+ pcmQueue.push(payload.buffer);
178
+ }
179
+ };
180
+
181
+ const stream = new MediaStream(tracks);
182
+ // Close WebSocket when all tracks are stopped (camera/mic off)
183
+ for (const track of tracks) {
184
+ track.addEventListener('ended', () => {
185
+ if (stream.getTracks().every(t => t.readyState === 'ended')) {
186
+ ws.close();
187
+ }
188
+ });
189
+ }
190
+ resolve(stream);
191
+ });
145
192
  };
146
- navigator.mediaDevices.getUserMedia = fakeStream;
147
193
  navigator.mediaDevices.enumerateDevices = () => Promise.resolve([
148
194
  { deviceId: 'default', kind: 'audioinput', label: 'Default Microphone', groupId: 'default' },
149
195
  { deviceId: 'default', kind: 'videoinput', label: 'Default Camera', groupId: 'default' },
@@ -178,7 +224,7 @@ export async function startBrowser() {
178
224
 
179
225
  // Phase 2: CDP connection + page setup (navigation is done by the caller)
180
226
  // Tries /json/new first, then /json/list, then Target.createTarget via browser CDP
181
- export async function setupPage({ port, wsUrl }, { width, height, zoom = 1 } = {}) {
227
+ export async function setupPage({ port, wsUrl }, { width, height, zoom = 1, mediaPort = 0 } = {}) {
182
228
  const config = loadConfig();
183
229
  const lang = config.language;
184
230
  const baseUrl = `http://127.0.0.1:${port}`;
@@ -240,7 +286,7 @@ export async function setupPage({ port, wsUrl }, { width, height, zoom = 1 } = {
240
286
  wow64: false,
241
287
  },
242
288
  }),
243
- client.send('Page.addScriptToEvaluateOnNewDocument', { source: buildStealthScript(lang, { fakeMedia: config.fakeMedia }) }),
289
+ client.send('Page.addScriptToEvaluateOnNewDocument', { source: buildStealthScript(lang, { media: config.media, mediaPort }) }),
244
290
  client.send('Browser.setDownloadBehavior', {
245
291
  behavior: 'allowAndName', downloadPath: join(homedir(), 'Downloads'),
246
292
  eventsEnabled: true,
package/lib/config.js CHANGED
@@ -32,7 +32,7 @@ const DEFAULTS = {
32
32
  searchUrl: 'https://www.google.com/search?q=',
33
33
  transport: 'auto', // 'auto' | 'file' | 'inline'
34
34
  format: 'auto', // 'auto' | 'png' | 'jpeg'
35
- fakeMedia: false, // Emulate camera/mic for WebRTC (headless-shell lacks getUserMedia)
35
+ media: false, // Enable camera/mic via ffmpeg for WebRTC (requires ffmpeg)
36
36
  language: detectLocale(), // System locale (override: "en-US", "ja", etc.)
37
37
  };
38
38
 
package/lib/input.js CHANGED
@@ -302,6 +302,7 @@ export function startInputHandling(client, cellWidth, cellHeight, bindings, paus
302
302
 
303
303
  // Throttled mouse motion (mode 1003 generates events for every pixel)
304
304
  let pendingMotion = null;
305
+ let lastClickTime = 0, lastClickX = 0, lastClickY = 0, lastClickCount = 0;
305
306
  let _motionTimer = null;
306
307
  const MOTION_INTERVAL = 16; // ms (~60fps)
307
308
  function flushMotion() {
@@ -511,7 +512,21 @@ export function startInputHandling(client, cellWidth, cellHeight, bindings, paus
511
512
  if (release) {
512
513
  await dispatchMouse(client, 'mouseReleased', x, y, 'left');
513
514
  } else {
514
- await dispatchMouse(client, 'mousePressed', x, y, 'left', 1);
515
+ // Detect double/triple click by timing and position
516
+ const now = Date.now();
517
+ const MULTI_CLICK_MS = 400;
518
+ const MULTI_CLICK_PX = 5;
519
+ if (now - lastClickTime < MULTI_CLICK_MS
520
+ && Math.abs(x - lastClickX) < MULTI_CLICK_PX
521
+ && Math.abs(y - lastClickY) < MULTI_CLICK_PX) {
522
+ lastClickCount = Math.min(lastClickCount + 1, 3);
523
+ } else {
524
+ lastClickCount = 1;
525
+ }
526
+ lastClickTime = now;
527
+ lastClickX = x;
528
+ lastClickY = y;
529
+ await dispatchMouse(client, 'mousePressed', x, y, 'left', lastClickCount);
515
530
  }
516
531
  } else if (cb === MOUSE_BTN_DRAG) {
517
532
  await dispatchMouse(client, 'mouseMoved', x, y, 'left');
package/lib/media.js ADDED
@@ -0,0 +1,202 @@
1
+ // Real camera/mic capture via ffmpeg → WebSocket relay
2
+ // Two ffmpeg processes (video MJPEG, audio PCM) piped to a local WS server
3
+
4
+ import { spawn, execFileSync } from 'node:child_process';
5
+ import { existsSync } from 'node:fs';
6
+ import { WebSocketServer } from 'ws';
7
+
8
+ const IS_MAC = process.platform === 'darwin';
9
+
10
+ // Detect available devices via ffmpeg
11
+ function listDevices() {
12
+ const video = detectVideoDevice();
13
+ const audio = detectAudioDevice();
14
+ return { video, audio };
15
+ }
16
+
17
+ function detectVideoDevice() {
18
+ if (IS_MAC) {
19
+ const out = ffmpegProbe(['-f', 'avfoundation', '-list_devices', 'true', '-i', '']);
20
+ // Parse first video device from "AVFoundation video devices:" section (language-independent)
21
+ const videoSection = out.split(/audio devices/i)[0];
22
+ const m = videoSection.match(/\[(\d+)\]/);
23
+ if (!m) return null;
24
+ const idx = m[1];
25
+ // Probe supported framerate by attempting to open with invalid fps
26
+ const probe = ffmpegProbe(['-f', 'avfoundation', '-framerate', '1', '-video_size', '640x480', '-i', idx]);
27
+ const fpsMatch = probe.match(/640x480@\[(\d+)/);
28
+ const fps = fpsMatch ? parseInt(fpsMatch[1]) : 30;
29
+ return { idx, fps };
30
+ }
31
+ return existsSync('/dev/video0') ? '/dev/video0' : null;
32
+ }
33
+
34
+ function detectAudioDevice() {
35
+ if (IS_MAC) {
36
+ const out = ffmpegProbe(['-f', 'avfoundation', '-list_devices', 'true', '-i', '']);
37
+ // Parse first audio device from "AVFoundation audio devices:" section (language-independent)
38
+ const audioSection = out.split(/audio devices/i)[1];
39
+ const m = audioSection && audioSection.match(/\[(\d+)\]/);
40
+ return m ? ':' + m[1] : null;
41
+ }
42
+ // Linux: find first PulseAudio input source (not monitor)
43
+ try {
44
+ const out = execFileSync('pactl', ['list', 'sources', 'short'], { encoding: 'utf8' });
45
+ for (const line of out.trim().split('\n')) {
46
+ const parts = line.split('\t');
47
+ if (parts[1] && !parts[1].includes('.monitor')) return parts[1];
48
+ }
49
+ } catch {}
50
+ return 'default';
51
+ }
52
+
53
+ function ffmpegProbe(args) {
54
+ try {
55
+ execFileSync('ffmpeg', args, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
56
+ } catch (e) {
57
+ return (e.stderr || '') + (e.stdout || '');
58
+ }
59
+ return '';
60
+ }
61
+
62
+ // Parse MJPEG stream: extract individual JPEG frames from continuous byte stream
63
+ function createMjpegParser(onFrame) {
64
+ let buf = Buffer.alloc(0);
65
+ return (chunk) => {
66
+ buf = Buffer.concat([buf, chunk]);
67
+ for (;;) {
68
+ // Find SOI marker (0xFF 0xD8)
69
+ const soi = buf.indexOf(Buffer.from([0xff, 0xd8]));
70
+ if (soi < 0) { buf = Buffer.alloc(0); return; }
71
+ if (soi > 0) buf = buf.subarray(soi);
72
+ // Find EOI marker (0xFF 0xD9) after SOI
73
+ const eoi = buf.indexOf(Buffer.from([0xff, 0xd9]), 2);
74
+ if (eoi < 0) return; // Incomplete frame
75
+ const frame = buf.subarray(0, eoi + 2);
76
+ onFrame(frame);
77
+ buf = buf.subarray(eoi + 2);
78
+ }
79
+ };
80
+ }
81
+
82
+ // Start media capture and WebSocket relay
83
+ export async function startMedia(config = {}) {
84
+ // Check ffmpeg availability
85
+ try {
86
+ execFileSync('ffmpeg', ['-version'], { stdio: 'pipe' });
87
+ } catch {
88
+ console.error('casty: ffmpeg is required for media capture.');
89
+ if (IS_MAC) console.error('casty: Install with: brew install ffmpeg');
90
+ else console.error('casty: Install with: sudo apt install ffmpeg');
91
+ return { port: 0, cleanup: () => {} };
92
+ }
93
+
94
+ const devices = listDevices();
95
+ const videoRaw = config.videoDevice || devices.video;
96
+ const audioDevice = config.audioDevice || devices.audio;
97
+
98
+ // Normalize video device: { idx, fps } on macOS, string on Linux
99
+ const videoDevice = videoRaw && typeof videoRaw === 'object' ? videoRaw.idx : videoRaw;
100
+ const videoFps = videoRaw && typeof videoRaw === 'object' ? videoRaw.fps : 15;
101
+
102
+ if (!videoDevice && !audioDevice) {
103
+ console.error('casty: no media devices found');
104
+ return { port: 0, cleanup: () => {} };
105
+ }
106
+
107
+ // Start WebSocket server on random port
108
+ const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 });
109
+ const port = await new Promise(resolve => {
110
+ wss.on('listening', () => resolve(wss.address().port));
111
+ });
112
+
113
+ const clients = new Set();
114
+ const procs = [];
115
+ let capturing = false;
116
+
117
+ function broadcast(prefix, data) {
118
+ const msg = Buffer.concat([Buffer.from([prefix]), data]);
119
+ for (const ws of clients) {
120
+ if (ws.readyState === 1) ws.send(msg);
121
+ }
122
+ }
123
+
124
+ // Start ffmpeg capture on demand (first WebSocket connection = getUserMedia called)
125
+ function startCapture() {
126
+ if (capturing) return;
127
+ capturing = true;
128
+ console.error('casty: media requested, starting capture');
129
+
130
+ // Video capture
131
+ if (videoDevice) {
132
+ const vargs = IS_MAC
133
+ ? ['-f', 'avfoundation', '-framerate', String(videoFps), '-video_size', '640x480', '-i', videoDevice]
134
+ : ['-f', 'v4l2', '-framerate', '15', '-video_size', '640x480', '-i', videoDevice];
135
+ vargs.push('-f', 'mjpeg', '-q:v', '5', '-r', '15', 'pipe:1');
136
+
137
+ const vproc = spawn('ffmpeg', vargs, { stdio: ['pipe', 'pipe', 'pipe'] });
138
+ const parse = createMjpegParser((frame) => broadcast(0x01, frame));
139
+ vproc.stdout.on('data', parse);
140
+ vproc.stderr.on('data', () => {}); // Suppress
141
+ vproc.on('error', (e) => console.error('casty: video capture error:', e.message));
142
+ procs.push(vproc);
143
+ console.error(`casty: video capture started (${videoDevice})`);
144
+ }
145
+
146
+ // Audio capture
147
+ if (audioDevice) {
148
+ const aargs = IS_MAC
149
+ ? ['-f', 'avfoundation', '-i', audioDevice]
150
+ : ['-f', 'pulse', '-i', audioDevice];
151
+ aargs.push('-f', 's16le', '-ar', '48000', '-ac', '1', 'pipe:1');
152
+
153
+ const env = { ...process.env };
154
+ if (!env.XDG_RUNTIME_DIR) env.XDG_RUNTIME_DIR = `/run/user/${process.getuid()}`;
155
+ const aproc = spawn('ffmpeg', aargs, { stdio: ['pipe', 'pipe', 'pipe'], env });
156
+ let pcmBuf = Buffer.alloc(0);
157
+ const PCM_CHUNK = 9600;
158
+ aproc.stdout.on('data', (chunk) => {
159
+ pcmBuf = Buffer.concat([pcmBuf, chunk]);
160
+ while (pcmBuf.length >= PCM_CHUNK) {
161
+ broadcast(0x02, pcmBuf.subarray(0, PCM_CHUNK));
162
+ pcmBuf = pcmBuf.subarray(PCM_CHUNK);
163
+ }
164
+ });
165
+ aproc.stderr.on('data', () => {}); // Suppress
166
+ aproc.on('error', (e) => console.error('casty: audio capture error:', e.message));
167
+ procs.push(aproc);
168
+ console.error(`casty: audio capture started (${audioDevice})`);
169
+ }
170
+ }
171
+
172
+ function stopCapture() {
173
+ if (!capturing) return;
174
+ for (const p of procs) {
175
+ try { p.kill(); } catch {}
176
+ }
177
+ procs.length = 0;
178
+ capturing = false;
179
+ console.error('casty: media stopped (no active sessions)');
180
+ }
181
+
182
+ // Start capture when first client connects, stop when all disconnect
183
+ wss.on('connection', (ws) => {
184
+ clients.add(ws);
185
+ startCapture();
186
+ ws.on('close', () => {
187
+ clients.delete(ws);
188
+ if (clients.size === 0) stopCapture();
189
+ });
190
+ });
191
+
192
+ console.error('casty: media ready (capture starts on demand)');
193
+
194
+ function cleanup() {
195
+ for (const p of procs) {
196
+ try { p.kill(); } catch {}
197
+ }
198
+ wss.close();
199
+ }
200
+
201
+ return { port, cleanup };
202
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanohiro/casty",
3
- "version": "1.1.2",
3
+ "version": "1.2.1",
4
4
  "description": "TTY web browser using raw CDP and Kitty graphics protocol",
5
5
  "main": "bin/casty.js",
6
6
  "bin": {