zero-query 1.2.0 → 1.2.3

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.
@@ -360,7 +360,6 @@ function buildEsmSection(root) {
360
360
 
361
361
  async function buildApi() {
362
362
  const root = process.cwd();
363
- const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf-8'));
364
363
 
365
364
  console.log('\n zQuery API.md Generator\n');
366
365
 
@@ -130,7 +130,6 @@ function buildLibrary() {
130
130
  // --- Generate API.md before zipping --------------------------------------
131
131
  const root = process.cwd();
132
132
  try {
133
- const buildApi = require('./build-api');
134
133
  // buildApi() is async (dynamic imports), so we run it synchronously via
135
134
  // a child process to keep the build function synchronous.
136
135
  execSync('node cli/commands/build-api.js', {
@@ -271,7 +271,6 @@ function minifyHTML(html) {
271
271
  html = html.replace(/>\s+</g, (m, offset) => {
272
272
  const before = html.slice(Math.max(0, offset - 80), offset + 1);
273
273
  const after = html.slice(offset + m.length - 1, offset + m.length + 40);
274
- const inlineTags = /\b(a|span|strong|em|b|i|code|small|sub|sup|abbr|label)\b/i;
275
274
  const closingInline = /<\/\s*(a|span|strong|em|b|i|code|small|sub|sup|abbr|label)\s*>$/i.test(before);
276
275
  const openingInline = /^<(a|span|strong|em|b|i|code|small|sub|sup|abbr|label)[\s>]/i.test(after);
277
276
  return (closingInline || openingInline) ? '> <' : '><';
@@ -1058,7 +1057,6 @@ function bundleApp() {
1058
1057
  // pre-built dist/zquery.min.js that ships with the package.
1059
1058
  const pkgRoot = path.resolve(__dirname, '..', '..');
1060
1059
  const pkgSrcDir = path.join(pkgRoot, 'src');
1061
- const pkgMinFile = path.join(pkgRoot, 'dist', 'zquery.min.js');
1062
1060
  const isInstalled = /[\\/]node_modules[\\/]/.test(pkgRoot);
1063
1061
 
1064
1062
  if (!isInstalled && fs.existsSync(pkgSrcDir) && fs.existsSync(path.join(pkgRoot, 'index.js'))) {
@@ -39,7 +39,7 @@ function createProject(args) {
39
39
 
40
40
  // Guard: refuse to overwrite existing files
41
41
  const checkFiles = ['index.html', 'global.css', 'app', 'assets'];
42
- if (variant === 'ssr') checkFiles.push('server');
42
+ if (variant === 'ssr' || variant === 'webrtc') checkFiles.push('server');
43
43
  const conflicts = checkFiles.filter(f =>
44
44
  fs.existsSync(path.join(target, f))
45
45
  );
@@ -121,6 +121,51 @@ function createProject(args) {
121
121
  try { execSync(`${open} http://localhost:3000`, { stdio: 'ignore' }); } catch {}
122
122
  }, 500);
123
123
 
124
+ process.on('SIGINT', () => { child.kill(); process.exit(); });
125
+ process.on('SIGTERM', () => { child.kill(); process.exit(); });
126
+ child.on('exit', (code) => process.exit(code || 0));
127
+ return; // keep process alive for the server
128
+ } else if (variant === 'webrtc') {
129
+ console.log(`\n Installing dependencies...\n`);
130
+ // Install zero-query from the same package that provides this CLI so
131
+ // local-dev and published-npm both work. Dev deps (@zero-server/sdk +
132
+ // @zero-server/webrtc) are declared in the scaffold's package.json and
133
+ // get installed by the same `npm install`. The server has its own
134
+ // install-prompt as a safety net.
135
+ const zqRoot = path.resolve(__dirname, '..', '..');
136
+ try {
137
+ execSync(`npm install "${zqRoot}"`, { cwd: target, stdio: 'inherit' });
138
+ execSync(`npm install`, { cwd: target, stdio: 'inherit' });
139
+ } catch {
140
+ console.error(`\n ✗ npm install failed. Run it manually:\n\n cd ${dirArg || '.'}\n npm install\n npm start\n`);
141
+ process.exit(1);
142
+ }
143
+
144
+ // Refresh zquery.min.js from the installed package (preferred over the
145
+ // pre-copied one above so post-install rebuilds win).
146
+ const zqMin = path.join(target, 'node_modules', 'zero-query', 'dist', 'zquery.min.js');
147
+ if (fs.existsSync(zqMin)) {
148
+ fs.copyFileSync(zqMin, path.join(target, 'zquery.min.js'));
149
+ console.log(` ✓ zquery.min.js`);
150
+ }
151
+
152
+ console.log(`
153
+ Camera, microphone, and screen-share are OFF by default - users opt in
154
+ from inside the room. Optional env vars:
155
+ - WEBRTC_JWT_SECRET enforce signed join tokens
156
+ - TURN_SECRET + TURN_URLS issue time-limited TURN credentials
157
+
158
+ Starting WebRTC signaling + static server on http://localhost:3000 ...
159
+ `);
160
+
161
+ const child = spawn('node', ['server/index.js'], { cwd: target, stdio: 'inherit' });
162
+
163
+ setTimeout(() => {
164
+ const open = process.platform === 'win32' ? 'start'
165
+ : process.platform === 'darwin' ? 'open' : 'xdg-open';
166
+ try { execSync(`${open} http://localhost:3000`, { stdio: 'ignore' }); } catch {}
167
+ }, 500);
168
+
124
169
  process.on('SIGINT', () => { child.kill(); process.exit(); });
125
170
  process.on('SIGTERM', () => { child.kill(); process.exit(); });
126
171
  child.on('exit', (code) => process.exit(code || 0));
@@ -1,176 +1,302 @@
1
- // video-room.js - Mini-Discord style room: video tiles + screen share + chat.
1
+ // video-room.js - Mini-Discord style room over zero-server signaling.
2
2
  //
3
- // Backed by app/lib/room.js (BroadcastChannel signaling) so multiple tabs
4
- // on the same browser form a working mesh room with no server at all.
5
-
6
- import { LocalRoom } from '../lib/room.js';
3
+ // Joins a real WebRTC mesh room via `$.webrtc.join()` against the
4
+ // SignalingHub running in server/index.js. The user picks a room name and
5
+ // joins as a viewer first - no camera, no microphone, no screen capture
6
+ // runs until they explicitly click a Start button. Each control acquires
7
+ // (or releases) exactly the media it owns, so granting "Start mic" never
8
+ // turns on the camera and vice-versa.
7
9
 
8
10
  $.component('video-room', {
9
11
  state: () => ({
10
- // Pre-join form ----------------------------------------------------
12
+ // Pre-join form
11
13
  roomName: 'lobby',
12
14
  displayName: 'User-' + Math.random().toString(36).slice(2, 6),
13
- // Live state -------------------------------------------------------
15
+ // Live state
14
16
  joined: false,
15
- status: 'Pick a room + name and click Join. Open this URL in a second tab to see a peer appear.',
17
+ connecting: false,
18
+ status: 'Pick a room name and join. The camera and mic stay off until you turn them on.',
16
19
  error: '',
17
- // Local media ------------------------------------------------------
18
- localStream: null,
19
- micOn: true,
20
- camOn: true,
20
+ // Local publishing flags
21
+ micOn: false,
22
+ camOn: false,
23
+ micMuted: false,
24
+ camMuted: false,
21
25
  sharing: false,
22
- // Roster + chat ----------------------------------------------------
23
- peers: [], // [{ id, name, stream }]
24
- messages: [], // [{ from, name, text, t, mine }]
26
+ // Live MediaStream refs (passed through reactive() unchanged because
27
+ // the proxy only wraps plain objects / arrays).
28
+ micStream: null,
29
+ camStream: null,
30
+ screenStream: null,
31
+ // Roster (each entry also holds the remote MediaStream for z-stream)
32
+ peers: [],
33
+ // Chat history
34
+ messages: [],
25
35
  draft: '',
26
36
  }),
27
37
 
28
38
  mounted() {
39
+ // Room/data-channel handles live on the instance; MediaStreams live
40
+ // in state so z-stream bindings can resolve them by name.
29
41
  this._room = null;
30
- this._cameraTrack = null; // original camera video track (kept while screen sharing)
31
- this._screenStream = null; // current display-media stream (cleared on stop)
42
+ this._chat = null;
43
+ this._cameraTrack = null;
44
+ this._unsubs = [];
32
45
  },
33
46
 
34
47
  async destroyed() {
35
48
  await this._teardown();
36
49
  },
37
50
 
38
- // ---- Pre-join form bindings -----------------------------------------
51
+ // ---- Form bindings ---------------------------------------------------
39
52
 
40
53
  setRoom(e) { this.setState({ roomName: e.target.value }); },
41
54
  setName(e) { this.setState({ displayName: e.target.value }); },
55
+ setDraft(e) { this.setState({ draft: e.target.value }); },
42
56
 
43
57
  // ---- Join / leave ----------------------------------------------------
44
58
 
45
- async join() {
46
- if (this.state.joined) return;
47
- this.setState({ status: 'Requesting camera + microphone...', error: '' });
59
+ async join(e) {
60
+ if (e && e.preventDefault) e.preventDefault();
61
+ if (this.state.joined || this.state.connecting) return;
62
+ if (!$.webrtc || typeof $.webrtc.join !== 'function') {
63
+ this.setState({ error: '$.webrtc.join is unavailable - build zquery.min.js with the webrtc bundle.' });
64
+ return;
65
+ }
66
+
67
+ this.setState({ connecting: true, error: '', status: 'Connecting to signaling server...' });
48
68
 
49
- let stream = null;
50
69
  try {
51
- stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
70
+ // Pull a fresh join token + the server's preferred ws url.
71
+ const meta = await this._fetchJSON('/rtc/token/' + encodeURIComponent(this.state.roomName));
72
+ const ice = await this._fetchJSON('/rtc/turn');
73
+
74
+ this._room = await $.webrtc.join(meta.wsUrl || this._defaultWsUrl(), {
75
+ room: this.state.roomName,
76
+ token: meta.token || undefined,
77
+ iceServers: (ice && ice.iceServers) || undefined,
78
+ });
79
+
80
+ this._wireRoom(this._room);
81
+
82
+ this.setState({
83
+ joined: true,
84
+ connecting: false,
85
+ status: 'Joined "' + this.state.roomName + '" as a viewer. Start your mic or camera when ready.',
86
+ });
52
87
  } catch (err) {
53
- // Joining without media is still useful (viewer + chat only).
54
- this.setState({ status: 'No camera/mic - joining as viewer.', error: '' });
88
+ this.setState({
89
+ connecting: false,
90
+ error: 'Could not join: ' + (err && err.message ? err.message : String(err)),
91
+ });
55
92
  }
56
-
57
- const cam = stream && stream.getVideoTracks()[0];
58
- if (cam) this._cameraTrack = cam;
59
-
60
- this._room = new LocalRoom(this.state.roomName, { displayName: this.state.displayName });
61
- this._room.on('peers', (peers) => this._onPeers(peers));
62
- this._room.on('chat', (msg) => this._onChat(msg));
63
- this._room.on('status', (status) => this.setState({ status }));
64
- this._room.on('error', (err) => this.setState({ error: String(err && err.message || err) }));
65
- this._room.join(stream);
66
-
67
- this.setState({
68
- joined: true,
69
- localStream: stream,
70
- micOn: !!(stream && stream.getAudioTracks()[0]),
71
- camOn: !!cam,
72
- sharing: false,
73
- });
74
93
  },
75
94
 
76
95
  async leave() {
77
96
  await this._teardown();
78
97
  this.setState({
79
- joined: false,
80
- localStream: null,
81
- peers: [],
82
- messages: [],
83
- sharing: false,
84
- status: 'Left the room. Click Join to reconnect.',
98
+ joined: false,
99
+ connecting: false,
100
+ micOn: false,
101
+ camOn: false,
102
+ micMuted: false,
103
+ camMuted: false,
104
+ sharing: false,
105
+ micStream: null,
106
+ camStream: null,
107
+ screenStream: null,
108
+ peers: [],
109
+ messages: [],
110
+ status: 'Left the room. Click Join to reconnect.',
85
111
  });
86
112
  },
87
113
 
88
114
  async _teardown() {
89
- if (this._room) { try { this._room.leave(); } catch (_) {} this._room = null; }
90
- if (this._screenStream) {
91
- for (const t of this._screenStream.getTracks()) { try { t.stop(); } catch (_) {} }
92
- this._screenStream = null;
93
- }
94
- if (this.state.localStream) {
95
- for (const t of this.state.localStream.getTracks()) { try { t.stop(); } catch (_) {} }
115
+ for (const off of this._unsubs) { try { off(); } catch (_) {} }
116
+ this._unsubs = [];
117
+
118
+ if (this._room) {
119
+ try { await this._room.leave(); } catch (_) {}
120
+ this._room = null;
96
121
  }
122
+ // Read raw values to avoid touching the reactive proxy while we
123
+ // shut things down.
124
+ const raw = this.state.__raw || this.state;
125
+ this._stopStream(raw.screenStream);
126
+ this._stopStream(raw.camStream);
127
+ this._stopStream(raw.micStream);
97
128
  this._cameraTrack = null;
129
+ this._chat = null;
98
130
  },
99
131
 
100
- // ---- Mic / cam toggles ----------------------------------------------
132
+ _stopStream(stream) {
133
+ if (!stream) return;
134
+ for (const t of stream.getTracks()) { try { t.stop(); } catch (_) {} }
135
+ },
136
+
137
+ // ---- Room wiring ----------------------------------------------------
138
+
139
+ _wireRoom(room) {
140
+ // Initial roster snapshot.
141
+ this._refreshPeers(room);
142
+
143
+ // Re-render whenever the room's peer map changes.
144
+ this._unsubs.push(room.peers.subscribe(() => this._refreshPeers(room)));
145
+
146
+ this._unsubs.push(room.on('peer-joined', ({ peerId }) => {
147
+ this.setState({ status: 'Peer joined: ' + peerId });
148
+ this._refreshPeers(room);
149
+ }));
150
+ this._unsubs.push(room.on('peer-left', ({ peerId }) => {
151
+ this.setState({ status: 'Peer left: ' + peerId });
152
+ this._refreshPeers(room);
153
+ }));
154
+ this._unsubs.push(room.on('error', (err) => {
155
+ this.setState({ error: String(err && err.message || err) });
156
+ }));
157
+
158
+ // Multiplexed text-chat data channel - opens on every peer.
159
+ this._chat = room.dataChannel('chat');
160
+ this._unsubs.push(this._chat.on('message', (raw, peerId) => {
161
+ try {
162
+ const msg = JSON.parse(typeof raw === 'string' ? raw : new TextDecoder().decode(raw));
163
+ this._appendChat({ from: peerId, name: msg.name || peerId, text: String(msg.text || ''), mine: false });
164
+ } catch (_) { /* ignore malformed */ }
165
+ }));
166
+ },
167
+
168
+ _refreshPeers(room) {
169
+ const list = [];
170
+ const map = room.peers.peek();
171
+ for (const info of map.values()) {
172
+ list.push({ id: info.id, name: info.id, stream: info.stream });
173
+ }
174
+ this.setState({ peers: list });
175
+ },
176
+
177
+ // ---- Mic ------------------------------------------------------------
178
+
179
+ async startMic() {
180
+ if (this.state.micStream || !this._room) return;
181
+ try {
182
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
183
+ await this._room.publish(stream);
184
+ this.setState({ micStream: stream, micOn: true, micMuted: false, error: '', status: 'Microphone is live.' });
185
+ } catch (err) {
186
+ this.setState({ error: 'Microphone denied or unavailable.' });
187
+ }
188
+ },
101
189
 
102
- toggleMic() {
103
- const stream = this.state.localStream;
190
+ async stopMic() {
191
+ const stream = this.state.micStream;
192
+ if (!stream || !this._room) return;
193
+ try { await this._room.unpublish(stream); } catch (_) {}
194
+ this._stopStream(stream);
195
+ this.setState({ micStream: null, micOn: false, micMuted: false, status: 'Microphone stopped.' });
196
+ },
197
+
198
+ toggleMute() {
199
+ const stream = this.state.micStream;
104
200
  if (!stream) return;
105
- const next = !this.state.micOn;
106
- for (const t of stream.getAudioTracks()) t.enabled = next;
107
- this.setState({ micOn: next });
201
+ const next = !this.state.micMuted;
202
+ for (const t of stream.getAudioTracks()) t.enabled = !next;
203
+ this.setState({ micMuted: next });
108
204
  },
109
205
 
110
- toggleCam() {
111
- const stream = this.state.localStream;
206
+ // ---- Camera ---------------------------------------------------------
207
+
208
+ async startCam() {
209
+ if (this.state.camStream || !this._room) return;
210
+ try {
211
+ const stream = await navigator.mediaDevices.getUserMedia({ video: true });
212
+ this._cameraTrack = stream.getVideoTracks()[0] || null;
213
+ await this._room.publish(stream);
214
+ this.setState({ camStream: stream, camOn: true, camMuted: false, error: '', status: 'Camera is live.' });
215
+ } catch (err) {
216
+ this.setState({ error: 'Camera denied or unavailable.' });
217
+ }
218
+ },
219
+
220
+ async stopCam() {
221
+ const stream = this.state.camStream;
222
+ if (!stream || !this._room) return;
223
+ if (this.state.sharing) await this.stopShare();
224
+ try { await this._room.unpublish(stream); } catch (_) {}
225
+ this._stopStream(stream);
226
+ this._cameraTrack = null;
227
+ this.setState({ camStream: null, camOn: false, camMuted: false, status: 'Camera stopped.' });
228
+ },
229
+
230
+ toggleCamMute() {
231
+ const stream = this.state.camStream;
112
232
  if (!stream) return;
113
- const next = !this.state.camOn;
114
- for (const t of stream.getVideoTracks()) t.enabled = next;
115
- this.setState({ camOn: next });
233
+ const next = !this.state.camMuted;
234
+ for (const t of stream.getVideoTracks()) t.enabled = !next;
235
+ this.setState({ camMuted: next });
116
236
  },
117
237
 
118
- // ---- Screen share ----------------------------------------------------
119
-
120
- async toggleShare() {
121
- if (!this._room) return;
122
- if (this.state.sharing) {
123
- // Stop sharing: revert every peer to the camera track.
124
- if (this._screenStream) {
125
- for (const t of this._screenStream.getTracks()) { try { t.stop(); } catch (_) {} }
126
- this._screenStream = null;
127
- }
128
- await this._room.replaceVideoTrack(this._cameraTrack || null);
129
- this.setState({ sharing: false, status: 'Stopped sharing screen.' });
238
+ // ---- Screen share ---------------------------------------------------
239
+
240
+ async startShare() {
241
+ if (this.state.sharing || !this._room) return;
242
+ if (!navigator.mediaDevices || typeof navigator.mediaDevices.getDisplayMedia !== 'function') {
243
+ this.setState({ error: 'Screen capture is not supported in this browser.' });
130
244
  return;
131
245
  }
132
-
133
246
  try {
134
247
  const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
135
- this._screenStream = stream;
136
- const shareTrack = stream.getVideoTracks()[0];
137
- // When the user clicks the browser's native "Stop sharing", flip back.
138
- shareTrack.onended = () => { if (this.state.sharing) this.toggleShare(); };
139
- await this._room.replaceVideoTrack(shareTrack);
140
- this.setState({ sharing: true, status: 'Sharing your screen.' });
248
+ const track = stream.getVideoTracks()[0];
249
+ if (!track) throw new Error('No video track from getDisplayMedia');
250
+ // Native browser "Stop sharing" button.
251
+ track.onended = () => { if (this.state.sharing) this.stopShare(); };
252
+ await this._room.publish(stream);
253
+ this.setState({ screenStream: stream, sharing: true, error: '', status: 'Sharing your screen.' });
141
254
  } catch (err) {
142
255
  this.setState({ error: 'Screen share denied or unavailable.' });
143
256
  }
144
257
  },
145
258
 
146
- // ---- Roster + chat ---------------------------------------------------
259
+ async stopShare() {
260
+ const stream = this.state.screenStream;
261
+ if (!stream) return;
262
+ try { await this._room.unpublish(stream); } catch (_) {}
263
+ this._stopStream(stream);
264
+ this.setState({ screenStream: null, sharing: false, status: 'Stopped sharing screen.' });
265
+ },
147
266
 
148
- _onPeers(peersMap) {
149
- const list = Array.from(peersMap.values()).map((p) => ({
150
- id: p.id, name: p.name, stream: p.stream,
151
- }));
152
- this.setState({ peers: list });
267
+ // ---- Chat -----------------------------------------------------------
268
+
269
+ sendChat(e) {
270
+ if (e && e.preventDefault) e.preventDefault();
271
+ const text = (this.state.draft || '').trim();
272
+ if (!text || !this._chat) return;
273
+ const payload = JSON.stringify({ name: this.state.displayName, text });
274
+ try { this._chat.send(payload); } catch (_) {}
275
+ this._appendChat({ from: 'me', name: this.state.displayName, text, mine: true });
276
+ this.setState({ draft: '' });
153
277
  },
154
278
 
155
- _onChat(msg) {
156
- const mine = this._room && msg.from === this._room.id;
157
- const next = this.state.messages.concat([{ ...msg, mine }]);
158
- // Cap history so a long-running room doesn't grow forever.
279
+ _appendChat(msg) {
280
+ const next = this.state.messages.concat([msg]);
159
281
  if (next.length > 200) next.splice(0, next.length - 200);
160
282
  this.setState({ messages: next });
161
283
  },
162
284
 
163
- setDraft(e) { this.setState({ draft: e.target.value }); },
285
+ // ---- Helpers --------------------------------------------------------
164
286
 
165
- sendChat(e) {
166
- if (e && e.preventDefault) e.preventDefault();
167
- const text = (this.state.draft || '').trim();
168
- if (!text || !this._room) return;
169
- this._room.sendChat(text);
170
- this.setState({ draft: '' });
287
+ async _fetchJSON(url) {
288
+ const res = await fetch(url, { headers: { Accept: 'application/json' } });
289
+ if (!res.ok) throw new Error(url + ' → HTTP ' + res.status);
290
+ return res.json();
171
291
  },
172
292
 
173
- // ---- Render ----------------------------------------------------------
293
+ _defaultWsUrl() {
294
+ if (typeof location === 'undefined') return 'ws://localhost:3000/rtc';
295
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
296
+ return proto + '//' + location.host + '/rtc';
297
+ },
298
+
299
+ // ---- Render ---------------------------------------------------------
174
300
 
175
301
  render() {
176
302
  if (!this.state.joined) return this._renderLobby();
@@ -178,27 +304,35 @@ $.component('video-room', {
178
304
  },
179
305
 
180
306
  _renderLobby() {
181
- const { roomName, displayName, status, error } = this.state;
307
+ const { roomName, displayName, status, error, connecting } = this.state;
182
308
  return `
183
309
  <div class="lobby">
184
310
  <h1>zQuery WebRTC Demo</h1>
185
311
  <p class="lead">
186
- A mini, no-backend room. Signaling runs over
187
- <code>BroadcastChannel</code>, so opening this page in
188
- multiple tabs / windows gives you a working mesh call
189
- with audio, video, screen share, and chat &mdash; no
190
- server required.
312
+ Mesh video call powered by
313
+ <code>$.webrtc.join()</code> against a
314
+ <a href="https://github.com/tonywied17/zero-server" target="_blank" rel="noopener">zero-server</a>
315
+ <code>SignalingHub</code>. Open this page on a second device
316
+ (or in another browser) and join the same room to see a peer
317
+ appear.
318
+ </p>
319
+ <p class="lead">
320
+ <strong>Camera and microphone are off by default.</strong>
321
+ Join the room first; then turn on the devices you actually
322
+ want to share.
191
323
  </p>
192
324
  <form class="join-form" @submit="join">
193
325
  <label>
194
326
  Room
195
- <input type="text" value="${$.escapeHtml(roomName)}" @input="setRoom" placeholder="lobby" />
327
+ <input type="text" value="${$.escapeHtml(roomName)}" @input="setRoom" placeholder="lobby" ${connecting ? 'disabled' : ''} />
196
328
  </label>
197
329
  <label>
198
330
  Your name
199
- <input type="text" value="${$.escapeHtml(displayName)}" @input="setName" placeholder="display name" />
331
+ <input type="text" value="${$.escapeHtml(displayName)}" @input="setName" placeholder="display name" ${connecting ? 'disabled' : ''} />
200
332
  </label>
201
- <button type="button" class="primary" @click="join">Join room</button>
333
+ <button type="submit" class="primary" ${connecting ? 'disabled' : ''}>
334
+ ${connecting ? 'Joining...' : 'Join room'}
335
+ </button>
202
336
  </form>
203
337
  <p class="status ${error ? 'error' : ''}">
204
338
  ${error ? $.escapeHtml(error) : $.escapeHtml(status)}
@@ -208,9 +342,12 @@ $.component('video-room', {
208
342
  },
209
343
 
210
344
  _renderRoom() {
211
- const { localStream, peers, status, error, micOn, camOn, sharing, messages, draft, displayName, roomName } = this.state;
345
+ const {
346
+ peers, status, error, displayName, roomName,
347
+ micOn, camOn, micMuted, camMuted, sharing, messages, draft,
348
+ } = this.state;
212
349
 
213
- const peerCount = peers.length + 1; // include self
350
+ const peerCount = peers.length + 1;
214
351
  const peerTiles = peers.map((p, i) => `
215
352
  <div class="tile">
216
353
  <video z-stream="peers[${i}].stream" autoplay playsinline></video>
@@ -225,6 +362,9 @@ $.component('video-room', {
225
362
  </div>
226
363
  `).join('');
227
364
 
365
+ // Self-tile prefers the screen stream when sharing, else the camera.
366
+ const selfBinding = sharing ? 'screenStream' : 'camStream';
367
+
228
368
  return `
229
369
  <div class="room">
230
370
  <aside class="sidebar">
@@ -234,7 +374,7 @@ $.component('video-room', {
234
374
  </div>
235
375
  <div class="roster">
236
376
  <div class="roster-row me">
237
- <span class="dot ${micOn ? 'on' : 'off'}"></span>
377
+ <span class="dot ${micOn && !micMuted ? 'on' : 'off'}"></span>
238
378
  ${$.escapeHtml(displayName)} <small>(you)</small>
239
379
  </div>
240
380
  ${peers.map((p) => `
@@ -250,23 +390,29 @@ $.component('video-room', {
250
390
  <section class="stage">
251
391
  <div class="tiles">
252
392
  <div class="tile self">
253
- <video z-stream="localStream" autoplay playsinline muted></video>
254
- <div class="label">You${sharing ? ' &middot; sharing' : ''}</div>
255
- ${!camOn && !sharing ? '<div class="camoff">Camera off</div>' : ''}
393
+ ${camOn || sharing
394
+ ? `<video z-stream="${selfBinding}" autoplay playsinline muted></video>`
395
+ : '<div class="camoff">Camera off</div>'}
396
+ <div class="label">You${sharing ? ' · sharing' : ''}${camMuted ? ' · paused' : ''}</div>
256
397
  </div>
257
398
  ${peerTiles}
258
399
  </div>
259
400
 
260
401
  <div class="controls">
261
- <button class="${micOn ? '' : 'off'}" @click="toggleMic">
262
- ${micOn ? '🎤 Mute' : '🔇 Unmute'}
263
- </button>
264
- <button class="${camOn ? '' : 'off'}" @click="toggleCam">
265
- ${camOn ? '📷 Stop video' : '🚫 Start video'}
266
- </button>
267
- <button class="${sharing ? 'active' : ''}" @click="toggleShare">
268
- ${sharing ? '🛑 Stop share' : '🖥️ Share screen'}
269
- </button>
402
+ ${micOn
403
+ ? `<button class="${micMuted ? 'off' : ''}" @click="toggleMute">${micMuted ? '🔇 Unmute' : '🎤 Mute'}</button>
404
+ <button class="off" @click="stopMic">⏹ Stop mic</button>`
405
+ : `<button class="primary" @click="startMic">🎤 Start mic</button>`}
406
+
407
+ ${camOn
408
+ ? `<button class="${camMuted ? 'off' : ''}" @click="toggleCamMute">${camMuted ? '🚫 Resume' : '📷 Pause'}</button>
409
+ <button class="off" @click="stopCam">⏹ Stop camera</button>`
410
+ : `<button class="primary" @click="startCam">📷 Start camera</button>`}
411
+
412
+ ${sharing
413
+ ? `<button class="active" @click="stopShare">🛑 Stop sharing</button>`
414
+ : `<button @click="startShare">🖥️ Share screen</button>`}
415
+
270
416
  <div class="status-inline ${error ? 'error' : ''}">
271
417
  ${error ? $.escapeHtml(error) : $.escapeHtml(status)}
272
418
  </div>
@@ -280,7 +426,7 @@ $.component('video-room', {
280
426
  </div>
281
427
  <form class="chat-form" @submit="sendChat">
282
428
  <input type="text" value="${$.escapeHtml(draft)}" @input="setDraft" placeholder="Message #${$.escapeHtml(roomName)}" />
283
- <button type="button" class="primary" @click="sendChat">Send</button>
429
+ <button type="submit" class="primary">Send</button>
284
430
  </form>
285
431
  </aside>
286
432
  </div>
@@ -289,7 +435,7 @@ $.component('video-room', {
289
435
 
290
436
  updated() {
291
437
  // Auto-scroll chat to the latest message after each render.
292
- const log = this.$el && this.$el.querySelector ? this.$el.querySelector('#chat-log') : null;
438
+ const log = this._el && this._el.querySelector ? this._el.querySelector('#chat-log') : null;
293
439
  if (log) log.scrollTop = log.scrollHeight;
294
440
  },
295
441
  });