tangled
alpha
login
or
join now
baileytownsend.dev
/
bootleg-stream-dot-place
10
fork
atom
A stream.place client in a single index.html
10
fork
atom
overview
issues
pulls
pipelines
it's working
baileytownsend.dev
1 week ago
8202d95f
+790
1 changed file
expand all
collapse all
unified
split
index.html
+790
index.html
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
<!DOCTYPE html>
2
+
<html lang="en">
3
+
<head>
4
+
<meta charset="UTF-8">
5
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+
<title>stream.place viewer</title>
7
+
<style>
8
+
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&family=Outfit:wght@300;400;600;700&display=swap');
9
+
10
+
:root {
11
+
--bg: #0a0a0b;
12
+
--surface: #141416;
13
+
--border: #222228;
14
+
--text: #e8e8ec;
15
+
--text-dim: #6e6e7a;
16
+
--accent: #4ade80;
17
+
--accent-dim: #4ade8020;
18
+
--red: #f87171;
19
+
--red-dim: #f8717120;
20
+
}
21
+
22
+
* { margin: 0; padding: 0; box-sizing: border-box; }
23
+
24
+
body {
25
+
background: var(--bg);
26
+
color: var(--text);
27
+
font-family: 'Outfit', sans-serif;
28
+
min-height: 100vh;
29
+
display: flex;
30
+
flex-direction: column;
31
+
}
32
+
33
+
.header {
34
+
width: 100%;
35
+
padding: 1.25rem 2rem;
36
+
display: flex;
37
+
align-items: center;
38
+
gap: 0.75rem;
39
+
border-bottom: 1px solid var(--border);
40
+
flex-shrink: 0;
41
+
}
42
+
43
+
.header .logo {
44
+
font-family: 'JetBrains Mono', monospace;
45
+
font-weight: 600;
46
+
font-size: 0.85rem;
47
+
letter-spacing: -0.02em;
48
+
color: var(--text-dim);
49
+
}
50
+
51
+
.header .logo span {
52
+
color: var(--accent);
53
+
}
54
+
55
+
.connect-bar {
56
+
width: 100%;
57
+
padding: 1.25rem 2rem;
58
+
display: flex;
59
+
gap: 0.75rem;
60
+
align-items: stretch;
61
+
flex-shrink: 0;
62
+
}
63
+
64
+
.input-wrapper {
65
+
flex: 1;
66
+
max-width: 400px;
67
+
position: relative;
68
+
display: flex;
69
+
align-items: center;
70
+
}
71
+
72
+
.input-wrapper input {
73
+
width: 100%;
74
+
padding: 0.75rem 1rem;
75
+
background: var(--surface);
76
+
border: 1px solid var(--border);
77
+
border-radius: 10px;
78
+
color: var(--text);
79
+
font-family: 'JetBrains Mono', monospace;
80
+
font-size: 0.85rem;
81
+
outline: none;
82
+
transition: border-color 0.2s, box-shadow 0.2s;
83
+
}
84
+
85
+
.input-wrapper input:focus {
86
+
border-color: var(--accent);
87
+
box-shadow: 0 0 0 3px var(--accent-dim);
88
+
}
89
+
90
+
.input-wrapper input::placeholder {
91
+
color: var(--text-dim);
92
+
opacity: 0.5;
93
+
}
94
+
95
+
.btn {
96
+
padding: 0.75rem 1.5rem;
97
+
border-radius: 10px;
98
+
font-family: 'Outfit', sans-serif;
99
+
font-weight: 600;
100
+
font-size: 0.85rem;
101
+
cursor: pointer;
102
+
border: none;
103
+
transition: all 0.15s;
104
+
white-space: nowrap;
105
+
}
106
+
107
+
.btn-connect {
108
+
background: var(--accent);
109
+
color: var(--bg);
110
+
}
111
+
.btn-connect:hover {
112
+
filter: brightness(1.1);
113
+
transform: translateY(-1px);
114
+
}
115
+
.btn-connect:active { transform: translateY(0); }
116
+
117
+
.btn-disconnect {
118
+
background: var(--red-dim);
119
+
color: var(--red);
120
+
border: 1px solid var(--red);
121
+
display: none;
122
+
}
123
+
.btn-disconnect:hover {
124
+
background: var(--red);
125
+
color: var(--bg);
126
+
}
127
+
128
+
/* ---- Main layout: video + chat side by side ---- */
129
+
.main-layout {
130
+
flex: 1;
131
+
display: flex;
132
+
gap: 0;
133
+
min-height: 0;
134
+
padding: 0 2rem 1.5rem;
135
+
}
136
+
137
+
.video-column {
138
+
flex: 1;
139
+
min-width: 0;
140
+
display: flex;
141
+
flex-direction: column;
142
+
}
143
+
144
+
.video-frame {
145
+
position: relative;
146
+
width: 100%;
147
+
aspect-ratio: 16 / 9;
148
+
background: var(--surface);
149
+
border-radius: 12px 0 0 12px;
150
+
overflow: hidden;
151
+
border: 1px solid var(--border);
152
+
border-right: none;
153
+
}
154
+
155
+
.video-frame video {
156
+
width: 100%;
157
+
height: 100%;
158
+
object-fit: contain;
159
+
display: block;
160
+
}
161
+
162
+
.video-overlay {
163
+
position: absolute;
164
+
inset: 0;
165
+
display: flex;
166
+
flex-direction: column;
167
+
align-items: center;
168
+
justify-content: center;
169
+
gap: 1rem;
170
+
pointer-events: none;
171
+
transition: opacity 0.3s;
172
+
}
173
+
174
+
.video-overlay.hidden { opacity: 0; }
175
+
176
+
.video-overlay .idle-icon {
177
+
width: 48px;
178
+
height: 48px;
179
+
border-radius: 50%;
180
+
border: 2px solid var(--border);
181
+
display: flex;
182
+
align-items: center;
183
+
justify-content: center;
184
+
}
185
+
186
+
.video-overlay .idle-icon svg {
187
+
width: 20px;
188
+
height: 20px;
189
+
fill: var(--text-dim);
190
+
}
191
+
192
+
.video-overlay .idle-text {
193
+
font-size: 0.85rem;
194
+
color: var(--text-dim);
195
+
font-weight: 300;
196
+
}
197
+
198
+
.status-bar {
199
+
display: flex;
200
+
align-items: center;
201
+
gap: 0.75rem;
202
+
padding: 0.75rem 0 0;
203
+
font-family: 'JetBrains Mono', monospace;
204
+
font-size: 0.72rem;
205
+
color: var(--text-dim);
206
+
}
207
+
208
+
.status-dot {
209
+
width: 8px;
210
+
height: 8px;
211
+
border-radius: 50%;
212
+
background: var(--border);
213
+
transition: background 0.3s;
214
+
flex-shrink: 0;
215
+
}
216
+
217
+
.status-dot.live {
218
+
background: var(--accent);
219
+
box-shadow: 0 0 8px var(--accent-dim);
220
+
animation: pulse 2s ease-in-out infinite;
221
+
}
222
+
223
+
.status-dot.error { background: var(--red); }
224
+
225
+
@keyframes pulse {
226
+
0%, 100% { opacity: 1; }
227
+
50% { opacity: 0.5; }
228
+
}
229
+
230
+
.status-text { flex: 1; }
231
+
232
+
.status-stats {
233
+
color: var(--text-dim);
234
+
opacity: 0.6;
235
+
}
236
+
237
+
/* ---- Chat panel ---- */
238
+
.chat-panel {
239
+
width: 340px;
240
+
flex-shrink: 0;
241
+
display: flex;
242
+
flex-direction: column;
243
+
border: 1px solid var(--border);
244
+
border-radius: 0 12px 12px 0;
245
+
background: var(--surface);
246
+
overflow: hidden;
247
+
}
248
+
249
+
.chat-header {
250
+
padding: 0.85rem 1rem;
251
+
border-bottom: 1px solid var(--border);
252
+
display: flex;
253
+
align-items: center;
254
+
gap: 0.5rem;
255
+
flex-shrink: 0;
256
+
}
257
+
258
+
.chat-header-title {
259
+
font-family: 'JetBrains Mono', monospace;
260
+
font-size: 0.75rem;
261
+
font-weight: 500;
262
+
color: var(--text-dim);
263
+
text-transform: uppercase;
264
+
letter-spacing: 0.05em;
265
+
}
266
+
267
+
.chat-header-count {
268
+
font-family: 'JetBrains Mono', monospace;
269
+
font-size: 0.65rem;
270
+
color: var(--text-dim);
271
+
opacity: 0.5;
272
+
margin-left: auto;
273
+
}
274
+
275
+
.chat-ws-dot {
276
+
width: 6px;
277
+
height: 6px;
278
+
border-radius: 50%;
279
+
background: var(--border);
280
+
flex-shrink: 0;
281
+
transition: background 0.3s;
282
+
}
283
+
284
+
.chat-ws-dot.connected {
285
+
background: var(--accent);
286
+
}
287
+
288
+
.chat-messages {
289
+
flex: 1;
290
+
overflow-y: auto;
291
+
padding: 0.5rem 0;
292
+
display: flex;
293
+
flex-direction: column;
294
+
min-height: 0;
295
+
}
296
+
297
+
.chat-messages::-webkit-scrollbar {
298
+
width: 4px;
299
+
}
300
+
.chat-messages::-webkit-scrollbar-track {
301
+
background: transparent;
302
+
}
303
+
.chat-messages::-webkit-scrollbar-thumb {
304
+
background: var(--border);
305
+
border-radius: 2px;
306
+
}
307
+
308
+
.chat-msg {
309
+
padding: 0.35rem 1rem;
310
+
font-size: 0.82rem;
311
+
line-height: 1.45;
312
+
transition: background 0.15s;
313
+
word-break: break-word;
314
+
}
315
+
316
+
.chat-msg:hover {
317
+
background: #ffffff06;
318
+
}
319
+
320
+
.chat-msg-author {
321
+
font-weight: 600;
322
+
margin-right: 0.35rem;
323
+
cursor: default;
324
+
}
325
+
326
+
.chat-msg-text {
327
+
color: var(--text);
328
+
font-weight: 300;
329
+
}
330
+
331
+
.chat-msg-time {
332
+
font-family: 'JetBrains Mono', monospace;
333
+
font-size: 0.6rem;
334
+
color: var(--text-dim);
335
+
opacity: 0;
336
+
margin-left: 0.35rem;
337
+
transition: opacity 0.15s;
338
+
}
339
+
340
+
.chat-msg:hover .chat-msg-time {
341
+
opacity: 0.6;
342
+
}
343
+
344
+
.chat-empty {
345
+
flex: 1;
346
+
display: flex;
347
+
align-items: center;
348
+
justify-content: center;
349
+
color: var(--text-dim);
350
+
font-size: 0.8rem;
351
+
font-weight: 300;
352
+
opacity: 0.5;
353
+
}
354
+
355
+
/* ---- Log panel ---- */
356
+
.log-panel {
357
+
margin-top: 0.75rem;
358
+
max-height: 100px;
359
+
overflow-y: auto;
360
+
background: var(--surface);
361
+
border: 1px solid var(--border);
362
+
border-radius: 8px;
363
+
padding: 0.6rem;
364
+
font-family: 'JetBrains Mono', monospace;
365
+
font-size: 0.65rem;
366
+
line-height: 1.6;
367
+
color: var(--text-dim);
368
+
display: none;
369
+
}
370
+
371
+
.log-panel.visible { display: block; }
372
+
.log-panel .log-line.error { color: var(--red); }
373
+
.log-panel .log-line.success { color: var(--accent); }
374
+
375
+
/* ---- Responsive ---- */
376
+
@media (max-width: 800px) {
377
+
.main-layout {
378
+
flex-direction: column;
379
+
padding: 0 1rem 1rem;
380
+
}
381
+
.video-frame {
382
+
border-radius: 12px 12px 0 0;
383
+
border-right: 1px solid var(--border);
384
+
border-bottom: none;
385
+
}
386
+
.chat-panel {
387
+
width: 100%;
388
+
border-radius: 0 0 12px 12px;
389
+
max-height: 300px;
390
+
}
391
+
.connect-bar {
392
+
flex-direction: column;
393
+
padding: 1rem;
394
+
}
395
+
.input-wrapper {
396
+
max-width: none;
397
+
}
398
+
.header {
399
+
padding: 1rem;
400
+
}
401
+
}
402
+
</style>
403
+
</head>
404
+
<body>
405
+
406
+
<div class="header">
407
+
<div class="logo"><span>▶</span> stream.place viewer</div>
408
+
</div>
409
+
410
+
<div class="connect-bar">
411
+
<div class="input-wrapper">
412
+
<input
413
+
type="text"
414
+
id="username"
415
+
placeholder="pokemon.evil.gay"
416
+
spellcheck="false"
417
+
autocomplete="off"
418
+
/>
419
+
</div>
420
+
<button class="btn btn-connect" id="connectBtn" onclick="connect()">Connect</button>
421
+
<button class="btn btn-disconnect" id="disconnectBtn" onclick="disconnect()">Disconnect</button>
422
+
</div>
423
+
424
+
<div class="main-layout">
425
+
<div class="video-column">
426
+
<div class="video-frame">
427
+
<video id="video" autoplay playsinline muted></video>
428
+
<div class="video-overlay" id="overlay">
429
+
<div class="idle-icon">
430
+
<svg viewBox="0 0 24 24"><polygon points="5,3 19,12 5,21"/></svg>
431
+
</div>
432
+
<div class="idle-text">Enter a username to start watching</div>
433
+
</div>
434
+
</div>
435
+
436
+
<div class="status-bar">
437
+
<div class="status-dot" id="statusDot"></div>
438
+
<div class="status-text" id="statusText">Idle</div>
439
+
<div class="status-stats" id="statusStats"></div>
440
+
</div>
441
+
442
+
<div class="log-panel" id="logPanel"></div>
443
+
</div>
444
+
445
+
<div class="chat-panel">
446
+
<div class="chat-header">
447
+
<div class="chat-ws-dot" id="chatWsDot"></div>
448
+
<div class="chat-header-title">Chat</div>
449
+
<div class="chat-header-count" id="chatCount"></div>
450
+
</div>
451
+
<div class="chat-messages" id="chatMessages">
452
+
<div class="chat-empty" id="chatEmpty">No messages yet</div>
453
+
</div>
454
+
</div>
455
+
</div>
456
+
457
+
<script>
458
+
let pc = null;
459
+
let ws = null;
460
+
let statsInterval = null;
461
+
let chatMsgCount = 0;
462
+
const MAX_CHAT_MESSAGES = 500;
463
+
464
+
const video = document.getElementById('video');
465
+
const overlay = document.getElementById('overlay');
466
+
const statusDot = document.getElementById('statusDot');
467
+
const statusText = document.getElementById('statusText');
468
+
const statusStats = document.getElementById('statusStats');
469
+
const logPanel = document.getElementById('logPanel');
470
+
const connectBtn = document.getElementById('connectBtn');
471
+
const disconnectBtn = document.getElementById('disconnectBtn');
472
+
const usernameInput = document.getElementById('username');
473
+
const chatMessages = document.getElementById('chatMessages');
474
+
const chatEmpty = document.getElementById('chatEmpty');
475
+
const chatWsDot = document.getElementById('chatWsDot');
476
+
const chatCount = document.getElementById('chatCount');
477
+
478
+
usernameInput.addEventListener('keydown', (e) => {
479
+
if (e.key === 'Enter') connect();
480
+
});
481
+
482
+
function log(msg, type = '') {
483
+
logPanel.classList.add('visible');
484
+
const line = document.createElement('div');
485
+
line.className = 'log-line' + (type ? ` ${type}` : '');
486
+
const ts = new Date().toLocaleTimeString('en-US', { hour12: false });
487
+
line.textContent = `${ts} ${msg}`;
488
+
logPanel.appendChild(line);
489
+
logPanel.scrollTop = logPanel.scrollHeight;
490
+
}
491
+
492
+
function setStatus(text, state = '') {
493
+
statusText.textContent = text;
494
+
statusDot.className = 'status-dot' + (state ? ` ${state}` : '');
495
+
}
496
+
497
+
// ---- Chat WebSocket ----
498
+
499
+
function connectChat(username) {
500
+
if (ws) {
501
+
ws.close();
502
+
ws = null;
503
+
}
504
+
505
+
const wsUrl = `wss://stream.place/api/websocket/${encodeURIComponent(username)}`;
506
+
log(`Chat WS: ${wsUrl}`);
507
+
508
+
ws = new WebSocket(wsUrl);
509
+
510
+
ws.onopen = () => {
511
+
log('Chat connected', 'success');
512
+
chatWsDot.classList.add('connected');
513
+
};
514
+
515
+
ws.onclose = (e) => {
516
+
log(`Chat disconnected (code ${e.code})`);
517
+
chatWsDot.classList.remove('connected');
518
+
};
519
+
520
+
ws.onerror = () => {
521
+
log('Chat WebSocket error', 'error');
522
+
chatWsDot.classList.remove('connected');
523
+
};
524
+
525
+
ws.onmessage = (event) => {
526
+
try {
527
+
const data = JSON.parse(event.data);
528
+
if (data.$type === 'place.stream.chat.defs#messageView') {
529
+
appendChatMessage(data);
530
+
}
531
+
} catch (err) {
532
+
// Ignore non-JSON or unknown message types
533
+
}
534
+
};
535
+
}
536
+
537
+
function disconnectChat() {
538
+
if (ws) {
539
+
ws.close();
540
+
ws = null;
541
+
}
542
+
chatWsDot.classList.remove('connected');
543
+
}
544
+
545
+
function appendChatMessage(data) {
546
+
// Hide empty placeholder
547
+
chatEmpty.style.display = 'none';
548
+
549
+
const handle = data.author?.handle || 'unknown';
550
+
const text = data.record?.text || '';
551
+
const color = data.chatProfile?.color;
552
+
const indexedAt = data.indexedAt;
553
+
554
+
// Build color string from the RGB fields
555
+
let authorColor = '#4ade80';
556
+
if (color && color.red !== undefined) {
557
+
authorColor = `rgb(${color.red}, ${color.green}, ${color.blue})`;
558
+
}
559
+
560
+
// Time string
561
+
let timeStr = '';
562
+
if (indexedAt) {
563
+
const d = new Date(indexedAt);
564
+
timeStr = d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
565
+
}
566
+
567
+
const msgEl = document.createElement('div');
568
+
msgEl.className = 'chat-msg';
569
+
570
+
const authorSpan = document.createElement('span');
571
+
authorSpan.className = 'chat-msg-author';
572
+
authorSpan.style.color = authorColor;
573
+
authorSpan.textContent = handle;
574
+
575
+
const textSpan = document.createElement('span');
576
+
textSpan.className = 'chat-msg-text';
577
+
textSpan.textContent = text;
578
+
579
+
const timeSpan = document.createElement('span');
580
+
timeSpan.className = 'chat-msg-time';
581
+
timeSpan.textContent = timeStr;
582
+
583
+
msgEl.appendChild(authorSpan);
584
+
msgEl.appendChild(textSpan);
585
+
msgEl.appendChild(timeSpan);
586
+
587
+
// Auto-scroll detection: are we near the bottom?
588
+
const isNearBottom = chatMessages.scrollHeight - chatMessages.scrollTop - chatMessages.clientHeight < 60;
589
+
590
+
chatMessages.appendChild(msgEl);
591
+
chatMsgCount++;
592
+
593
+
// Prune old messages
594
+
while (chatMessages.children.length > MAX_CHAT_MESSAGES + 1) {
595
+
const first = chatMessages.children[1]; // skip chatEmpty at index 0
596
+
if (first && first !== chatEmpty) {
597
+
first.remove();
598
+
} else {
599
+
break;
600
+
}
601
+
}
602
+
603
+
// Update count
604
+
chatCount.textContent = `${chatMsgCount} msgs`;
605
+
606
+
// Auto-scroll if near bottom
607
+
if (isNearBottom) {
608
+
chatMessages.scrollTop = chatMessages.scrollHeight;
609
+
}
610
+
}
611
+
612
+
// ---- WebRTC ----
613
+
614
+
async function connect() {
615
+
const username = usernameInput.value.trim();
616
+
if (!username) {
617
+
usernameInput.focus();
618
+
return;
619
+
}
620
+
621
+
if (pc) disconnect();
622
+
623
+
// Reset chat
624
+
chatMsgCount = 0;
625
+
chatCount.textContent = '';
626
+
chatEmpty.style.display = '';
627
+
// Remove old chat messages
628
+
const existingMsgs = chatMessages.querySelectorAll('.chat-msg');
629
+
existingMsgs.forEach(m => m.remove());
630
+
631
+
const whepUrl = `https://stream.place/api/playback/${encodeURIComponent(username)}/webrtc?rendition=source`;
632
+
633
+
setStatus('Connecting\u2026');
634
+
log(`WHEP endpoint: ${whepUrl}`);
635
+
636
+
connectBtn.style.display = 'none';
637
+
disconnectBtn.style.display = '';
638
+
639
+
// Connect chat WebSocket
640
+
connectChat(username);
641
+
642
+
try {
643
+
pc = new RTCPeerConnection({
644
+
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
645
+
bundlePolicy: 'max-bundle',
646
+
});
647
+
648
+
pc.addTransceiver('video', { direction: 'recvonly' });
649
+
pc.addTransceiver('audio', { direction: 'recvonly' });
650
+
651
+
pc.ontrack = (event) => {
652
+
log(`Track received: ${event.track.kind}`, 'success');
653
+
if (event.streams && event.streams[0]) {
654
+
video.srcObject = event.streams[0];
655
+
} else {
656
+
if (!video.srcObject) {
657
+
video.srcObject = new MediaStream();
658
+
}
659
+
video.srcObject.addTrack(event.track);
660
+
}
661
+
overlay.classList.add('hidden');
662
+
setStatus('Live', 'live');
663
+
video.play().catch(() => {});
664
+
};
665
+
666
+
pc.oniceconnectionstatechange = () => {
667
+
log(`ICE: ${pc.iceConnectionState}`);
668
+
if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') {
669
+
setStatus('Live', 'live');
670
+
startStats();
671
+
} else if (pc.iceConnectionState === 'failed' || pc.iceConnectionState === 'disconnected') {
672
+
setStatus('Disconnected', 'error');
673
+
log('Connection lost', 'error');
674
+
stopStats();
675
+
}
676
+
};
677
+
678
+
pc.onconnectionstatechange = () => {
679
+
log(`Connection: ${pc.connectionState}`);
680
+
if (pc.connectionState === 'failed') {
681
+
setStatus('Failed', 'error');
682
+
log('PeerConnection failed', 'error');
683
+
stopStats();
684
+
}
685
+
};
686
+
687
+
const offer = await pc.createOffer();
688
+
await pc.setLocalDescription(offer);
689
+
await waitForIceGathering(pc, 2000);
690
+
691
+
log('Sending SDP offer\u2026');
692
+
693
+
const resp = await fetch(whepUrl, {
694
+
method: 'POST',
695
+
headers: { 'Content-Type': 'application/sdp' },
696
+
body: pc.localDescription.sdp,
697
+
});
698
+
699
+
if (!resp.ok) {
700
+
const errText = await resp.text();
701
+
throw new Error(`WHEP ${resp.status}: ${errText}`);
702
+
}
703
+
704
+
const answerSdp = await resp.text();
705
+
log('Received SDP answer', 'success');
706
+
707
+
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
708
+
log('Remote description set, waiting for media\u2026');
709
+
710
+
} catch (err) {
711
+
log(`Error: ${err.message}`, 'error');
712
+
setStatus('Error', 'error');
713
+
console.error(err);
714
+
}
715
+
}
716
+
717
+
function waitForIceGathering(peerConnection, timeout) {
718
+
return new Promise((resolve) => {
719
+
if (peerConnection.iceGatheringState === 'complete') {
720
+
resolve();
721
+
return;
722
+
}
723
+
const timer = setTimeout(() => {
724
+
log('ICE gathering timed out, proceeding with candidates');
725
+
resolve();
726
+
}, timeout);
727
+
728
+
peerConnection.onicegatheringstatechange = () => {
729
+
if (peerConnection.iceGatheringState === 'complete') {
730
+
clearTimeout(timer);
731
+
log('ICE gathering complete');
732
+
resolve();
733
+
}
734
+
};
735
+
});
736
+
}
737
+
738
+
function disconnect() {
739
+
stopStats();
740
+
disconnectChat();
741
+
if (pc) {
742
+
pc.close();
743
+
pc = null;
744
+
}
745
+
video.srcObject = null;
746
+
overlay.classList.remove('hidden');
747
+
setStatus('Idle');
748
+
statusStats.textContent = '';
749
+
connectBtn.style.display = '';
750
+
disconnectBtn.style.display = 'none';
751
+
log('Disconnected');
752
+
}
753
+
754
+
function startStats() {
755
+
stopStats();
756
+
statsInterval = setInterval(async () => {
757
+
if (!pc) return;
758
+
try {
759
+
const stats = await pc.getStats();
760
+
let resolution = '';
761
+
stats.forEach((report) => {
762
+
if (report.type === 'inbound-rtp' && report.kind === 'video') {
763
+
if (report.frameWidth && report.frameHeight) {
764
+
resolution = `${report.frameWidth}\u00d7${report.frameHeight}`;
765
+
}
766
+
}
767
+
});
768
+
const parts = [];
769
+
if (resolution) parts.push(resolution);
770
+
statusStats.textContent = parts.join(' \u00b7 ');
771
+
} catch {}
772
+
}, 2000);
773
+
}
774
+
775
+
function stopStats() {
776
+
if (statsInterval) {
777
+
clearInterval(statsInterval);
778
+
statsInterval = null;
779
+
}
780
+
}
781
+
782
+
// Unmute on click
783
+
video.addEventListener('click', () => {
784
+
video.muted = !video.muted;
785
+
log(video.muted ? 'Muted' : 'Unmuted');
786
+
});
787
+
</script>
788
+
789
+
</body>
790
+
</html>