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.
- package/cli/commands/build-api.js +0 -1
- package/cli/commands/build.js +0 -1
- package/cli/commands/bundle.js +0 -2
- package/cli/commands/create.js +46 -1
- package/cli/scaffold/webrtc/app/components/video-room.js +276 -130
- package/cli/scaffold/webrtc/package.json +16 -0
- package/cli/scaffold/webrtc/server/index.js +198 -0
- package/dist/API.md +1 -1
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +296 -155
- package/dist/zquery.min.js +7 -7
- package/package.json +3 -2
- package/src/component.js +161 -112
- package/src/core.js +11 -3
- package/src/expression.js +26 -19
- package/src/reactive.js +18 -2
- package/src/router.js +38 -3
- package/src/ssr.js +14 -11
- package/src/store.js +16 -9
- package/src/utils.js +24 -5
- package/tests/audit.test.js +9 -9
- package/tests/bench/render.bench.js +77 -0
- package/tests/cli.test.js +7 -7
- package/tests/component.test.js +107 -0
- package/tests/core.test.js +16 -0
- package/tests/expression.test.js +31 -0
- package/tests/http.test.js +45 -0
- package/tests/reactive.test.js +25 -0
- package/tests/router.test.js +96 -0
- package/tests/ssr.test.js +46 -0
- package/tests/store.test.js +10 -0
- package/tests/utils.test.js +34 -0
- package/cli/scaffold/webrtc/app/lib/room.js +0 -252
package/cli/commands/build.js
CHANGED
|
@@ -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', {
|
package/cli/commands/bundle.js
CHANGED
|
@@ -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'))) {
|
package/cli/commands/create.js
CHANGED
|
@@ -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
|
|
1
|
+
// video-room.js - Mini-Discord style room over zero-server signaling.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
// Local publishing flags
|
|
21
|
+
micOn: false,
|
|
22
|
+
camOn: false,
|
|
23
|
+
micMuted: false,
|
|
24
|
+
camMuted: false,
|
|
21
25
|
sharing: false,
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
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.
|
|
31
|
-
this.
|
|
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
|
-
// ----
|
|
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 (
|
|
47
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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:
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
const stream = this.state.
|
|
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.
|
|
106
|
-
for (const t of stream.getAudioTracks()) t.enabled = next;
|
|
107
|
-
this.setState({
|
|
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
|
-
|
|
111
|
-
|
|
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.
|
|
114
|
-
for (const t of stream.getVideoTracks()) t.enabled = next;
|
|
115
|
-
this.setState({
|
|
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
|
|
121
|
-
if (!this._room) return;
|
|
122
|
-
if (
|
|
123
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
await this._room.
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
this.
|
|
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
|
-
|
|
156
|
-
const
|
|
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
|
-
|
|
285
|
+
// ---- Helpers --------------------------------------------------------
|
|
164
286
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
<code
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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="
|
|
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 {
|
|
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;
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
262
|
-
${
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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="
|
|
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
|
|
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
|
});
|