instbyte 1.9.4 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -161
- package/bin/instbyte.js +1 -0
- package/client/css/app.css +366 -0
- package/client/index.html +31 -0
- package/client/js/app.js +498 -3
- package/package.json +1 -1
- package/server/server.js +126 -12
package/README.md
CHANGED
|
@@ -102,147 +102,19 @@ For teams who want auth, custom retention, or branding — create `instbyte.conf
|
|
|
102
102
|
|
|
103
103
|
Then run `npx instbyte` in the same directory. The config is picked up automatically.
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
For persistent team use, run it as a background process:
|
|
107
|
-
|
|
108
|
-
```bash
|
|
109
|
-
# using pm2
|
|
110
|
-
npm install -g pm2
|
|
111
|
-
pm2 start "npx instbyte" --name instbyte
|
|
112
|
-
pm2 save
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
Or use any process manager you already have — systemd, screen, tmux.
|
|
105
|
+
For persistent deployment options including pm2, systemd, and Docker, see the [Deployment Guide](docs/deployment.md).
|
|
116
106
|
|
|
117
107
|
---
|
|
118
108
|
|
|
119
109
|
## Docker
|
|
120
110
|
|
|
121
|
-
|
|
122
|
-
```bash
|
|
123
|
-
docker compose up -d
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
Or with plain Docker:
|
|
127
|
-
```bash
|
|
128
|
-
docker run -d \
|
|
129
|
-
-p 3000:3000 \
|
|
130
|
-
-v $(pwd)/instbyte-data:/data \
|
|
131
|
-
-e INSTBYTE_DATA=/data \
|
|
132
|
-
-e INSTBYTE_UPLOADS=/data/uploads \
|
|
133
|
-
--name instbyte \
|
|
134
|
-
mohitgauniyal/instbyte
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
Data persists in `./instbyte-data` on your host. The same folder used by `npx instbyte` — so switching between the two preserves all your data.
|
|
138
|
-
|
|
139
|
-
### With a config file
|
|
140
|
-
|
|
141
|
-
Mount your config file into the container:
|
|
142
|
-
```yaml
|
|
143
|
-
services:
|
|
144
|
-
instbyte:
|
|
145
|
-
image: mohitgauniyal/instbyte
|
|
146
|
-
ports:
|
|
147
|
-
- "3000:3000"
|
|
148
|
-
volumes:
|
|
149
|
-
- ./instbyte-data:/data
|
|
150
|
-
- ./instbyte.config.json:/app/instbyte.config.json
|
|
151
|
-
environment:
|
|
152
|
-
- INSTBYTE_DATA=/data
|
|
153
|
-
- INSTBYTE_UPLOADS=/data/uploads
|
|
154
|
-
restart: unless-stopped
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
### Changing the port
|
|
158
|
-
|
|
159
|
-
Edit the host port in `docker-compose.yml`:
|
|
160
|
-
```yaml
|
|
161
|
-
ports:
|
|
162
|
-
- "8080:3000" # now runs on port 8080
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
> **Note:** File uploads may not work correctly on Windows Docker Desktop due to network limitations. For Windows, use `npx instbyte` directly or deploy on a Linux server.
|
|
111
|
+
For Docker setup, persistent data, and config file mounting, see [Deployment Guide](docs/deployment.md).
|
|
166
112
|
|
|
167
113
|
---
|
|
168
114
|
|
|
169
115
|
## Reverse Proxy
|
|
170
116
|
|
|
171
|
-
For
|
|
172
|
-
|
|
173
|
-
> **Important:** Instbyte uses WebSockets for real-time sync. Your proxy must be configured to forward WebSocket connections — otherwise the app will load but live updates will stop working.
|
|
174
|
-
|
|
175
|
-
### Nginx
|
|
176
|
-
```nginx
|
|
177
|
-
server {
|
|
178
|
-
listen 80;
|
|
179
|
-
server_name instbyte.yourdomain.com;
|
|
180
|
-
|
|
181
|
-
# Redirect HTTP to HTTPS
|
|
182
|
-
return 301 https://$host$request_uri;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
server {
|
|
186
|
-
listen 443 ssl;
|
|
187
|
-
server_name instbyte.yourdomain.com;
|
|
188
|
-
|
|
189
|
-
ssl_certificate /etc/letsencrypt/live/instbyte.yourdomain.com/fullchain.pem;
|
|
190
|
-
ssl_certificate_key /etc/letsencrypt/live/instbyte.yourdomain.com/privkey.pem;
|
|
191
|
-
|
|
192
|
-
# Increase max upload size to match Instbyte's limit
|
|
193
|
-
client_max_body_size 2G;
|
|
194
|
-
|
|
195
|
-
location / {
|
|
196
|
-
proxy_pass http://localhost:3000;
|
|
197
|
-
proxy_http_version 1.1;
|
|
198
|
-
|
|
199
|
-
# Required for WebSocket support
|
|
200
|
-
proxy_set_header Upgrade $http_upgrade;
|
|
201
|
-
proxy_set_header Connection "upgrade";
|
|
202
|
-
|
|
203
|
-
proxy_set_header Host $host;
|
|
204
|
-
proxy_set_header X-Real-IP $remote_addr;
|
|
205
|
-
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
206
|
-
proxy_set_header X-Forwarded-Proto $scheme;
|
|
207
|
-
|
|
208
|
-
# Prevent proxy timeouts on large uploads
|
|
209
|
-
proxy_read_timeout 300s;
|
|
210
|
-
proxy_send_timeout 300s;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
Get a free SSL certificate with Certbot:
|
|
216
|
-
```bash
|
|
217
|
-
certbot --nginx -d instbyte.yourdomain.com
|
|
218
|
-
```
|
|
219
|
-
|
|
220
|
-
### Caddy
|
|
221
|
-
|
|
222
|
-
Caddy automatically handles HTTPS certificates — no Certbot needed.
|
|
223
|
-
```caddy
|
|
224
|
-
instbyte.yourdomain.com {
|
|
225
|
-
reverse_proxy localhost:3000
|
|
226
|
-
}
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
Caddy handles WebSocket forwarding and HTTPS automatically. That's all you need.
|
|
230
|
-
|
|
231
|
-
### With Docker
|
|
232
|
-
|
|
233
|
-
If running Instbyte via Docker, proxy to the mapped host port:
|
|
234
|
-
```nginx
|
|
235
|
-
proxy_pass http://localhost:3000;
|
|
236
|
-
```
|
|
237
|
-
|
|
238
|
-
Or use Docker's internal network — replace `localhost` with the container name:
|
|
239
|
-
```nginx
|
|
240
|
-
proxy_pass http://instbyte:3000;
|
|
241
|
-
```
|
|
242
|
-
|
|
243
|
-
### Keeping it LAN-only
|
|
244
|
-
|
|
245
|
-
If you don't want external access but still want HTTPS on your local network, tools like [Tailscale](https://tailscale.com) or [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) are good options that require no open ports.
|
|
117
|
+
For Nginx, Caddy, and HTTPS setup, see [Deployment Guide](docs/deployment.md).
|
|
246
118
|
|
|
247
119
|
---
|
|
248
120
|
|
|
@@ -305,6 +177,8 @@ The difference between *a tool you use* and *a tool you own.*
|
|
|
305
177
|
|
|
306
178
|
**Presence awareness** — see how many people are currently connected in real time.
|
|
307
179
|
|
|
180
|
+
**Live broadcast** — share your screen in real time with everyone on the network. Viewers join instantly from their browser, no plugins or installs needed. Built on WebRTC for smooth, low-latency video. Includes mic audio, viewer mute/unmute, raise hand, and screen capture to channel.
|
|
181
|
+
|
|
308
182
|
**Read receipts** — see how many devices have viewed each shared item. Updates live as teammates open the page.
|
|
309
183
|
|
|
310
184
|
**Item management** — add optional titles to label any item for future reference. Edit text items inline without deleting and re-pasting. Pinned items are protected from both manual deletion and auto-cleanup.
|
|
@@ -315,8 +189,35 @@ The difference between *a tool you use* and *a tool you own.*
|
|
|
315
189
|
|
|
316
190
|
---
|
|
317
191
|
|
|
192
|
+
## Broadcasting
|
|
193
|
+
|
|
194
|
+
One person shares their screen — everyone else on the network watches live in their browser. No plugins, no accounts, no external services. Built on WebRTC for smooth, low-latency video.
|
|
195
|
+
|
|
196
|
+
### How to broadcast
|
|
197
|
+
|
|
198
|
+
Click **📡 Broadcast** in the composer. Your browser will ask you to choose a screen, window, or tab to share. Once you pick one, a live bar appears at the top for all connected devices — teammates click **Join** to watch.
|
|
199
|
+
|
|
200
|
+
While broadcasting you can still use Instbyte normally — send text, drop files, switch channels. The broadcast runs in the background.
|
|
201
|
+
|
|
202
|
+
To stop, click **⏹ Stop** in the composer or use the browser's built-in "Stop sharing" bar.
|
|
203
|
+
|
|
204
|
+
### As a viewer
|
|
205
|
+
|
|
206
|
+
When a broadcast is live, a bar appears at the top of the page. Click **Join** to open the viewer panel. The panel is draggable and resizable — move it anywhere on your screen.
|
|
207
|
+
|
|
208
|
+
- **📸 Capture** — saves the current frame as an image to the active channel
|
|
209
|
+
- **✋ Raise hand** — notifies the broadcaster with a sound and toast
|
|
210
|
+
- **🔇 / 🔊** — mute and unmute audio
|
|
211
|
+
- **─** — minimize the panel without leaving the broadcast
|
|
212
|
+
- **✕** — leave the broadcast entirely
|
|
213
|
+
|
|
214
|
+
For HTTPS setup, enabling broadcast for all devices, and advanced network configuration, see the [Deployment Guide](docs/deployment.md#broadcasting).
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
318
218
|
## Keyboard Shortcuts
|
|
319
219
|
|
|
220
|
+
```
|
|
320
221
|
| Key | Action |
|
|
321
222
|
|---|---|
|
|
322
223
|
| `/` | Focus search |
|
|
@@ -346,40 +247,13 @@ node server/server.js
|
|
|
346
247
|
- Home lab file sharing without setting up NAS or cloud sync
|
|
347
248
|
- Piping build logs or stack traces from CI or terminal directly into a shared channel
|
|
348
249
|
- Sharing sensitive credentials or config files over LAN without leaving a cloud trail
|
|
250
|
+
- Live screen sharing during standups, design reviews, or debugging sessions — no Zoom link needed
|
|
349
251
|
|
|
350
252
|
---
|
|
351
253
|
|
|
352
254
|
## Terminal Usage
|
|
353
255
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
**Send a log file:**
|
|
357
|
-
```bash
|
|
358
|
-
curl -X POST http://192.168.x.x:3000/text \
|
|
359
|
-
-H "Content-Type: application/json" \
|
|
360
|
-
-d "{\"content\": \"$(cat error.log)\", \"channel\": \"general\", \"uploader\": \"terminal\"}"
|
|
361
|
-
```
|
|
362
|
-
|
|
363
|
-
**Pipe command output directly:**
|
|
364
|
-
```bash
|
|
365
|
-
npm run build 2>&1 | curl -X POST http://192.168.x.x:3000/text \
|
|
366
|
-
-H "Content-Type: application/json" \
|
|
367
|
-
--data-binary @- \
|
|
368
|
-
-H "X-Channel: general" \
|
|
369
|
-
-H "X-Uploader: CI"
|
|
370
|
-
```
|
|
371
|
-
|
|
372
|
-
**Upload a file from the terminal:**
|
|
373
|
-
```bash
|
|
374
|
-
curl -X POST http://192.168.x.x:3000/upload \
|
|
375
|
-
-F "file=@./build.log" \
|
|
376
|
-
-F "channel=general" \
|
|
377
|
-
-F "uploader=terminal"
|
|
378
|
-
```
|
|
379
|
-
|
|
380
|
-
Replace `192.168.x.x:3000` with the URL shown when Instbyte starts. If auth is enabled, add `-b "instbyte_auth=your-token"` to each request.
|
|
381
|
-
|
|
382
|
-
Useful for piping stack traces, build logs, or environment dumps straight into a channel your whole team can see instantly.
|
|
256
|
+
Push content from your terminal using curl — no browser needed. See [Terminal Usage Guide](docs/terminal-usage.md).
|
|
383
257
|
|
|
384
258
|
---
|
|
385
259
|
|
|
@@ -393,7 +267,7 @@ Instbyte follows [Semantic Versioning](https://semver.org). See [Releases](https
|
|
|
393
267
|
|
|
394
268
|
Instbyte is intentionally lightweight and LAN-first. If you want to extend it — CLI tools, themes, integrations — open an issue or submit a pull request.
|
|
395
269
|
|
|
396
|
-
The codebase has a full test suite (
|
|
270
|
+
The codebase has a full test suite (195 tests across unit and integration). Run `npm test` before submitting anything. Issues tagged **good first issue** are a good starting point.
|
|
397
271
|
|
|
398
272
|
---
|
|
399
273
|
|
package/bin/instbyte.js
CHANGED
package/client/css/app.css
CHANGED
|
@@ -1079,6 +1079,264 @@ input[type=text]:focus {
|
|
|
1079
1079
|
transition: background 1.5s ease, border-color 1.5s ease;
|
|
1080
1080
|
}
|
|
1081
1081
|
|
|
1082
|
+
/* ============================================================
|
|
1083
|
+
BROADCAST
|
|
1084
|
+
============================================================ */
|
|
1085
|
+
|
|
1086
|
+
/* Bar — sits below header, visible to all when live */
|
|
1087
|
+
.broadcast-bar {
|
|
1088
|
+
background: #111827;
|
|
1089
|
+
color: #fff;
|
|
1090
|
+
padding: 8px 20px;
|
|
1091
|
+
position: sticky;
|
|
1092
|
+
top: 0;
|
|
1093
|
+
z-index: 100;
|
|
1094
|
+
border-bottom: 1px solid #1f2937;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
.broadcast-bar-inner {
|
|
1098
|
+
max-width: 900px;
|
|
1099
|
+
margin: 0 auto;
|
|
1100
|
+
display: flex;
|
|
1101
|
+
align-items: center;
|
|
1102
|
+
gap: 10px;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
.broadcast-pulse {
|
|
1106
|
+
display: inline-block;
|
|
1107
|
+
width: 8px;
|
|
1108
|
+
height: 8px;
|
|
1109
|
+
background: #ef4444;
|
|
1110
|
+
border-radius: 50%;
|
|
1111
|
+
flex-shrink: 0;
|
|
1112
|
+
animation: broadcastPulse 1.5s ease-in-out infinite;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
@keyframes broadcastPulse {
|
|
1116
|
+
|
|
1117
|
+
0%,
|
|
1118
|
+
100% {
|
|
1119
|
+
opacity: 1;
|
|
1120
|
+
transform: scale(1);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
50% {
|
|
1124
|
+
opacity: 0.4;
|
|
1125
|
+
transform: scale(0.85);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
#broadcastLabel {
|
|
1130
|
+
font-size: 13px;
|
|
1131
|
+
font-weight: 500;
|
|
1132
|
+
flex: 1;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
.broadcast-viewers {
|
|
1136
|
+
font-size: 12px;
|
|
1137
|
+
color: #9ca3af;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
.broadcast-bar-actions {
|
|
1141
|
+
display: flex;
|
|
1142
|
+
gap: 8px;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
.broadcast-join-btn {
|
|
1146
|
+
background: #2563eb;
|
|
1147
|
+
color: #fff;
|
|
1148
|
+
border: none;
|
|
1149
|
+
border-radius: 6px;
|
|
1150
|
+
padding: 5px 14px;
|
|
1151
|
+
font-size: 13px;
|
|
1152
|
+
cursor: pointer;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
.broadcast-join-btn:hover {
|
|
1156
|
+
background: #1d4ed8;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
.broadcast-end-btn {
|
|
1160
|
+
background: #ef4444;
|
|
1161
|
+
color: #fff;
|
|
1162
|
+
border: none;
|
|
1163
|
+
border-radius: 6px;
|
|
1164
|
+
padding: 5px 14px;
|
|
1165
|
+
font-size: 13px;
|
|
1166
|
+
cursor: pointer;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
.broadcast-end-btn:hover {
|
|
1170
|
+
background: #dc2626;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/* Start button in composer */
|
|
1174
|
+
.broadcast-start-btn {
|
|
1175
|
+
background: #374151;
|
|
1176
|
+
color: #fff;
|
|
1177
|
+
border: none;
|
|
1178
|
+
border-radius: 6px;
|
|
1179
|
+
padding: 10px 14px;
|
|
1180
|
+
font-size: 13px;
|
|
1181
|
+
cursor: pointer;
|
|
1182
|
+
white-space: nowrap;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
.broadcast-start-btn:hover {
|
|
1186
|
+
background: #4b5563;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
.broadcast-start-btn.is-live {
|
|
1190
|
+
background: #ef4444;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
.broadcast-start-btn.is-live:hover {
|
|
1194
|
+
background: #dc2626;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/* ─── VIEWER PANEL ─────────────────────────────────────────── */
|
|
1198
|
+
|
|
1199
|
+
.viewer-panel {
|
|
1200
|
+
position: fixed;
|
|
1201
|
+
top: 60px;
|
|
1202
|
+
right: 20px;
|
|
1203
|
+
width: 480px;
|
|
1204
|
+
max-width: calc(100vw - 40px);
|
|
1205
|
+
background: #fff;
|
|
1206
|
+
border: 1px solid #e5e7eb;
|
|
1207
|
+
border-radius: 12px;
|
|
1208
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
|
1209
|
+
z-index: 500;
|
|
1210
|
+
display: flex;
|
|
1211
|
+
flex-direction: column;
|
|
1212
|
+
overflow: auto;
|
|
1213
|
+
resize: both;
|
|
1214
|
+
min-width: 280px;
|
|
1215
|
+
min-height: 200px;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
.viewer-panel-header {
|
|
1219
|
+
display: flex;
|
|
1220
|
+
align-items: center;
|
|
1221
|
+
justify-content: space-between;
|
|
1222
|
+
padding: 10px 14px;
|
|
1223
|
+
background: #f9fafb;
|
|
1224
|
+
border-bottom: 1px solid #e5e7eb;
|
|
1225
|
+
flex-shrink: 0;
|
|
1226
|
+
cursor: grab;
|
|
1227
|
+
user-select: none;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
.viewer-panel-header:active {
|
|
1231
|
+
cursor: grabbing;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
.viewer-minimize-btn {
|
|
1235
|
+
background: #f3f4f6;
|
|
1236
|
+
border: 1px solid #e5e7eb;
|
|
1237
|
+
border-radius: 6px;
|
|
1238
|
+
padding: 4px 10px;
|
|
1239
|
+
font-size: 12px;
|
|
1240
|
+
cursor: pointer;
|
|
1241
|
+
color: #374151;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
.viewer-minimize-btn:hover {
|
|
1245
|
+
background: #fef9c3;
|
|
1246
|
+
border-color: #fde68a;
|
|
1247
|
+
color: #92400e;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
.viewer-panel.minimized .viewer-frame,
|
|
1251
|
+
.viewer-panel.minimized .viewer-panel-actions .viewer-capture-btn,
|
|
1252
|
+
.viewer-panel.minimized .viewer-panel-actions .viewer-raise-btn,
|
|
1253
|
+
.viewer-panel.minimized .viewer-panel-actions .viewer-close-btn {
|
|
1254
|
+
display: none;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
.viewer-panel.minimized {
|
|
1258
|
+
min-height: unset;
|
|
1259
|
+
resize: none;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
#viewerLabel {
|
|
1263
|
+
font-size: 13px;
|
|
1264
|
+
font-weight: 600;
|
|
1265
|
+
color: #111827;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
.viewer-panel-actions {
|
|
1269
|
+
display: flex;
|
|
1270
|
+
gap: 6px;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
.viewer-capture-btn,
|
|
1274
|
+
.viewer-raise-btn,
|
|
1275
|
+
.viewer-close-btn {
|
|
1276
|
+
background: #f3f4f6;
|
|
1277
|
+
border: 1px solid #e5e7eb;
|
|
1278
|
+
border-radius: 6px;
|
|
1279
|
+
padding: 4px 10px;
|
|
1280
|
+
font-size: 12px;
|
|
1281
|
+
cursor: pointer;
|
|
1282
|
+
color: #374151;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
.viewer-capture-btn:hover {
|
|
1286
|
+
background: #dbeafe;
|
|
1287
|
+
border-color: #bfdbfe;
|
|
1288
|
+
color: #1d4ed8;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
.viewer-raise-btn:hover {
|
|
1292
|
+
background: #fef9c3;
|
|
1293
|
+
border-color: #fde68a;
|
|
1294
|
+
color: #92400e;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
.viewer-close-btn:hover {
|
|
1298
|
+
background: #fee2e2;
|
|
1299
|
+
border-color: #fecaca;
|
|
1300
|
+
color: #b91c1c;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
.viewer-frame {
|
|
1304
|
+
width: 100%;
|
|
1305
|
+
display: block;
|
|
1306
|
+
background: #0f1117;
|
|
1307
|
+
min-height: 180px;
|
|
1308
|
+
object-fit: contain;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/* ─── RAISE HAND TOAST ─────────────────────────────────────── */
|
|
1312
|
+
|
|
1313
|
+
.raise-hand-toast {
|
|
1314
|
+
position: fixed;
|
|
1315
|
+
bottom: 80px;
|
|
1316
|
+
left: 50%;
|
|
1317
|
+
transform: translateX(-50%);
|
|
1318
|
+
background: #111827;
|
|
1319
|
+
color: #fff;
|
|
1320
|
+
padding: 8px 16px;
|
|
1321
|
+
border-radius: 20px;
|
|
1322
|
+
font-size: 13px;
|
|
1323
|
+
z-index: 9999;
|
|
1324
|
+
pointer-events: none;
|
|
1325
|
+
animation: raiseHandIn 0.2s ease;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
@keyframes raiseHandIn {
|
|
1329
|
+
from {
|
|
1330
|
+
opacity: 0;
|
|
1331
|
+
transform: translateX(-50%) translateY(8px);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
to {
|
|
1335
|
+
opacity: 1;
|
|
1336
|
+
transform: translateX(-50%) translateY(0);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1082
1340
|
/* ============================================================
|
|
1083
1341
|
MOBILE
|
|
1084
1342
|
============================================================ */
|
|
@@ -1322,6 +1580,36 @@ input[type=text]:focus {
|
|
|
1322
1580
|
transform: translateY(0);
|
|
1323
1581
|
}
|
|
1324
1582
|
}
|
|
1583
|
+
|
|
1584
|
+
.broadcast-bar {
|
|
1585
|
+
padding: 8px 12px;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
.broadcast-bar-inner {
|
|
1589
|
+
gap: 8px;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
#broadcastLabel {
|
|
1593
|
+
font-size: 12px;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
.broadcast-start-btn {
|
|
1597
|
+
display: none;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
.viewer-panel {
|
|
1601
|
+
position: fixed;
|
|
1602
|
+
top: auto;
|
|
1603
|
+
bottom: 0;
|
|
1604
|
+
left: 0;
|
|
1605
|
+
right: 0;
|
|
1606
|
+
width: 100%;
|
|
1607
|
+
max-width: 100%;
|
|
1608
|
+
border-radius: 16px 16px 0 0;
|
|
1609
|
+
resize: none;
|
|
1610
|
+
max-height: 85vh;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1325
1613
|
}
|
|
1326
1614
|
|
|
1327
1615
|
|
|
@@ -1728,6 +2016,45 @@ input[type=text]:focus {
|
|
|
1728
2016
|
background: #1a1d27 !important;
|
|
1729
2017
|
color: #e5e7eb !important;
|
|
1730
2018
|
}
|
|
2019
|
+
|
|
2020
|
+
.broadcast-bar {
|
|
2021
|
+
background: #0f1117;
|
|
2022
|
+
border-bottom-color: #1f2937;
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
.broadcast-start-btn {
|
|
2026
|
+
background: #1f2937;
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
.broadcast-start-btn:hover {
|
|
2030
|
+
background: #374151;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
.viewer-panel {
|
|
2034
|
+
background: #1e2433;
|
|
2035
|
+
border-color: #2d3748;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
.viewer-panel-header {
|
|
2039
|
+
background: #161b27;
|
|
2040
|
+
border-bottom-color: #2d3748;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
#viewerLabel {
|
|
2044
|
+
color: #f9fafb;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
.viewer-capture-btn,
|
|
2048
|
+
.viewer-raise-btn,
|
|
2049
|
+
.viewer-close-btn {
|
|
2050
|
+
background: #2d3748;
|
|
2051
|
+
border-color: #374151;
|
|
2052
|
+
color: #d1d5db;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
.raise-hand-toast {
|
|
2056
|
+
background: #1f2937;
|
|
2057
|
+
}
|
|
1731
2058
|
}
|
|
1732
2059
|
}
|
|
1733
2060
|
|
|
@@ -2126,4 +2453,43 @@ input[type=text]:focus {
|
|
|
2126
2453
|
background: #1a1d27 !important;
|
|
2127
2454
|
color: #e5e7eb !important;
|
|
2128
2455
|
}
|
|
2456
|
+
|
|
2457
|
+
.broadcast-bar {
|
|
2458
|
+
background: #0f1117;
|
|
2459
|
+
border-bottom-color: #1f2937;
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
.broadcast-start-btn {
|
|
2463
|
+
background: #1f2937;
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
.broadcast-start-btn:hover {
|
|
2467
|
+
background: #374151;
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
.viewer-panel {
|
|
2471
|
+
background: #1e2433;
|
|
2472
|
+
border-color: #2d3748;
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
.viewer-panel-header {
|
|
2476
|
+
background: #161b27;
|
|
2477
|
+
border-bottom-color: #2d3748;
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
#viewerLabel {
|
|
2481
|
+
color: #f9fafb;
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
.viewer-capture-btn,
|
|
2485
|
+
.viewer-raise-btn,
|
|
2486
|
+
.viewer-close-btn {
|
|
2487
|
+
background: #2d3748;
|
|
2488
|
+
border-color: #374151;
|
|
2489
|
+
color: #d1d5db;
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
.raise-hand-toast {
|
|
2493
|
+
background: #1f2937;
|
|
2494
|
+
}
|
|
2129
2495
|
}
|
package/client/index.html
CHANGED
|
@@ -37,6 +37,34 @@
|
|
|
37
37
|
</div>
|
|
38
38
|
</header>
|
|
39
39
|
|
|
40
|
+
<div id="broadcastBar" class="broadcast-bar" style="display:none">
|
|
41
|
+
<div class="broadcast-bar-inner">
|
|
42
|
+
<span class="broadcast-pulse"></span>
|
|
43
|
+
<span id="broadcastLabel">Someone is broadcasting</span>
|
|
44
|
+
<span id="broadcastViewers" class="broadcast-viewers"></span>
|
|
45
|
+
<div class="broadcast-bar-actions">
|
|
46
|
+
<button id="broadcastJoinBtn" class="broadcast-join-btn" onclick="joinBroadcast()">Join</button>
|
|
47
|
+
<button id="broadcastEndBtn" class="broadcast-end-btn" onclick="stopBroadcast()" style="display:none">End
|
|
48
|
+
broadcast</button>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div id="viewerPanel" class="viewer-panel" style="display:none">
|
|
54
|
+
<div class="viewer-panel-header">
|
|
55
|
+
<span id="viewerLabel">Broadcast</span>
|
|
56
|
+
<div class="viewer-panel-actions">
|
|
57
|
+
<button onclick="captureToChannel()" class="viewer-capture-btn" title="Save frame to channel">📸
|
|
58
|
+
Capture</button>
|
|
59
|
+
<button onclick="raiseHand()" class="viewer-raise-btn" title="Raise hand">✋</button>
|
|
60
|
+
<button onclick="toggleAudio()" class="viewer-audio-btn" id="audioToggleBtn" title="Unmute audio">🔇</button>
|
|
61
|
+
<button onclick="toggleMinimize()" class="viewer-minimize-btn" title="Minimize">─</button>
|
|
62
|
+
<button onclick="leaveBroadcast()" class="viewer-close-btn" title="Leave broadcast">✕</button>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
<video id="viewerFrame" class="viewer-frame" autoplay playsinline muted></video>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
40
68
|
<div class="container">
|
|
41
69
|
<div class="channel-bar">
|
|
42
70
|
<div class="channels" id="channels"></div>
|
|
@@ -49,6 +77,9 @@
|
|
|
49
77
|
<button onclick="sendText()">Send</button>
|
|
50
78
|
<input type="file" id="fileInput" hidden multiple>
|
|
51
79
|
<button onclick="fileInput.click()">Upload</button>
|
|
80
|
+
<button id="startBroadcastBtn" onclick="startBroadcast()" class="broadcast-start-btn"
|
|
81
|
+
title="Start broadcasting your screen">📡 Broadcast</button>
|
|
82
|
+
|
|
52
83
|
</div>
|
|
53
84
|
<div class="drop" id="drop">Drag files anywhere to upload</div>
|
|
54
85
|
<div id="uploadStatus"
|
package/client/js/app.js
CHANGED
|
@@ -6,6 +6,27 @@ let retention = 24 * 60 * 60 * 1000; // default 24h, overwritten on init
|
|
|
6
6
|
const newItemIds = new Set();
|
|
7
7
|
const seenEmitted = new Set(); // item IDs this session has already emitted seen for
|
|
8
8
|
|
|
9
|
+
// BROADCAST STATE
|
|
10
|
+
let isBroadcasting = false;
|
|
11
|
+
let broadcastStream = null;
|
|
12
|
+
let broadcastChannel = 'general';
|
|
13
|
+
let audioCtx = null;
|
|
14
|
+
|
|
15
|
+
// WebRTC — broadcaster maintains one peer connection per viewer
|
|
16
|
+
// key: viewerId (socket id), value: RTCPeerConnection
|
|
17
|
+
const peerConnections = new Map();
|
|
18
|
+
|
|
19
|
+
// WebRTC — viewer holds a single peer connection to broadcaster
|
|
20
|
+
let viewerPeerConnection = null;
|
|
21
|
+
let broadcasterId = null;
|
|
22
|
+
|
|
23
|
+
const STUN_SERVERS = {
|
|
24
|
+
iceServers: [
|
|
25
|
+
{ urls: 'stun:stun.l.google.com:19302' },
|
|
26
|
+
{ urls: 'stun:stun1.l.google.com:19302' }
|
|
27
|
+
]
|
|
28
|
+
};
|
|
29
|
+
|
|
9
30
|
const seenObserver = new IntersectionObserver((entries) => {
|
|
10
31
|
entries.forEach(entry => {
|
|
11
32
|
if (!entry.isIntersecting) return;
|
|
@@ -161,7 +182,7 @@ function escapeHtml(str) {
|
|
|
161
182
|
|
|
162
183
|
function playChime() {
|
|
163
184
|
try {
|
|
164
|
-
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
185
|
+
const ctx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
|
|
165
186
|
|
|
166
187
|
const gain = ctx.createGain();
|
|
167
188
|
gain.connect(ctx.destination);
|
|
@@ -1271,7 +1292,7 @@ document.addEventListener("paste", async e => {
|
|
|
1271
1292
|
});
|
|
1272
1293
|
});
|
|
1273
1294
|
|
|
1274
|
-
async function uploadFiles(files) {
|
|
1295
|
+
async function uploadFiles(files, overrideChannel) {
|
|
1275
1296
|
if (!files || !files.length) return;
|
|
1276
1297
|
|
|
1277
1298
|
const status = document.getElementById("uploadStatus");
|
|
@@ -1279,7 +1300,7 @@ async function uploadFiles(files) {
|
|
|
1279
1300
|
const text = document.getElementById("uploadText");
|
|
1280
1301
|
|
|
1281
1302
|
const total = files.length;
|
|
1282
|
-
const targetChannel = channel; // capture at start, won't change if user switches
|
|
1303
|
+
const targetChannel = overrideChannel || channel; // capture at start, won't change if user switches
|
|
1283
1304
|
|
|
1284
1305
|
for (let i = 0; i < total; i++) {
|
|
1285
1306
|
const file = files[i];
|
|
@@ -1679,6 +1700,473 @@ function closeAllBottomSheets() {
|
|
|
1679
1700
|
}
|
|
1680
1701
|
}
|
|
1681
1702
|
|
|
1703
|
+
// ============================================================
|
|
1704
|
+
// BROADCAST
|
|
1705
|
+
// ============================================================
|
|
1706
|
+
|
|
1707
|
+
async function startBroadcast() {
|
|
1708
|
+
if (!window.isSecureContext) {
|
|
1709
|
+
showBroadcastSecureWarning();
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
const statusRes = await fetch('/broadcast/status');
|
|
1714
|
+
const status = await statusRes.json();
|
|
1715
|
+
if (status.live) {
|
|
1716
|
+
alert(`${status.uploader} is already broadcasting. Only one broadcast at a time.`);
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
const channelNames = channels.map(c => c.name);
|
|
1721
|
+
broadcastChannel = channelNames.includes('general') ? 'general' : channelNames[0];
|
|
1722
|
+
|
|
1723
|
+
let stream;
|
|
1724
|
+
try {
|
|
1725
|
+
stream = await navigator.mediaDevices.getDisplayMedia({
|
|
1726
|
+
video: { frameRate: { ideal: 30 }, width: { ideal: 1920 }, height: { ideal: 1080 } },
|
|
1727
|
+
audio: true
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
// Mix in microphone if available — failure is silent, broadcast continues without mic
|
|
1731
|
+
try {
|
|
1732
|
+
const micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
1733
|
+
micStream.getAudioTracks().forEach(track => stream.addTrack(track));
|
|
1734
|
+
} catch (e) {
|
|
1735
|
+
// Mic denied or unavailable — continue without it
|
|
1736
|
+
}
|
|
1737
|
+
} catch (e) {
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
const startRes = await fetch('/broadcast/start', {
|
|
1742
|
+
method: 'POST',
|
|
1743
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1744
|
+
body: JSON.stringify({ uploader, channel: broadcastChannel, socketId: socket.id })
|
|
1745
|
+
});
|
|
1746
|
+
|
|
1747
|
+
if (!startRes.ok) {
|
|
1748
|
+
const err = await startRes.json();
|
|
1749
|
+
alert(err.error || 'Could not start broadcast');
|
|
1750
|
+
stream.getTracks().forEach(t => t.stop());
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
broadcastStream = stream;
|
|
1755
|
+
isBroadcasting = true;
|
|
1756
|
+
|
|
1757
|
+
try {
|
|
1758
|
+
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
1759
|
+
await audioCtx.resume();
|
|
1760
|
+
} catch (e) { }
|
|
1761
|
+
|
|
1762
|
+
const startBtn = document.getElementById('startBroadcastBtn');
|
|
1763
|
+
if (startBtn) {
|
|
1764
|
+
startBtn.textContent = '⏹ Stop';
|
|
1765
|
+
startBtn.classList.add('is-live');
|
|
1766
|
+
startBtn.onclick = stopBroadcast;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
stream.getVideoTracks()[0].addEventListener('ended', () => {
|
|
1770
|
+
stopBroadcast();
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
async function stopBroadcast() {
|
|
1775
|
+
if (!isBroadcasting) return;
|
|
1776
|
+
|
|
1777
|
+
isBroadcasting = false;
|
|
1778
|
+
|
|
1779
|
+
// Reset UI immediately — before any async calls that might fail
|
|
1780
|
+
const startBtn = document.getElementById('startBroadcastBtn');
|
|
1781
|
+
if (startBtn) {
|
|
1782
|
+
startBtn.textContent = '📡 Broadcast';
|
|
1783
|
+
startBtn.classList.remove('is-live');
|
|
1784
|
+
startBtn.onclick = startBroadcast;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
// Close all peer connections to viewers
|
|
1788
|
+
peerConnections.forEach(pc => pc.close());
|
|
1789
|
+
peerConnections.clear();
|
|
1790
|
+
|
|
1791
|
+
if (broadcastStream) {
|
|
1792
|
+
broadcastStream.getTracks().forEach(t => t.stop());
|
|
1793
|
+
broadcastStream = null;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// Best effort — server may already be down
|
|
1797
|
+
try { await fetch('/broadcast/end', { method: 'POST' }); } catch (e) { }
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// Called when broadcaster gets notified a new viewer joined
|
|
1801
|
+
async function handleViewerJoined(viewerId) {
|
|
1802
|
+
if (!broadcastStream) return;
|
|
1803
|
+
|
|
1804
|
+
const pc = new RTCPeerConnection(STUN_SERVERS);
|
|
1805
|
+
peerConnections.set(viewerId, pc);
|
|
1806
|
+
|
|
1807
|
+
// Add all tracks from the screen share stream
|
|
1808
|
+
broadcastStream.getTracks().forEach(track => {
|
|
1809
|
+
pc.addTrack(track, broadcastStream);
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
// Send ICE candidates to this viewer as they're discovered
|
|
1813
|
+
pc.onicecandidate = ({ candidate }) => {
|
|
1814
|
+
if (candidate) {
|
|
1815
|
+
socket.emit('webrtc-ice', { candidate, targetId: viewerId });
|
|
1816
|
+
}
|
|
1817
|
+
};
|
|
1818
|
+
|
|
1819
|
+
pc.onconnectionstatechange = () => {
|
|
1820
|
+
if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
|
|
1821
|
+
pc.close();
|
|
1822
|
+
peerConnections.delete(viewerId);
|
|
1823
|
+
}
|
|
1824
|
+
};
|
|
1825
|
+
|
|
1826
|
+
// Create and send offer to viewer
|
|
1827
|
+
const offer = await pc.createOffer();
|
|
1828
|
+
await pc.setLocalDescription(offer);
|
|
1829
|
+
socket.emit('webrtc-offer', { offer, viewerId });
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
function showBroadcastBar(data) {
|
|
1833
|
+
const bar = document.getElementById('broadcastBar');
|
|
1834
|
+
const label = document.getElementById('broadcastLabel');
|
|
1835
|
+
const endBtn = document.getElementById('broadcastEndBtn');
|
|
1836
|
+
const joinBtn = document.getElementById('broadcastJoinBtn');
|
|
1837
|
+
|
|
1838
|
+
if (!bar) return;
|
|
1839
|
+
|
|
1840
|
+
label.textContent = `${data.uploader} is broadcasting`;
|
|
1841
|
+
bar.style.display = 'block';
|
|
1842
|
+
|
|
1843
|
+
// Show end button only to the broadcaster
|
|
1844
|
+
if (data.uploader === uploader) {
|
|
1845
|
+
endBtn.style.display = 'inline-block';
|
|
1846
|
+
joinBtn.style.display = 'none';
|
|
1847
|
+
} else {
|
|
1848
|
+
endBtn.style.display = 'none';
|
|
1849
|
+
joinBtn.style.display = 'inline-block';
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
function hideBroadcastBar() {
|
|
1854
|
+
const bar = document.getElementById('broadcastBar');
|
|
1855
|
+
if (!bar) return;
|
|
1856
|
+
|
|
1857
|
+
// Show ended toast for 5 seconds then hide bar
|
|
1858
|
+
const label = document.getElementById('broadcastLabel');
|
|
1859
|
+
label.textContent = 'Broadcast ended';
|
|
1860
|
+
|
|
1861
|
+
setTimeout(() => {
|
|
1862
|
+
bar.style.display = 'none';
|
|
1863
|
+
}, 5000);
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
function joinBroadcast() {
|
|
1867
|
+
const panel = document.getElementById('viewerPanel');
|
|
1868
|
+
const label = document.getElementById('viewerLabel');
|
|
1869
|
+
if (!panel) return;
|
|
1870
|
+
|
|
1871
|
+
label.textContent = `${document.getElementById('broadcastLabel').textContent}`;
|
|
1872
|
+
panel.style.display = 'flex';
|
|
1873
|
+
|
|
1874
|
+
// Hide join button while viewing
|
|
1875
|
+
const joinBtn = document.getElementById('broadcastJoinBtn');
|
|
1876
|
+
if (joinBtn) joinBtn.style.display = 'none';
|
|
1877
|
+
|
|
1878
|
+
// Reset position to default top-right on each join
|
|
1879
|
+
panel.style.left = '';
|
|
1880
|
+
panel.style.top = '';
|
|
1881
|
+
panel.style.right = '20px';
|
|
1882
|
+
|
|
1883
|
+
makeDraggable(panel);
|
|
1884
|
+
socket.emit('broadcast-join');
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
function leaveBroadcast() {
|
|
1888
|
+
const panel = document.getElementById('viewerPanel');
|
|
1889
|
+
if (panel) panel.style.display = 'none';
|
|
1890
|
+
|
|
1891
|
+
// Restore join button
|
|
1892
|
+
const joinBtn = document.getElementById('broadcastJoinBtn');
|
|
1893
|
+
if (joinBtn) joinBtn.style.display = 'inline-block';
|
|
1894
|
+
|
|
1895
|
+
// Reset audio button to muted state
|
|
1896
|
+
const audioBtn = document.getElementById('audioToggleBtn');
|
|
1897
|
+
if (audioBtn) {
|
|
1898
|
+
audioBtn.textContent = '🔇';
|
|
1899
|
+
audioBtn.title = 'Unmute audio';
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
// Clean up viewer peer connection
|
|
1903
|
+
if (viewerPeerConnection) {
|
|
1904
|
+
viewerPeerConnection.close();
|
|
1905
|
+
viewerPeerConnection = null;
|
|
1906
|
+
}
|
|
1907
|
+
broadcasterId = null;
|
|
1908
|
+
|
|
1909
|
+
// Stop video stream and reset audio on viewer element
|
|
1910
|
+
const video = document.getElementById('viewerFrame');
|
|
1911
|
+
if (video) {
|
|
1912
|
+
video.srcObject = null;
|
|
1913
|
+
video.muted = true;
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
async function captureToChannel() {
|
|
1918
|
+
const video = document.getElementById('viewerFrame');
|
|
1919
|
+
if (!video || !video.srcObject) return;
|
|
1920
|
+
|
|
1921
|
+
// Draw current video frame to canvas and convert to blob
|
|
1922
|
+
const canvas = document.createElement('canvas');
|
|
1923
|
+
canvas.width = video.videoWidth || 1280;
|
|
1924
|
+
canvas.height = video.videoHeight || 720;
|
|
1925
|
+
const ctx = canvas.getContext('2d');
|
|
1926
|
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
1927
|
+
|
|
1928
|
+
canvas.toBlob((blob) => {
|
|
1929
|
+
if (!blob) return;
|
|
1930
|
+
const file = new File([blob], `broadcast-${Date.now()}.jpg`, { type: 'image/jpeg' });
|
|
1931
|
+
uploadFiles([file], broadcastChannel);
|
|
1932
|
+
}, 'image/jpeg', 0.9);
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
function raiseHand() {
|
|
1936
|
+
socket.emit('broadcast-reaction', { from: uploader });
|
|
1937
|
+
|
|
1938
|
+
// Visual feedback for the person raising hand
|
|
1939
|
+
const toast = document.createElement('div');
|
|
1940
|
+
toast.className = 'raise-hand-toast';
|
|
1941
|
+
toast.textContent = '✋ Raised hand';
|
|
1942
|
+
document.body.appendChild(toast);
|
|
1943
|
+
setTimeout(() => toast.remove(), 2000);
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
function showBroadcastSecureWarning() {
|
|
1947
|
+
const bar = document.getElementById('broadcastBar');
|
|
1948
|
+
const label = document.getElementById('broadcastLabel');
|
|
1949
|
+
if (!bar || !label) return;
|
|
1950
|
+
|
|
1951
|
+
bar.style.display = 'block';
|
|
1952
|
+
label.innerHTML = `Broadcasting requires HTTPS or localhost.
|
|
1953
|
+
<a href="https://github.com/mohitgauniyal/instbyte#reverse-proxy"
|
|
1954
|
+
target="_blank"
|
|
1955
|
+
style="color:#60a5fa;text-decoration:underline">
|
|
1956
|
+
Learn more
|
|
1957
|
+
</a>`;
|
|
1958
|
+
|
|
1959
|
+
// Hide after 6 seconds
|
|
1960
|
+
setTimeout(() => {
|
|
1961
|
+
bar.style.display = 'none';
|
|
1962
|
+
label.textContent = 'Someone is broadcasting';
|
|
1963
|
+
}, 6000);
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
function toggleMinimize() {
|
|
1967
|
+
const panel = document.getElementById('viewerPanel');
|
|
1968
|
+
if (!panel) return;
|
|
1969
|
+
panel.classList.toggle('minimized');
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
function toggleAudio() {
|
|
1973
|
+
const video = document.getElementById('viewerFrame');
|
|
1974
|
+
const btn = document.getElementById('audioToggleBtn');
|
|
1975
|
+
if (!video || !btn) return;
|
|
1976
|
+
|
|
1977
|
+
video.muted = !video.muted;
|
|
1978
|
+
btn.textContent = video.muted ? '🔇' : '🔊';
|
|
1979
|
+
btn.title = video.muted ? 'Unmute audio' : 'Mute audio';
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
function makeDraggable(panel) {
|
|
1983
|
+
const header = panel.querySelector('.viewer-panel-header');
|
|
1984
|
+
if (!header) return;
|
|
1985
|
+
|
|
1986
|
+
let isDragging = false;
|
|
1987
|
+
let startX, startY, startLeft, startTop;
|
|
1988
|
+
|
|
1989
|
+
header.addEventListener('mousedown', (e) => {
|
|
1990
|
+
// Don't drag if clicking a button
|
|
1991
|
+
if (e.target.tagName === 'BUTTON') return;
|
|
1992
|
+
|
|
1993
|
+
isDragging = true;
|
|
1994
|
+
startX = e.clientX;
|
|
1995
|
+
startY = e.clientY;
|
|
1996
|
+
|
|
1997
|
+
const rect = panel.getBoundingClientRect();
|
|
1998
|
+
startLeft = rect.left;
|
|
1999
|
+
startTop = rect.top;
|
|
2000
|
+
|
|
2001
|
+
// Switch from right-anchored to left-anchored positioning
|
|
2002
|
+
panel.style.right = 'auto';
|
|
2003
|
+
panel.style.left = startLeft + 'px';
|
|
2004
|
+
panel.style.top = startTop + 'px';
|
|
2005
|
+
|
|
2006
|
+
e.preventDefault();
|
|
2007
|
+
});
|
|
2008
|
+
|
|
2009
|
+
document.addEventListener('mousemove', (e) => {
|
|
2010
|
+
if (!isDragging) return;
|
|
2011
|
+
|
|
2012
|
+
const dx = e.clientX - startX;
|
|
2013
|
+
const dy = e.clientY - startY;
|
|
2014
|
+
|
|
2015
|
+
let newLeft = startLeft + dx;
|
|
2016
|
+
let newTop = startTop + dy;
|
|
2017
|
+
|
|
2018
|
+
// Keep within viewport
|
|
2019
|
+
newLeft = Math.max(0, Math.min(newLeft, window.innerWidth - panel.offsetWidth));
|
|
2020
|
+
newTop = Math.max(0, Math.min(newTop, window.innerHeight - panel.offsetHeight));
|
|
2021
|
+
|
|
2022
|
+
panel.style.left = newLeft + 'px';
|
|
2023
|
+
panel.style.top = newTop + 'px';
|
|
2024
|
+
});
|
|
2025
|
+
|
|
2026
|
+
document.addEventListener('mouseup', () => {
|
|
2027
|
+
isDragging = false;
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
// ─── SOCKET LISTENERS ────────────────────────────────────────
|
|
2032
|
+
|
|
2033
|
+
socket.on('broadcast-started', (data) => {
|
|
2034
|
+
showBroadcastBar(data);
|
|
2035
|
+
});
|
|
2036
|
+
|
|
2037
|
+
socket.on('broadcast-ended', () => {
|
|
2038
|
+
hideBroadcastBar();
|
|
2039
|
+
leaveBroadcast();
|
|
2040
|
+
|
|
2041
|
+
if (isBroadcasting) {
|
|
2042
|
+
isBroadcasting = false;
|
|
2043
|
+
const startBtn = document.getElementById('startBroadcastBtn');
|
|
2044
|
+
if (startBtn) {
|
|
2045
|
+
startBtn.textContent = '📡 Broadcast';
|
|
2046
|
+
startBtn.classList.remove('is-live');
|
|
2047
|
+
startBtn.onclick = startBroadcast;
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
});
|
|
2051
|
+
|
|
2052
|
+
// ─── WEBRTC — BROADCASTER SIDE ───────────────────────────────
|
|
2053
|
+
|
|
2054
|
+
// A new viewer joined — broadcaster initiates peer connection
|
|
2055
|
+
socket.on('webrtc-viewer-joined', ({ viewerId }) => {
|
|
2056
|
+
if (!isBroadcasting) return;
|
|
2057
|
+
handleViewerJoined(viewerId);
|
|
2058
|
+
});
|
|
2059
|
+
|
|
2060
|
+
// Broadcaster receives answer from a viewer
|
|
2061
|
+
socket.on('webrtc-answer', async ({ answer, viewerId }) => {
|
|
2062
|
+
const pc = peerConnections.get(viewerId);
|
|
2063
|
+
if (!pc) return;
|
|
2064
|
+
try {
|
|
2065
|
+
await pc.setRemoteDescription(new RTCSessionDescription(answer));
|
|
2066
|
+
} catch (e) {
|
|
2067
|
+
console.error('Error setting remote description on broadcaster:', e);
|
|
2068
|
+
}
|
|
2069
|
+
});
|
|
2070
|
+
|
|
2071
|
+
// ─── WEBRTC — VIEWER SIDE ────────────────────────────────────
|
|
2072
|
+
|
|
2073
|
+
// Viewer receives offer from broadcaster
|
|
2074
|
+
socket.on('webrtc-offer', async ({ offer, broadcasterId: bid }) => {
|
|
2075
|
+
broadcasterId = bid;
|
|
2076
|
+
|
|
2077
|
+
// Clean up any existing peer connection
|
|
2078
|
+
if (viewerPeerConnection) {
|
|
2079
|
+
viewerPeerConnection.close();
|
|
2080
|
+
viewerPeerConnection = null;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
const pc = new RTCPeerConnection(STUN_SERVERS);
|
|
2084
|
+
viewerPeerConnection = pc;
|
|
2085
|
+
|
|
2086
|
+
// When stream arrives, attach to video element
|
|
2087
|
+
pc.ontrack = ({ streams }) => {
|
|
2088
|
+
const video = document.getElementById('viewerFrame');
|
|
2089
|
+
if (video && streams[0]) {
|
|
2090
|
+
video.srcObject = streams[0];
|
|
2091
|
+
}
|
|
2092
|
+
};
|
|
2093
|
+
|
|
2094
|
+
// Send ICE candidates back to broadcaster
|
|
2095
|
+
pc.onicecandidate = ({ candidate }) => {
|
|
2096
|
+
if (candidate) {
|
|
2097
|
+
socket.emit('webrtc-ice', { candidate, targetId: broadcasterId });
|
|
2098
|
+
}
|
|
2099
|
+
};
|
|
2100
|
+
|
|
2101
|
+
pc.onconnectionstatechange = () => {
|
|
2102
|
+
if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
|
|
2103
|
+
pc.close();
|
|
2104
|
+
viewerPeerConnection = null;
|
|
2105
|
+
}
|
|
2106
|
+
};
|
|
2107
|
+
|
|
2108
|
+
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
|
2109
|
+
const answer = await pc.createAnswer();
|
|
2110
|
+
await pc.setLocalDescription(answer);
|
|
2111
|
+
socket.emit('webrtc-answer', { answer, broadcasterId });
|
|
2112
|
+
});
|
|
2113
|
+
|
|
2114
|
+
// ICE candidates — both broadcaster and viewer use same handler
|
|
2115
|
+
socket.on('webrtc-ice', async ({ candidate, fromId }) => {
|
|
2116
|
+
// Broadcaster receives from viewer
|
|
2117
|
+
if (isBroadcasting) {
|
|
2118
|
+
const pc = peerConnections.get(fromId);
|
|
2119
|
+
if (pc) {
|
|
2120
|
+
try { await pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (e) { }
|
|
2121
|
+
}
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
// Viewer receives from broadcaster
|
|
2126
|
+
if (viewerPeerConnection) {
|
|
2127
|
+
try { await viewerPeerConnection.addIceCandidate(new RTCIceCandidate(candidate)); } catch (e) { }
|
|
2128
|
+
}
|
|
2129
|
+
});
|
|
2130
|
+
|
|
2131
|
+
socket.on('disconnect', () => {
|
|
2132
|
+
// Trigger the same cleanup flow as a normal broadcast-ended event
|
|
2133
|
+
if (isBroadcasting || viewerPeerConnection) {
|
|
2134
|
+
isBroadcasting = false;
|
|
2135
|
+
|
|
2136
|
+
peerConnections.forEach(pc => pc.close());
|
|
2137
|
+
peerConnections.clear();
|
|
2138
|
+
|
|
2139
|
+
if (broadcastStream) {
|
|
2140
|
+
broadcastStream.getTracks().forEach(t => t.stop());
|
|
2141
|
+
broadcastStream = null;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
const startBtn = document.getElementById('startBroadcastBtn');
|
|
2145
|
+
if (startBtn) {
|
|
2146
|
+
startBtn.textContent = '📡 Broadcast';
|
|
2147
|
+
startBtn.classList.remove('is-live');
|
|
2148
|
+
startBtn.onclick = startBroadcast;
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
hideBroadcastBar();
|
|
2152
|
+
leaveBroadcast();
|
|
2153
|
+
}
|
|
2154
|
+
});
|
|
2155
|
+
|
|
2156
|
+
// ─── RAISE HAND ──────────────────────────────────────────────
|
|
2157
|
+
|
|
2158
|
+
socket.on('broadcast-reaction-received', ({ from }) => {
|
|
2159
|
+
if (!isBroadcasting) return;
|
|
2160
|
+
|
|
2161
|
+
playChime();
|
|
2162
|
+
|
|
2163
|
+
const toast = document.createElement('div');
|
|
2164
|
+
toast.className = 'raise-hand-toast';
|
|
2165
|
+
toast.textContent = `✋ ${from} raised their hand`;
|
|
2166
|
+
document.body.appendChild(toast);
|
|
2167
|
+
setTimeout(() => toast.remove(), 8000);
|
|
2168
|
+
});
|
|
2169
|
+
|
|
1682
2170
|
(async function init() {
|
|
1683
2171
|
await applyBranding();
|
|
1684
2172
|
await initName();
|
|
@@ -1690,4 +2178,11 @@ function closeAllBottomSheets() {
|
|
|
1690
2178
|
retention = info.retention; // null if "never", ms value otherwise
|
|
1691
2179
|
await loadChannels();
|
|
1692
2180
|
load();
|
|
2181
|
+
|
|
2182
|
+
// Check if a broadcast is already live when page loads
|
|
2183
|
+
const broadcastRes = await fetch('/broadcast/status');
|
|
2184
|
+
const broadcastStatus = await broadcastRes.json();
|
|
2185
|
+
if (broadcastStatus.live) {
|
|
2186
|
+
showBroadcastBar(broadcastStatus);
|
|
2187
|
+
}
|
|
1693
2188
|
})();
|
package/package.json
CHANGED
package/server/server.js
CHANGED
|
@@ -364,6 +364,13 @@ const channelLimiter = rateLimit({
|
|
|
364
364
|
message: { error: "Too many requests, try again later" }
|
|
365
365
|
});
|
|
366
366
|
|
|
367
|
+
const broadcastLimiter = rateLimit({
|
|
368
|
+
windowMs: 60 * 1000,
|
|
369
|
+
max: 5,
|
|
370
|
+
skip: () => process.env.NODE_ENV === 'test',
|
|
371
|
+
message: { error: "Too many broadcast attempts, slow down" }
|
|
372
|
+
});
|
|
373
|
+
|
|
367
374
|
/* LOGIN POST */
|
|
368
375
|
app.post("/login", loginLimiter, (req, res) => {
|
|
369
376
|
if (!config.auth.passphrase) return res.redirect("/");
|
|
@@ -812,6 +819,62 @@ app.get("/health", (req, res) => {
|
|
|
812
819
|
});
|
|
813
820
|
});
|
|
814
821
|
|
|
822
|
+
/* BROADCAST*/
|
|
823
|
+
/* GET /broadcast/status — returns current broadcast or null */
|
|
824
|
+
app.get("/broadcast/status", (req, res) => {
|
|
825
|
+
if (!currentBroadcast) return res.json({ live: false });
|
|
826
|
+
res.json({
|
|
827
|
+
live: true,
|
|
828
|
+
uploader: currentBroadcast.uploader,
|
|
829
|
+
channel: currentBroadcast.channel,
|
|
830
|
+
startedAt: currentBroadcast.startedAt
|
|
831
|
+
});
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
/* POST /broadcast/start */
|
|
835
|
+
app.post("/broadcast/start", broadcastLimiter, (req, res) => {
|
|
836
|
+
|
|
837
|
+
if (currentBroadcast) {
|
|
838
|
+
return res.status(409).json({
|
|
839
|
+
error: "Broadcast already in progress",
|
|
840
|
+
uploader: currentBroadcast.uploader
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const { uploader, channel, socketId } = req.body;
|
|
845
|
+
if (!uploader || !channel) {
|
|
846
|
+
return res.status(400).json({ error: "uploader and channel required" });
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
currentBroadcast = {
|
|
850
|
+
uploader,
|
|
851
|
+
channel,
|
|
852
|
+
startedAt: Date.now(),
|
|
853
|
+
lastFrame: null,
|
|
854
|
+
socketId
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
io.emit("broadcast-started", {
|
|
858
|
+
uploader: currentBroadcast.uploader,
|
|
859
|
+
channel: currentBroadcast.channel,
|
|
860
|
+
startedAt: currentBroadcast.startedAt
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
console.log(`Broadcast started by ${uploader}`);
|
|
864
|
+
res.json({ ok: true, ...currentBroadcast });
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
/* POST /broadcast/end */
|
|
868
|
+
app.post("/broadcast/end", (req, res) => {
|
|
869
|
+
if (!currentBroadcast) return res.status(400).json({ error: "No broadcast in progress" });
|
|
870
|
+
|
|
871
|
+
const uploader = currentBroadcast.uploader;
|
|
872
|
+
currentBroadcast = null;
|
|
873
|
+
|
|
874
|
+
io.emit("broadcast-ended", { uploader });
|
|
875
|
+
console.log(`Broadcast ended by ${uploader}`);
|
|
876
|
+
res.json({ ok: true });
|
|
877
|
+
});
|
|
815
878
|
|
|
816
879
|
/* FAVICON */
|
|
817
880
|
app.get("/favicon-dynamic.png", async (req, res) => {
|
|
@@ -867,6 +930,9 @@ app.get("/logo-dynamic.png", (req, res) => {
|
|
|
867
930
|
// resets on server restart, no DB needed
|
|
868
931
|
const seenBy = new Map();
|
|
869
932
|
|
|
933
|
+
// Broadcast state — null when IDLE, populated when LIVE
|
|
934
|
+
let currentBroadcast = null;
|
|
935
|
+
|
|
870
936
|
let connectedUsers = 0;
|
|
871
937
|
|
|
872
938
|
io.on("connection", (socket) => {
|
|
@@ -889,10 +955,56 @@ io.on("connection", (socket) => {
|
|
|
889
955
|
io.emit("seen-update", { id, count });
|
|
890
956
|
});
|
|
891
957
|
|
|
958
|
+
// WebRTC — viewer joins, notify broadcaster to initiate peer connection
|
|
959
|
+
socket.on("broadcast-join", () => {
|
|
960
|
+
if (!currentBroadcast) return;
|
|
961
|
+
// tell broadcaster a new viewer has joined, pass viewer's socket id
|
|
962
|
+
const broadcasterSocket = io.sockets.sockets.get(currentBroadcast.socketId);
|
|
963
|
+
if (broadcasterSocket) {
|
|
964
|
+
broadcasterSocket.emit("webrtc-viewer-joined", { viewerId: socket.id });
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
// WebRTC — broadcaster sends offer to a specific viewer
|
|
969
|
+
socket.on("webrtc-offer", ({ offer, viewerId }) => {
|
|
970
|
+
const viewerSocket = io.sockets.sockets.get(viewerId);
|
|
971
|
+
if (viewerSocket) {
|
|
972
|
+
viewerSocket.emit("webrtc-offer", { offer, broadcasterId: socket.id });
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
// WebRTC — viewer sends answer back to broadcaster
|
|
977
|
+
socket.on("webrtc-answer", ({ answer, broadcasterId }) => {
|
|
978
|
+
const broadcasterSocket = io.sockets.sockets.get(broadcasterId);
|
|
979
|
+
if (broadcasterSocket) {
|
|
980
|
+
broadcasterSocket.emit("webrtc-answer", { answer, viewerId: socket.id });
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// WebRTC — ICE candidate exchange, both directions
|
|
985
|
+
socket.on("webrtc-ice", ({ candidate, targetId }) => {
|
|
986
|
+
const targetSocket = io.sockets.sockets.get(targetId);
|
|
987
|
+
if (targetSocket) {
|
|
988
|
+
targetSocket.emit("webrtc-ice", { candidate, fromId: socket.id });
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
// Broadcast — raise hand, relay to broadcaster
|
|
993
|
+
socket.on("broadcast-reaction", ({ from }) => {
|
|
994
|
+
if (!currentBroadcast) return;
|
|
995
|
+
io.emit("broadcast-reaction-received", { from });
|
|
996
|
+
});
|
|
997
|
+
|
|
892
998
|
socket.on("disconnect", () => {
|
|
893
999
|
connectedUsers--;
|
|
894
1000
|
console.log(username + " disconnected | total:", connectedUsers);
|
|
895
1001
|
io.emit("user-count", connectedUsers);
|
|
1002
|
+
|
|
1003
|
+
if (currentBroadcast && currentBroadcast.socketId === socket.id) {
|
|
1004
|
+
currentBroadcast = null;
|
|
1005
|
+
io.emit("broadcast-ended");
|
|
1006
|
+
console.log("Broadcast ended — broadcaster disconnected");
|
|
1007
|
+
}
|
|
896
1008
|
});
|
|
897
1009
|
});
|
|
898
1010
|
|
|
@@ -941,19 +1053,21 @@ const PREFERRED = parseInt(process.env.PORT) || config.server.port;
|
|
|
941
1053
|
const localIP = getLocalIP();
|
|
942
1054
|
|
|
943
1055
|
let PORT;
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1056
|
+
if (process.env.INSTBYTE_BOOT === '1') {
|
|
1057
|
+
findFreePort(PREFERRED).then(p => {
|
|
1058
|
+
PORT = p;
|
|
1059
|
+
server.listen(PORT, () => {
|
|
1060
|
+
console.log("\nInstbyte running");
|
|
1061
|
+
console.log("Local: http://localhost:" + PORT);
|
|
1062
|
+
console.log("Network: http://" + localIP + ":" + PORT);
|
|
1063
|
+
if (PORT !== PREFERRED) {
|
|
1064
|
+
console.log(`(port ${PREFERRED} was busy, switched to ${PORT})`);
|
|
1065
|
+
}
|
|
1066
|
+
console.log("");
|
|
1067
|
+
scanOrphans();
|
|
1068
|
+
});
|
|
955
1069
|
});
|
|
956
|
-
}
|
|
1070
|
+
}
|
|
957
1071
|
|
|
958
1072
|
module.exports = { app, server, sessions };
|
|
959
1073
|
|