A stream.place client in a single index.html

it's working

+790
+790
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>