A stream.place client in a single index.html

index.html

+1606 -677
+58
Caddyfile
···
··· 1 + { 2 + admin off 3 + persist_config off 4 + auto_https off 5 + 6 + log { 7 + format json 8 + } 9 + 10 + servers { 11 + trusted_proxies static private_ranges 12 + } 13 + } 14 + 15 + :{$PORT:80} { 16 + log { 17 + format json 18 + } 19 + 20 + respond /health 200 21 + 22 + # Security headers 23 + header { 24 + # Enable cross-site filter (XSS) and tell browsers to block detected attacks 25 + X-XSS-Protection "1; mode=block" 26 + # Prevent some browsers from MIME-sniffing a response away from the declared Content-Type 27 + X-Content-Type-Options "nosniff" 28 + # Keep referrer data off of HTTP connections 29 + Referrer-Policy "strict-origin-when-cross-origin" 30 + # Enable strict Content Security Policy 31 + Content-Security-Policy "default-src 'self'; img-src 'self' data: https: *; style-src 'self' 'unsafe-inline' https: *; script-src 'self' 'unsafe-inline' https: *; font-src 'self' data: https: *; connect-src 'self' https: *; media-src 'self' https: *; object-src 'none'; frame-src 'self' https: *;" 32 + # Remove Server header 33 + -Server 34 + } 35 + 36 + root * . 37 + 38 + # Handle static files 39 + file_server { 40 + hide .git 41 + hide .env* 42 + } 43 + 44 + # Compression with more formats 45 + encode { 46 + gzip 47 + zstd 48 + } 49 + 50 + # Try files with HTML extension and handle SPA routing 51 + try_files {path} {path}.html {path}/index.html /index.html 52 + 53 + # Handle 404 errors 54 + handle_errors { 55 + rewrite * /{err.status_code}.html 56 + file_server 57 + } 58 + }
+12
README.md
···
··· 1 + # Bootleg stream.place 2 + 3 + A stream.place client in a single [index.html](index.html) file. It's not very good, and you're better off using [stream.place](https://stream.place). But it was a fun little thing I had to work through (I was nerd sniped). It's also a fun little thing to see how stream.place works. Displaying the video is actually all built in browser apis using [RTCPeerConnection](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection). 4 + 5 + Public instance at: [https://bootleg.baileytownsend.dev](https://bootleg.baileytownsend.dev) 6 + 7 + # Features 8 + - You can watch your favorite streamer on the atmosphere. If you know their handle that is.... 9 + - It even shows the stream. with play/pause, mute, and volume controls. 10 + - Loads chats in as well from stream.place's websocket. 11 + - You can login via oauth thanks to [@atcute/oauth-browser-client](https://github.com/mary-ext/atcute/tree/trunk/packages/oauth/browser-client). 12 + - Once logged in you can send chats
+1525 -677
index.html
··· 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>&#9654;</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>
··· 1 + <!doctype html> 2 <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta 6 + name="viewport" 7 + content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" 8 + /> 9 + <title>Bootleg stream.place</title> 10 + <meta 11 + name="description" 12 + content="What if the stream.place client was in a single index.html?" 13 + /> 14 + <meta 15 + name="og:description" 16 + content="What if the stream.place client was in a single index.html?" 17 + /> 18 + <style> 19 + @import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&family=Outfit:wght@300;400;600;700&display=swap"); 20 + 21 + :root { 22 + --bg: #0a0a0b; 23 + --surface: #141416; 24 + --border: #222228; 25 + --text: #e8e8ec; 26 + --text-dim: #6e6e7a; 27 + --accent: #4ade80; 28 + --accent-dim: #4ade8020; 29 + --red: #f87171; 30 + --red-dim: #f8717120; 31 + } 32 + 33 + * { 34 + margin: 0; 35 + padding: 0; 36 + box-sizing: border-box; 37 + } 38 + 39 + body { 40 + background: var(--bg); 41 + color: var(--text); 42 + font-family: "Outfit", sans-serif; 43 + min-height: 100vh; 44 + display: flex; 45 + flex-direction: column; 46 + } 47 + 48 + .header { 49 + width: 100%; 50 + padding: 1.25rem 2rem; 51 + display: flex; 52 + align-items: center; 53 + gap: 0.75rem; 54 + border-bottom: 1px solid var(--border); 55 + flex-shrink: 0; 56 + } 57 + 58 + .header .logo { 59 + font-family: "JetBrains Mono", monospace; 60 + font-weight: 600; 61 + font-size: 0.85rem; 62 + letter-spacing: -0.02em; 63 + color: var(--text-dim); 64 + } 65 + 66 + .header .logo span { 67 + color: var(--accent); 68 + } 69 + 70 + .connect-bar { 71 + width: 100%; 72 + padding: 1.25rem 2rem; 73 + display: flex; 74 + gap: 0.75rem; 75 + align-items: stretch; 76 + flex-shrink: 0; 77 + } 78 + 79 + .input-wrapper { 80 + flex: 1; 81 + max-width: 400px; 82 + position: relative; 83 + display: flex; 84 + align-items: center; 85 + } 86 + 87 + .input-wrapper input { 88 + width: 100%; 89 + padding: 0.75rem 1rem; 90 + background: var(--surface); 91 + border: 1px solid var(--border); 92 + border-radius: 10px; 93 + color: var(--text); 94 + font-family: "JetBrains Mono", monospace; 95 + font-size: 0.85rem; 96 + outline: none; 97 + transition: 98 + border-color 0.2s, 99 + box-shadow 0.2s; 100 + } 101 + 102 + .input-wrapper input:focus { 103 + border-color: var(--accent); 104 + box-shadow: 0 0 0 3px var(--accent-dim); 105 + } 106 + 107 + .input-wrapper input::placeholder { 108 + color: var(--text-dim); 109 + opacity: 0.5; 110 + } 111 + 112 + .btn { 113 + padding: 0.75rem 1.5rem; 114 + border-radius: 10px; 115 + font-family: "Outfit", sans-serif; 116 + font-weight: 600; 117 + font-size: 0.85rem; 118 + cursor: pointer; 119 + border: none; 120 + transition: all 0.15s; 121 + white-space: nowrap; 122 + } 123 + 124 + .btn-connect { 125 + background: var(--accent); 126 + color: var(--bg); 127 + } 128 + .btn-connect:hover { 129 + filter: brightness(1.1); 130 + transform: translateY(-1px); 131 + } 132 + .btn-connect:active { 133 + transform: translateY(0); 134 + } 135 + 136 + .btn-disconnect { 137 + background: var(--red-dim); 138 + color: var(--red); 139 + border: 1px solid var(--red); 140 + display: none; 141 + } 142 + .btn-disconnect:hover { 143 + background: var(--red); 144 + color: var(--bg); 145 + } 146 + 147 + /* ---- Main layout: video + chat side by side ---- */ 148 + .main-layout { 149 + padding: 0 2rem 1.5rem; 150 + } 151 + 152 + .stream-row { 153 + display: flex; 154 + align-items: stretch; 155 + height: 750px; 156 + } 157 + 158 + .video-frame { 159 + position: relative; 160 + flex: 1; 161 + min-width: 0; 162 + aspect-ratio: 16 / 9; 163 + background: var(--surface); 164 + border-radius: 12px 0 0 12px; 165 + overflow: hidden; 166 + border: 1px solid var(--border); 167 + border-right: none; 168 + } 169 + 170 + .video-frame video { 171 + width: 100%; 172 + height: 100%; 173 + object-fit: contain; 174 + display: block; 175 + } 176 + 177 + .video-overlay { 178 + position: absolute; 179 + inset: 0; 180 + display: flex; 181 + flex-direction: column; 182 + align-items: center; 183 + justify-content: center; 184 + gap: 1rem; 185 + pointer-events: none; 186 + transition: opacity 0.3s; 187 + } 188 + 189 + .video-overlay.hidden { 190 + opacity: 0; 191 + } 192 + 193 + .video-overlay .idle-icon { 194 + width: 48px; 195 + height: 48px; 196 + border-radius: 50%; 197 + border: 2px solid var(--border); 198 + display: flex; 199 + align-items: center; 200 + justify-content: center; 201 + } 202 + 203 + .video-overlay .idle-icon svg { 204 + width: 20px; 205 + height: 20px; 206 + fill: var(--text-dim); 207 + } 208 + 209 + .video-overlay .idle-text { 210 + font-size: 0.85rem; 211 + color: var(--text-dim); 212 + font-weight: 300; 213 + } 214 + 215 + .status-details { 216 + padding-top: 0.5rem; 217 + } 218 + 219 + .status-details summary { 220 + display: flex; 221 + align-items: center; 222 + gap: 0.5rem; 223 + cursor: pointer; 224 + font-family: "JetBrains Mono", monospace; 225 + font-size: 0.72rem; 226 + color: var(--text-dim); 227 + list-style: none; 228 + user-select: none; 229 + } 230 + 231 + .status-details summary::-webkit-details-marker { 232 + display: none; 233 + } 234 + 235 + .status-details summary .toggle-arrow { 236 + font-size: 0.6rem; 237 + transition: transform 0.2s; 238 + flex-shrink: 0; 239 + } 240 + 241 + .status-details[open] summary .toggle-arrow { 242 + transform: rotate(90deg); 243 + } 244 + 245 + .status-dot { 246 + width: 8px; 247 + height: 8px; 248 + border-radius: 50%; 249 + background: var(--border); 250 + transition: background 0.3s; 251 + flex-shrink: 0; 252 + } 253 + 254 + .status-dot.live { 255 + background: var(--accent); 256 + box-shadow: 0 0 8px var(--accent-dim); 257 + animation: pulse 2s ease-in-out infinite; 258 + } 259 + 260 + .status-dot.error { 261 + background: var(--red); 262 + } 263 + 264 + @keyframes pulse { 265 + 0%, 266 + 100% { 267 + opacity: 1; 268 + } 269 + 50% { 270 + opacity: 0.5; 271 + } 272 + } 273 + 274 + .status-text { 275 + flex: 1; 276 + } 277 278 + .status-stats { 279 + color: var(--text-dim); 280 + opacity: 0.6; 281 + } 282 283 + /* ---- Chat panel ---- */ 284 + .chat-panel { 285 + width: 420px; 286 + flex-shrink: 0; 287 + display: flex; 288 + flex-direction: column; 289 + border: 1px solid var(--border); 290 + border-radius: 0 12px 12px 0; 291 + background: var(--surface); 292 + overflow: hidden; 293 + } 294 295 + .chat-header { 296 + padding: 0.85rem 1rem; 297 + border-bottom: 1px solid var(--border); 298 + display: flex; 299 + align-items: center; 300 + gap: 0.5rem; 301 + flex-shrink: 0; 302 + } 303 304 + .chat-header-title { 305 + font-family: "JetBrains Mono", monospace; 306 + font-size: 0.75rem; 307 + font-weight: 500; 308 + color: var(--text-dim); 309 + text-transform: uppercase; 310 + letter-spacing: 0.05em; 311 + } 312 313 + .chat-header-count { 314 + font-family: "JetBrains Mono", monospace; 315 + font-size: 0.65rem; 316 + color: var(--text-dim); 317 + opacity: 0.5; 318 + margin-left: auto; 319 + } 320 321 + .chat-ws-dot { 322 + width: 6px; 323 + height: 6px; 324 + border-radius: 50%; 325 + background: var(--border); 326 + flex-shrink: 0; 327 + transition: background 0.3s; 328 + } 329 330 + .chat-ws-dot.connected { 331 + background: var(--accent); 332 + } 333 334 + .chat-messages { 335 + flex: 1; 336 + overflow-y: auto; 337 + padding: 0.5rem 0; 338 + display: flex; 339 + flex-direction: column-reverse; 340 + min-height: 0; 341 + height: 0; 342 + } 343 344 + .chat-messages::-webkit-scrollbar { 345 + width: 4px; 346 + } 347 + .chat-messages::-webkit-scrollbar-track { 348 + background: transparent; 349 + } 350 + .chat-messages::-webkit-scrollbar-thumb { 351 + background: var(--border); 352 + border-radius: 2px; 353 + } 354 355 + .chat-msg { 356 + padding: 0.35rem 1rem; 357 + font-size: 0.82rem; 358 + line-height: 1.45; 359 + transition: background 0.15s; 360 + word-break: break-word; 361 + } 362 363 + .chat-msg:hover { 364 + background: #ffffff06; 365 + } 366 367 + .chat-msg-author { 368 + font-weight: 600; 369 + margin-right: 0.35rem; 370 + cursor: default; 371 + } 372 373 + .chat-msg-text { 374 + color: var(--text); 375 + font-weight: 300; 376 + } 377 378 + .chat-msg-time { 379 + font-family: "JetBrains Mono", monospace; 380 + font-size: 0.6rem; 381 + color: var(--text-dim); 382 + opacity: 0.45; 383 + margin-right: 0.4rem; 384 + flex-shrink: 0; 385 + } 386 387 + .chat-empty { 388 + flex: 1; 389 + display: flex; 390 + align-items: center; 391 + justify-content: center; 392 + color: var(--text-dim); 393 + font-size: 0.8rem; 394 + font-weight: 300; 395 + opacity: 0.5; 396 + } 397 398 + /* ---- Tangled source link ---- */ 399 + .tangled-link { 400 + display: flex; 401 + align-items: center; 402 + gap: 0.4rem; 403 + font-family: "JetBrains Mono", monospace; 404 + font-size: 0.72rem; 405 + color: var(--text-dim); 406 + text-decoration: none; 407 + padding: 0.4rem 0.75rem; 408 + border-radius: 8px; 409 + border: 1px solid transparent; 410 + transition: all 0.15s; 411 + white-space: nowrap; 412 + } 413 414 + .tangled-link svg { 415 + width: 16px; 416 + height: 16px; 417 + flex-shrink: 0; 418 + } 419 420 + .tangled-link:hover { 421 + color: var(--text); 422 + border-color: var(--border); 423 + background: var(--surface); 424 + } 425 426 + /* ---- Auth controls ---- */ 427 + .auth-controls { 428 + margin-left: auto; 429 + display: flex; 430 + align-items: center; 431 + gap: 0.75rem; 432 + } 433 434 + .auth-handle { 435 + font-family: "JetBrains Mono", monospace; 436 + font-size: 0.75rem; 437 + color: var(--accent); 438 + font-weight: 500; 439 + } 440 441 + .btn-auth { 442 + padding: 0.4rem 0.85rem; 443 + border-radius: 8px; 444 + font-family: "Outfit", sans-serif; 445 + font-weight: 500; 446 + font-size: 0.75rem; 447 + cursor: pointer; 448 + transition: all 0.15s; 449 + white-space: nowrap; 450 + background: transparent; 451 + color: var(--text-dim); 452 + border: 1px solid var(--border); 453 + } 454 455 + .btn-auth:hover { 456 + border-color: var(--text-dim); 457 + color: var(--text); 458 + } 459 460 + /* ---- Chat input ---- */ 461 + .chat-input { 462 + display: flex; 463 + gap: 0.5rem; 464 + padding: 0.65rem 0.75rem; 465 + border-top: 1px solid var(--border); 466 + flex-shrink: 0; 467 + } 468 469 + .chat-input input { 470 + flex: 1; 471 + padding: 0.5rem 0.75rem; 472 + background: var(--bg); 473 + border: 1px solid var(--border); 474 + border-radius: 8px; 475 + color: var(--text); 476 + font-family: "Outfit", sans-serif; 477 + font-size: 0.8rem; 478 + outline: none; 479 + transition: border-color 0.2s; 480 + } 481 482 + .chat-input input:focus { 483 + border-color: var(--accent); 484 + } 485 486 + .chat-input input::placeholder { 487 + color: var(--text-dim); 488 + opacity: 0.4; 489 + } 490 491 + .chat-input button { 492 + padding: 0.5rem 0.85rem; 493 + background: var(--accent); 494 + color: var(--bg); 495 + border: none; 496 + border-radius: 8px; 497 + font-family: "Outfit", sans-serif; 498 + font-weight: 600; 499 + font-size: 0.75rem; 500 + cursor: pointer; 501 + transition: filter 0.15s; 502 + flex-shrink: 0; 503 + } 504 505 + .chat-input button:hover { 506 + filter: brightness(1.1); 507 + } 508 509 + .chat-input button:disabled { 510 + opacity: 0.4; 511 + cursor: default; 512 + filter: none; 513 + } 514 515 + .chat-signin-prompt { 516 + padding: 0.65rem 0.75rem; 517 + border-top: 1px solid var(--border); 518 + text-align: center; 519 + flex-shrink: 0; 520 + } 521 522 + .chat-signin-prompt span { 523 + font-size: 0.75rem; 524 + color: var(--text-dim); 525 + opacity: 0.6; 526 + cursor: pointer; 527 + transition: opacity 0.15s; 528 + } 529 530 + .chat-signin-prompt span:hover { 531 + opacity: 1; 532 + } 533 534 + /* ---- Stream info bar ---- */ 535 + .stream-info { 536 + display: none; 537 + align-items: center; 538 + gap: 1rem; 539 + padding: 0.6rem 0; 540 + } 541 542 + .stream-info.visible { 543 + display: flex; 544 + } 545 546 + .stream-info-text { 547 + display: flex; 548 + flex-direction: column; 549 + gap: 0.15rem; 550 + min-width: 0; 551 + flex: 1; 552 + } 553 554 + .stream-title { 555 + font-weight: 600; 556 + font-size: 0.95rem; 557 + color: var(--text); 558 + white-space: nowrap; 559 + overflow: hidden; 560 + text-overflow: ellipsis; 561 + } 562 563 + .stream-handle { 564 + font-family: "JetBrains Mono", monospace; 565 + font-size: 0.72rem; 566 + color: var(--text-dim); 567 + white-space: nowrap; 568 + } 569 570 + .stream-viewer-count { 571 + font-family: "JetBrains Mono", monospace; 572 + font-size: 0.72rem; 573 + color: var(--text-dim); 574 + display: flex; 575 + align-items: center; 576 + gap: 0.35rem; 577 + white-space: nowrap; 578 + flex-shrink: 0; 579 + } 580 581 + .viewer-dot { 582 + width: 6px; 583 + height: 6px; 584 + border-radius: 50%; 585 + background: var(--accent); 586 + flex-shrink: 0; 587 + } 588 589 + /* ---- Log panel ---- */ 590 + .log-panel { 591 + margin-top: 0.5rem; 592 + max-height: 100px; 593 + overflow-y: auto; 594 + background: var(--bg); 595 + border: 1px solid var(--border); 596 + border-radius: 8px; 597 + padding: 0.6rem; 598 + font-family: "JetBrains Mono", monospace; 599 + font-size: 0.65rem; 600 + line-height: 1.6; 601 + color: var(--text-dim); 602 + } 603 604 + .log-panel .log-line.error { 605 + color: var(--red); 606 + } 607 + .log-panel .log-line.success { 608 + color: var(--accent); 609 + } 610 611 + /* ---- Media controls ---- */ 612 + .media-controls { 613 + position: absolute; 614 + bottom: 0; 615 + left: 0; 616 + right: 0; 617 + display: flex; 618 + align-items: center; 619 + gap: 0.5rem; 620 + padding: 0.5rem 0.75rem; 621 + background: rgba(0, 0, 0, 0.7); 622 + opacity: 0; 623 + transition: opacity 0.25s; 624 + z-index: 2; 625 + } 626 627 + .video-frame:hover .media-controls, 628 + .media-controls:focus-within { 629 + opacity: 1; 630 + } 631 632 + .mc-btn { 633 + background: none; 634 + border: none; 635 + cursor: pointer; 636 + padding: 4px; 637 + display: flex; 638 + align-items: center; 639 + justify-content: center; 640 + color: var(--text); 641 + transition: color 0.15s; 642 + } 643 644 + .mc-btn:hover { 645 + color: var(--accent); 646 + } 647 648 + .mc-btn svg { 649 + width: 22px; 650 + height: 22px; 651 + fill: currentColor; 652 + } 653 654 + .volume-slider { 655 + -webkit-appearance: none; 656 + appearance: none; 657 + width: 90px; 658 + height: 4px; 659 + border-radius: 2px; 660 + background: var(--border); 661 + outline: none; 662 + cursor: pointer; 663 + } 664 665 + .volume-slider::-webkit-slider-thumb { 666 + -webkit-appearance: none; 667 + appearance: none; 668 + width: 12px; 669 + height: 12px; 670 + border-radius: 50%; 671 + background: var(--accent); 672 + cursor: pointer; 673 + } 674 675 + .volume-slider::-moz-range-thumb { 676 + width: 12px; 677 + height: 12px; 678 + border-radius: 50%; 679 + background: var(--accent); 680 + border: none; 681 + cursor: pointer; 682 + } 683 684 + .volume-slider::-moz-range-track { 685 + height: 4px; 686 + border-radius: 2px; 687 + background: var(--border); 688 + } 689 690 + /* ---- Responsive ---- */ 691 + @media (max-width: 800px) { 692 + .main-layout { 693 + padding: 0 1rem 1rem; 694 + } 695 + .stream-row { 696 + flex-direction: column; 697 + } 698 + .video-frame { 699 + border-radius: 12px 12px 0 0; 700 + border-right: 1px solid var(--border); 701 + border-bottom: none; 702 + } 703 + .chat-panel { 704 + width: 100%; 705 + border-radius: 0 0 12px 12px; 706 + max-height: 300px; 707 + } 708 + .connect-bar { 709 + flex-direction: column; 710 + padding: 1rem; 711 + } 712 + .input-wrapper { 713 + max-width: none; 714 + } 715 + .header { 716 + padding: 1rem; 717 + } 718 + } 719 + </style> 720 + </head> 721 + <body> 722 + <div class="header"> 723 + <div class="logo"> 724 + <a href="/" style="text-decoration: none; color: inherit" 725 + ><span>&#9654;</span> Bootleg stream.place</a 726 + > 727 + </div> 728 + <a 729 + href="https://tangled.org/did:plc:rnpkyqnmsw4ipey6eotbdnnf/bootleg-stream-dot-place" 730 + target="_blank" 731 + rel="noopener noreferrer" 732 + class="tangled-link" 733 + > 734 + <svg viewBox="0 0 25 25" xmlns="http://www.w3.org/2000/svg"> 735 + <g transform="translate(-0.42924038,-0.87777209)"> 736 + <path 737 + fill="currentColor" 738 + style="stroke-width: 0.111183" 739 + d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z" 740 + ></path> 741 + </g> 742 + </svg> 743 + View on Tangled 744 + </a> 745 + <div class="auth-controls"> 746 + <span 747 + class="auth-handle" 748 + id="authHandle" 749 + style="display: none" 750 + ></span> 751 + <button class="btn-auth" id="signInBtn" onclick="signIn()"> 752 + Sign In 753 + </button> 754 + <button 755 + class="btn-auth" 756 + id="signOutBtn" 757 + onclick="signOut()" 758 + style="display: none" 759 + > 760 + Sign Out 761 + </button> 762 + </div> 763 </div> 764 765 + <div class="connect-bar"> 766 + <div class="input-wrapper"> 767 + <input 768 + type="text" 769 + id="username" 770 + placeholder="yourfavestreamer.com" 771 + spellcheck="false" 772 + autocomplete="off" 773 + /> 774 + </div> 775 + <button class="btn btn-connect" id="connectBtn" onclick="connect()"> 776 + Connect 777 + </button> 778 + <button 779 + class="btn btn-disconnect" 780 + id="disconnectBtn" 781 + onclick="disconnect()" 782 + > 783 + Disconnect 784 + </button> 785 + </div> 786 787 + <div class="main-layout"> 788 + <div class="stream-info" id="streamInfo"> 789 + <div class="stream-info-text"> 790 + <div class="stream-title" id="streamTitle"></div> 791 + <div class="stream-handle" id="streamHandle"></div> 792 + </div> 793 + <div class="stream-viewer-count" id="streamViewerCount"> 794 + <span class="viewer-dot"></span> 795 + <span id="viewerCountNum">0</span> watching 796 + </div> 797 + </div> 798 + <div class="stream-row"> 799 + <div class="video-frame"> 800 + <video id="video" autoplay playsinline muted></video> 801 + <div class="media-controls" id="mediaControls"> 802 + <button 803 + class="mc-btn" 804 + id="playPauseBtn" 805 + onclick="togglePlayPause()" 806 + title="Play/Pause" 807 + > 808 + <svg viewBox="0 0 24 24"> 809 + <polygon points="6,3 20,12 6,21" /> 810 + </svg> 811 + </button> 812 + <button 813 + class="mc-btn" 814 + id="muteBtn" 815 + onclick="toggleMute()" 816 + title="Mute/Unmute" 817 + > 818 + <svg viewBox="0 0 24 24" id="muteIcon"> 819 + <path d="M3 9v6h4l5 5V4L7 9H3z" /> 820 + <line 821 + x1="23" 822 + y1="9" 823 + x2="17" 824 + y2="15" 825 + stroke="currentColor" 826 + stroke-width="2" 827 + /> 828 + <line 829 + x1="17" 830 + y1="9" 831 + x2="23" 832 + y2="15" 833 + stroke="currentColor" 834 + stroke-width="2" 835 + /> 836 + </svg> 837 + </button> 838 + <input 839 + type="range" 840 + class="volume-slider" 841 + id="volumeSlider" 842 + min="0" 843 + max="1" 844 + step="0.05" 845 + value="1" 846 + oninput="setVolume(this.value)" 847 + title="Volume" 848 + /> 849 + </div> 850 + <div class="video-overlay" id="overlay"> 851 + <div class="idle-icon"> 852 + <svg viewBox="0 0 24 24"> 853 + <polygon points="5,3 19,12 5,21" /> 854 + </svg> 855 + </div> 856 + <div class="idle-text"> 857 + Enter a username to start watching 858 + </div> 859 + </div> 860 + </div> 861 + 862 + <div class="chat-panel"> 863 + <div class="chat-header"> 864 + <div class="chat-ws-dot" id="chatWsDot"></div> 865 + <div class="chat-header-title">Chat</div> 866 + <div class="chat-header-count" id="chatCount"></div> 867 + </div> 868 + <div class="chat-messages" id="chatMessages"> 869 + <div class="chat-empty" id="chatEmpty"> 870 + No messages yet 871 + </div> 872 + </div> 873 + <div 874 + class="chat-input" 875 + id="chatInput" 876 + style="display: none" 877 + > 878 + <input 879 + type="text" 880 + id="chatMsgInput" 881 + placeholder="Send a message..." 882 + autocomplete="off" 883 + /> 884 + <button id="chatSendBtn" onclick="sendChat()"> 885 + Send 886 + </button> 887 + </div> 888 + <div class="chat-signin-prompt" id="chatSigninPrompt"> 889 + <span onclick="signIn()">Sign in to chat</span> 890 + </div> 891 + </div> 892 + </div> 893 + 894 + <details class="status-details"> 895 + <summary> 896 + <div class="status-dot" id="statusDot"></div> 897 + <div class="status-text" id="statusText">Idle</div> 898 + <div class="status-stats" id="statusStats"></div> 899 + <span class="toggle-arrow">&#9654;</span> 900 + </summary> 901 + <div class="log-panel" id="logPanel"></div> 902 + </details> 903 + </div> 904 + 905 + <script type="module"> 906 + const production = window.location.host !== "127.0.0.1"; 907 + 908 + import { 909 + configureOAuth, 910 + createAuthorizationUrl, 911 + finalizeAuthorization, 912 + OAuthUserAgent, 913 + getSession, 914 + deleteStoredSession, 915 + } from "https://cdn.jsdelivr.net/npm/@atcute/oauth-browser-client/+esm"; 916 + import { Client } from "https://cdn.jsdelivr.net/npm/@atcute/client/+esm"; 917 + import { 918 + LocalActorResolver, 919 + XrpcHandleResolver, 920 + CompositeDidDocumentResolver, 921 + PlcDidDocumentResolver, 922 + WebDidDocumentResolver, 923 + CompositeHandleResolver, 924 + DohJsonHandleResolver, 925 + WellKnownHandleResolver, 926 + } from "https://cdn.jsdelivr.net/npm/@atcute/identity-resolver/+esm"; 927 + 928 + const handleResolver = new CompositeHandleResolver({ 929 + methods: { 930 + dns: new DohJsonHandleResolver({ 931 + dohUrl: "https://cloudflare-dns.com/dns-query", 932 + }), 933 + http: new WellKnownHandleResolver(), 934 + }, 935 + }); 936 + 937 + // ---- State ---- 938 + let pc = null; 939 + let ws = null; 940 + let statsInterval = null; 941 + let chatMsgCount = 0; 942 + const MAX_CHAT_MESSAGES = 500; 943 + // Used to help with ordering chat messages 944 + let videoLoadedAt = new Date(); 945 + 946 + let atClient = null; 947 + let agent = null; 948 + let loggedInDid = null; 949 + let loggedInHandle = null; 950 + let currentStreamerDid = null; 951 + 952 + // ---- DOM refs ---- 953 + const video = document.getElementById("video"); 954 + const overlay = document.getElementById("overlay"); 955 + const statusDot = document.getElementById("statusDot"); 956 + const statusText = document.getElementById("statusText"); 957 + const statusStats = document.getElementById("statusStats"); 958 + const logPanel = document.getElementById("logPanel"); 959 + const connectBtn = document.getElementById("connectBtn"); 960 + const disconnectBtn = document.getElementById("disconnectBtn"); 961 + const usernameInput = document.getElementById("username"); 962 + const chatMessages = document.getElementById("chatMessages"); 963 + const chatEmpty = document.getElementById("chatEmpty"); 964 + const chatWsDot = document.getElementById("chatWsDot"); 965 + const chatCount = document.getElementById("chatCount"); 966 + const authHandle = document.getElementById("authHandle"); 967 + const signInBtn = document.getElementById("signInBtn"); 968 + const signOutBtn = document.getElementById("signOutBtn"); 969 + const chatInput = document.getElementById("chatInput"); 970 + const chatMsgInput = document.getElementById("chatMsgInput"); 971 + const chatSendBtn = document.getElementById("chatSendBtn"); 972 + const chatSigninPrompt = 973 + document.getElementById("chatSigninPrompt"); 974 + const streamInfo = document.getElementById("streamInfo"); 975 + const streamTitle = document.getElementById("streamTitle"); 976 + const streamHandle = document.getElementById("streamHandle"); 977 + const viewerCountNum = document.getElementById("viewerCountNum"); 978 + 979 + // ---- OAuth setup ---- 980 + const redirectUri = 981 + window.location.origin + window.location.pathname; 982 + 983 + const oauthScope = "atproto include:place.stream.authFull"; 984 + let clientId; 985 + if (production) { 986 + clientId = `${window.location.origin}/oauth-client-metadata.json`; 987 + } else { 988 + clientId = 989 + `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}` + 990 + `&scope=${encodeURIComponent(oauthScope)}`; 991 + } 992 + configureOAuth({ 993 + metadata: { client_id: clientId, redirect_uri: redirectUri }, 994 + identityResolver: new LocalActorResolver({ 995 + handleResolver, 996 + didDocumentResolver: new CompositeDidDocumentResolver({ 997 + methods: { 998 + plc: new PlcDidDocumentResolver(), 999 + web: new WebDidDocumentResolver(), 1000 + }, 1001 + }), 1002 + }), 1003 + }); 1004 + 1005 + // ---- Auth UI helpers ---- 1006 + function updateAuthUI() { 1007 + if (loggedInDid) { 1008 + authHandle.textContent = `@${loggedInHandle || loggedInDid}`; 1009 + authHandle.style.display = ""; 1010 + signInBtn.style.display = "none"; 1011 + signOutBtn.style.display = ""; 1012 + chatInput.style.display = ""; 1013 + chatSigninPrompt.style.display = "none"; 1014 + } else { 1015 + authHandle.style.display = "none"; 1016 + signInBtn.style.display = ""; 1017 + signOutBtn.style.display = "none"; 1018 + chatInput.style.display = "none"; 1019 + chatSigninPrompt.style.display = ""; 1020 + } 1021 + } 1022 + 1023 + function setAuthSession(session) { 1024 + agent = new OAuthUserAgent(session); 1025 + atClient = new Client({ handler: agent }); 1026 + loggedInDid = session.info.sub; 1027 + localStorage.setItem("atproto_did", loggedInDid); 1028 + // Resolve handle from DID 1029 + resolveOwnHandle(); 1030 + } 1031 + 1032 + async function getMiniDoc(identity) { 1033 + const res = await fetch( 1034 + `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(identity)}`, 1035 + ); 1036 + if (res.ok) { 1037 + const data = await res.json(); 1038 + return data; 1039 + } else { 1040 + log( 1041 + `Error resolving the mini doc for: ${identity}`, 1042 + "error", 1043 + ); 1044 + console.error(res); 1045 + } 1046 + } 1047 + 1048 + async function resolveOwnHandle() { 1049 + const miniDoc = await getMiniDoc(loggedInDid); 1050 + loggedInHandle = miniDoc.handle; 1051 + updateAuthUI(); 1052 + } 1053 + 1054 + async function signIn() { 1055 + const handle = window.prompt( 1056 + "Enter your atmosphere handle (e.g. jcsalterego.bsky.social):", 1057 + ); 1058 + if (!handle) return; 1059 + try { 1060 + const authUrl = await createAuthorizationUrl({ 1061 + target: { type: "account", identifier: handle.trim() }, 1062 + scope: oauthScope, 1063 + }); 1064 + await new Promise((r) => setTimeout(r, 200)); 1065 + const maybeAprofile = getProfileFromUrl(); 1066 + if (maybeAprofile) { 1067 + localStorage.setItem( 1068 + "last-watched-streamer", 1069 + maybeAprofile, 1070 + ); 1071 + } 1072 + window.location.assign(authUrl); 1073 + } catch (err) { 1074 + log(`Auth error: ${err.message}`, "error"); 1075 + console.error(err); 1076 + } 1077 + } 1078 + 1079 + async function signOut() { 1080 + try { 1081 + if (agent) await agent.signOut(); 1082 + } catch { 1083 + if (loggedInDid) deleteStoredSession(loggedInDid); 1084 + } 1085 + localStorage.removeItem("atproto_did"); 1086 + atClient = null; 1087 + agent = null; 1088 + loggedInDid = null; 1089 + loggedInHandle = null; 1090 + updateAuthUI(); 1091 + log("Signed out"); 1092 + } 1093 + 1094 + // ---- OAuth callback / session resume on load ---- 1095 + async function initAuth() { 1096 + // Check for OAuth callback in hash 1097 + if (location.hash && location.hash.length > 1) { 1098 + try { 1099 + const params = new URLSearchParams( 1100 + location.hash.slice(1), 1101 + ); 1102 + history.replaceState( 1103 + null, 1104 + "", 1105 + location.pathname + location.search, 1106 + ); 1107 + const { session } = await finalizeAuthorization(params); 1108 + setAuthSession(session); 1109 + log(`Signed in as ${loggedInDid}`, "success"); 1110 + const maybeAprofile = getProfileFromUrl(); 1111 + if (maybeAprofile) { 1112 + localStorage.setItem( 1113 + "last-watched-streamer", 1114 + maybeAprofile, 1115 + ); 1116 + } 1117 + return; 1118 + } catch (err) { 1119 + // Hash might not be OAuth params, ignore 1120 + console.warn("OAuth finalize failed:", err); 1121 + } 1122 + } 1123 + 1124 + // Try to resume existing session 1125 + const storedDid = localStorage.getItem("atproto_did"); 1126 + if (storedDid) { 1127 + try { 1128 + const session = await getSession(storedDid, { 1129 + allowStale: true, 1130 + }); 1131 + setAuthSession(session); 1132 + log(`Session resumed for ${loggedInDid}`, "success"); 1133 + } catch (err) { 1134 + localStorage.removeItem("atproto_did"); 1135 + console.warn("Session resume failed:", err); 1136 + } 1137 + } 1138 + } 1139 + 1140 + // ---- Chat sending ---- 1141 + async function sendChat() { 1142 + if (!atClient || !loggedInDid) return; 1143 + 1144 + const text = chatMsgInput.value.trim(); 1145 + 1146 + if (!text) return; 1147 + if (!currentStreamerDid) { 1148 + log("Cannot send: streamer DID not resolved", "error"); 1149 + return; 1150 + } 1151 + 1152 + chatSendBtn.disabled = true; 1153 + try { 1154 + await atClient.post("com.atproto.repo.createRecord", { 1155 + input: { 1156 + repo: loggedInDid, 1157 + collection: "place.stream.chat.message", 1158 + record: { 1159 + $type: "place.stream.chat.message", 1160 + text: text, 1161 + streamer: currentStreamerDid, 1162 + createdAt: new Date().toISOString(), 1163 + }, 1164 + }, 1165 + }); 1166 + chatMsgInput.value = ""; 1167 + } catch (err) { 1168 + log(`Send failed: ${err.message}`, "error"); 1169 + console.error(err); 1170 + } finally { 1171 + chatSendBtn.disabled = false; 1172 + chatMsgInput.focus(); 1173 + } 1174 + } 1175 + 1176 + // ---- Streamer DID resolution ---- 1177 + async function resolveStreamerDid(handle) { 1178 + try { 1179 + const streamersMiniDoc = await getMiniDoc(handle); 1180 + currentStreamerDid = streamersMiniDoc.did; 1181 + log(`Streamer DID: ${currentStreamerDid}`); 1182 + } catch { 1183 + log("Streamer DID resolution failed", "error"); 1184 + currentStreamerDid = null; 1185 + } 1186 + } 1187 + 1188 + // ---- Event listeners ---- 1189 + usernameInput.addEventListener("keydown", (e) => { 1190 + if (e.key === "Enter") connect(); 1191 + }); 1192 + 1193 + chatMsgInput.addEventListener("keydown", (e) => { 1194 + if (e.key === "Enter") sendChat(); 1195 + }); 1196 + 1197 + // ---- Media controls ---- 1198 + const playPauseBtn = document.getElementById("playPauseBtn"); 1199 + const muteBtn = document.getElementById("muteBtn"); 1200 + const volumeSlider = document.getElementById("volumeSlider"); 1201 + let savedVolume = 1; 1202 + 1203 + const iconPlay = 1204 + '<svg viewBox="0 0 24 24"><polygon points="6,3 20,12 6,21" /></svg>'; 1205 + const iconPause = 1206 + '<svg viewBox="0 0 24 24"><rect x="5" y="3" width="4" height="18"/><rect x="15" y="3" width="4" height="18"/></svg>'; 1207 + const iconVolume = 1208 + '<svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3z"/><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/><path d="M19 12c0-3.07-1.96-5.68-4.5-6.65v1.52A5.99 5.99 0 0118 12a5.99 5.99 0 01-3.5 5.13v1.52C17.04 17.68 19 15.07 19 12z"/></svg>'; 1209 + const iconMuted = 1210 + '<svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3z"/><line x1="23" y1="9" x2="17" y2="15" stroke="currentColor" stroke-width="2"/><line x1="17" y1="9" x2="23" y2="15" stroke="currentColor" stroke-width="2"/></svg>'; 1211 + 1212 + function updatePlayPauseIcon() { 1213 + playPauseBtn.innerHTML = video.paused ? iconPlay : iconPause; 1214 + } 1215 + 1216 + function updateMuteIcon() { 1217 + muteBtn.innerHTML = 1218 + video.muted || video.volume === 0 ? iconMuted : iconVolume; 1219 + } 1220 1221 + function togglePlayPause() { 1222 + if (video.paused) { 1223 + video.play().catch(() => {}); 1224 + } else { 1225 + video.pause(); 1226 + } 1227 + } 1228 1229 + function toggleMute() { 1230 + if (video.muted) { 1231 + video.muted = false; 1232 + video.volume = savedVolume || 1; 1233 + volumeSlider.value = video.volume; 1234 + } else { 1235 + savedVolume = video.volume; 1236 + video.muted = true; 1237 + volumeSlider.value = 0; 1238 + } 1239 + updateMuteIcon(); 1240 + } 1241 1242 + function setVolume(val) { 1243 + val = parseFloat(val); 1244 + video.volume = val; 1245 + if (val > 0 && video.muted) { 1246 + video.muted = false; 1247 + } 1248 + savedVolume = val > 0 ? val : savedVolume; 1249 + updateMuteIcon(); 1250 + } 1251 1252 + video.addEventListener("play", updatePlayPauseIcon); 1253 + video.addEventListener("pause", updatePlayPauseIcon); 1254 + video.addEventListener("volumechange", () => { 1255 + updateMuteIcon(); 1256 + if (!video.muted) { 1257 + volumeSlider.value = video.volume; 1258 + } 1259 + }); 1260 1261 + // ---- Logging / status ---- 1262 + function log(msg, type = "") { 1263 + const line = document.createElement("div"); 1264 + line.className = "log-line" + (type ? ` ${type}` : ""); 1265 + const ts = new Date().toLocaleTimeString("en-US", { 1266 + hour12: false, 1267 + }); 1268 + line.textContent = `${ts} ${msg}`; 1269 + logPanel.appendChild(line); 1270 + logPanel.scrollTop = logPanel.scrollHeight; 1271 + } 1272 1273 + function setStatus(text, state = "") { 1274 + statusText.textContent = text; 1275 + statusDot.className = "status-dot" + (state ? ` ${state}` : ""); 1276 + } 1277 1278 + // ---- Chat WebSocket ---- 1279 + function connectChat(username) { 1280 + if (ws) { 1281 + ws.close(); 1282 + ws = null; 1283 + } 1284 1285 + const wsUrl = `wss://stream.place/api/websocket/${encodeURIComponent(username)}`; 1286 + log(`Chat WS: ${wsUrl}`); 1287 1288 + ws = new WebSocket(wsUrl); 1289 1290 + ws.onopen = () => { 1291 + log("Chat connected", "success"); 1292 + chatWsDot.classList.add("connected"); 1293 + }; 1294 1295 + ws.onclose = (e) => { 1296 + log(`Chat disconnected (code ${e.code})`); 1297 + chatWsDot.classList.remove("connected"); 1298 + }; 1299 1300 + ws.onerror = () => { 1301 + log("Chat WebSocket error", "error"); 1302 + chatWsDot.classList.remove("connected"); 1303 + }; 1304 1305 + ws.onmessage = (event) => { 1306 + try { 1307 + const data = JSON.parse(event.data); 1308 + if ( 1309 + data.$type === "place.stream.chat.defs#messageView" 1310 + ) { 1311 + appendChatMessage(data); 1312 + } else if ( 1313 + data.$type === 1314 + "place.stream.livestream#livestreamView" 1315 + ) { 1316 + const title = data.record?.title || ""; 1317 + const handle = data.author?.handle || ""; 1318 + streamTitle.textContent = title; 1319 + streamHandle.textContent = handle 1320 + ? `@${handle}` 1321 + : ""; 1322 + streamInfo.classList.add("visible"); 1323 + } else if ( 1324 + data.$type === "place.stream.livestream#viewerCount" 1325 + ) { 1326 + viewerCountNum.textContent = data.count ?? 0; 1327 + } 1328 + } catch { 1329 + // Ignore non-JSON or unknown message types 1330 + } 1331 + }; 1332 + } 1333 1334 + function disconnectChat() { 1335 + if (ws) { 1336 + ws.close(); 1337 + ws = null; 1338 + } 1339 + chatWsDot.classList.remove("connected"); 1340 + streamInfo.classList.remove("visible"); 1341 + streamTitle.textContent = ""; 1342 + streamHandle.textContent = ""; 1343 + viewerCountNum.textContent = "0"; 1344 + } 1345 1346 + function appendChatMessage(data) { 1347 + chatEmpty.style.display = "none"; 1348 1349 + const handle = data.author?.handle || "unknown"; 1350 + const text = data.record?.text || ""; 1351 + const color = data.chatProfile?.color; 1352 + const indexedAt = data.indexedAt; 1353 1354 + let authorColor = "#4ade80"; 1355 + if (color && color.red !== undefined) { 1356 + authorColor = `rgb(${color.red}, ${color.green}, ${color.blue})`; 1357 + } 1358 1359 + let timeStr = ""; 1360 + // if (indexedAt) { 1361 + const indexedAtDate = new Date(indexedAt); 1362 + timeStr = indexedAtDate.toLocaleTimeString("en-US", { 1363 + hour12: false, 1364 + hour: "2-digit", 1365 + minute: "2-digit", 1366 + second: "2-digit", 1367 + }); 1368 + // } 1369 1370 + const msgEl = document.createElement("div"); 1371 + msgEl.className = "chat-msg"; 1372 1373 + const authorSpan = document.createElement("span"); 1374 + authorSpan.className = "chat-msg-author"; 1375 + authorSpan.style.color = authorColor; 1376 + authorSpan.textContent = handle; 1377 1378 + const textSpan = document.createElement("span"); 1379 + textSpan.className = "chat-msg-text"; 1380 + textSpan.textContent = text; 1381 1382 + const timeSpan = document.createElement("span"); 1383 + timeSpan.className = "chat-msg-time"; 1384 + timeSpan.textContent = timeStr; 1385 1386 + msgEl.appendChild(timeSpan); 1387 + msgEl.appendChild(authorSpan); 1388 + msgEl.appendChild(textSpan); 1389 1390 + if (indexedAtDate < videoLoadedAt) { 1391 + chatMessages.appendChild(msgEl); 1392 + } else { 1393 + chatMessages.prepend(msgEl); 1394 + } 1395 1396 + chatMsgCount++; 1397 1398 + while (chatMessages.children.length > MAX_CHAT_MESSAGES + 1) { 1399 + const last = chatMessages.lastElementChild; 1400 + if (last && last !== chatEmpty) { 1401 + last.remove(); 1402 + } else { 1403 + break; 1404 + } 1405 + } 1406 1407 + chatCount.textContent = `${chatMsgCount} msgs`; 1408 1409 + // if (isAtNewest) { 1410 + // chatMessages.scrollTop = 0; 1411 + // } 1412 + } 1413 1414 + // ---- WebRTC ---- 1415 + async function connect() { 1416 + const streamersHandle = usernameInput.value.trim(); 1417 + if (!streamersHandle) { 1418 + usernameInput.focus(); 1419 + return; 1420 + } 1421 1422 + if (pc) disconnect(); 1423 1424 + chatMsgCount = 0; 1425 + chatCount.textContent = ""; 1426 + chatEmpty.style.display = ""; 1427 + const existingMsgs = chatMessages.querySelectorAll(".chat-msg"); 1428 + existingMsgs.forEach((m) => m.remove()); 1429 1430 + const whepUrl = `https://stream.place/api/playback/${encodeURIComponent(streamersHandle)}/webrtc?rendition=source`; 1431 1432 + setStatus("Connecting\u2026"); 1433 + log(`WHEP endpoint: ${whepUrl}`); 1434 1435 + connectBtn.style.display = "none"; 1436 + disconnectBtn.style.display = ""; 1437 1438 + connectChat(streamersHandle); 1439 + await resolveStreamerDid(streamersHandle); 1440 1441 + try { 1442 + pc = new RTCPeerConnection({ 1443 + iceServers: [{ urls: "stun:stun.l.google.com:19302" }], 1444 + bundlePolicy: "max-bundle", 1445 + }); 1446 1447 + pc.addTransceiver("video", { direction: "recvonly" }); 1448 + pc.addTransceiver("audio", { direction: "recvonly" }); 1449 1450 + pc.ontrack = (event) => { 1451 + log(`Track received: ${event.track.kind}`, "success"); 1452 + if (event.streams && event.streams[0]) { 1453 + video.srcObject = event.streams[0]; 1454 + } else { 1455 + if (!video.srcObject) { 1456 + video.srcObject = new MediaStream(); 1457 + } 1458 + video.srcObject.addTrack(event.track); 1459 + } 1460 + overlay.classList.add("hidden"); 1461 + setStatus("Live", "live"); 1462 + video.play().catch(() => {}); 1463 + }; 1464 1465 + pc.oniceconnectionstatechange = () => { 1466 + log(`PeerConnection: ${pc.iceConnectionState}`); 1467 + if ( 1468 + pc.iceConnectionState === "connected" || 1469 + pc.iceConnectionState === "completed" 1470 + ) { 1471 + //This is when the video is successfully loaded 1472 + videoLoadedAt = new Date(); 1473 1474 + window.history.pushState( 1475 + {}, 1476 + "", 1477 + `/${streamersHandle}`, 1478 + ); 1479 + setStatus("Live", "live"); 1480 + startStats(); 1481 + } else if ( 1482 + pc.iceConnectionState === "failed" || 1483 + pc.iceConnectionState === "disconnected" 1484 + ) { 1485 + setStatus("Disconnected", "error"); 1486 + log("Connection lost", "error"); 1487 + stopStats(); 1488 + } 1489 + }; 1490 1491 + pc.onconnectionstatechange = () => { 1492 + log(`Connection: ${pc.connectionState}`); 1493 + if (pc.connectionState === "failed") { 1494 + setStatus("Failed", "error"); 1495 + log("PeerConnection failed", "error"); 1496 + stopStats(); 1497 + } 1498 + }; 1499 1500 + const offer = await pc.createOffer(); 1501 + await pc.setLocalDescription(offer); 1502 + await waitForIceGathering(pc, 2000); 1503 1504 + log("Sending SDP offer\u2026"); 1505 1506 + const resp = await fetch(whepUrl, { 1507 + method: "POST", 1508 + headers: { "Content-Type": "application/sdp" }, 1509 + body: pc.localDescription.sdp, 1510 + }); 1511 1512 + if (!resp.ok) { 1513 + const errText = await resp.text(); 1514 + throw new Error(`WHEP ${resp.status}: ${errText}`); 1515 + } 1516 1517 + const answerSdp = await resp.text(); 1518 + log("Received SDP answer", "success"); 1519 1520 + await pc.setRemoteDescription({ 1521 + type: "answer", 1522 + sdp: answerSdp, 1523 + }); 1524 + log("Remote description set, waiting for media\u2026"); 1525 + } catch (err) { 1526 + log(`Error: ${err.message}`, "error"); 1527 + setStatus("Error", "error"); 1528 + console.error(err); 1529 + } 1530 + } 1531 1532 + function waitForIceGathering(peerConnection, timeout) { 1533 + return new Promise((resolve) => { 1534 + if (peerConnection.iceGatheringState === "complete") { 1535 + resolve(); 1536 + return; 1537 + } 1538 + const timer = setTimeout(() => { 1539 + log( 1540 + "PeerConnection gathering timed out, proceeding with candidates", 1541 + ); 1542 + resolve(); 1543 + }, timeout); 1544 1545 + peerConnection.onicegatheringstatechange = () => { 1546 + if (peerConnection.iceGatheringState === "complete") { 1547 + clearTimeout(timer); 1548 + log("PeerConnection gathering complete"); 1549 + resolve(); 1550 + } 1551 + }; 1552 + }); 1553 + } 1554 1555 + function disconnect() { 1556 + stopStats(); 1557 + disconnectChat(); 1558 + if (pc) { 1559 + pc.close(); 1560 + pc = null; 1561 + } 1562 + currentStreamerDid = null; 1563 + video.srcObject = null; 1564 + overlay.classList.remove("hidden"); 1565 + setStatus("Idle"); 1566 + statusStats.textContent = ""; 1567 + connectBtn.style.display = ""; 1568 + disconnectBtn.style.display = "none"; 1569 + log("Disconnected"); 1570 + } 1571 1572 + function startStats() { 1573 + stopStats(); 1574 + statsInterval = setInterval(async () => { 1575 + if (!pc) return; 1576 + try { 1577 + const stats = await pc.getStats(); 1578 + let resolution = ""; 1579 + stats.forEach((report) => { 1580 + if ( 1581 + report.type === "inbound-rtp" && 1582 + report.kind === "video" 1583 + ) { 1584 + if (report.frameWidth && report.frameHeight) { 1585 + resolution = `${report.frameWidth}\u00d7${report.frameHeight}`; 1586 + } 1587 + } 1588 + }); 1589 + const parts = []; 1590 + if (resolution) parts.push(resolution); 1591 + statusStats.textContent = parts.join(" \u00b7 "); 1592 + } catch {} 1593 + }, 2000); 1594 + } 1595 1596 + function stopStats() { 1597 + if (statsInterval) { 1598 + clearInterval(statsInterval); 1599 + statsInterval = null; 1600 + } 1601 + } 1602 1603 + function getProfileFromUrl() { 1604 + const path = window.location.pathname; 1605 + const pathSplit = path.split("/"); 1606 + if (pathSplit.length > 1) { 1607 + const maybeAprofile = pathSplit[1]; 1608 + if (maybeAprofile !== "") { 1609 + return maybeAprofile; 1610 + } 1611 + } 1612 } 1613 1614 + function urlProfileWatch() { 1615 + const maybeAprofile = getProfileFromUrl(); 1616 + if (maybeAprofile) { 1617 + usernameInput.value = maybeAprofile; 1618 + connect(); 1619 + } 1620 + } 1621 1622 + // ---- Expose to onclick handlers ---- 1623 + window.connect = connect; 1624 + window.disconnect = disconnect; 1625 + window.signIn = signIn; 1626 + window.signOut = signOut; 1627 + window.sendChat = sendChat; 1628 + window.togglePlayPause = togglePlayPause; 1629 + window.toggleMute = toggleMute; 1630 + window.setVolume = setVolume; 1631 1632 + // ---- Init ---- 1633 + updateAuthUI(); 1634 + initAuth(); 1635 + urlProfileWatch(); 1636 + </script> 1637 + </body> 1638 </html>
+11
oauth-client-metadata.json
···
··· 1 + { 2 + "client_id": "https://bootleg.baileytownsend.dev/oauth-client-metadata.json", 3 + "client_uri": "https://bootleg.baileytownsend.dev", 4 + "redirect_uris": ["https://bootleg.baileytownsend.dev/"], 5 + "dpop_bound_access_tokens": true, 6 + "token_endpoint_auth_method": "none", 7 + "grant_types": ["authorization_code", "refresh_token"], 8 + "scope": "atproto include:place.stream.authFull", 9 + "response_types": ["code"], 10 + "client_name": "Bootleg stream.place" 11 + }