A stream.place client in a single index.html
at main 2101 lines 79 kB view raw
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-reply-preview { 388 display: flex; 389 align-items: baseline; 390 gap: 0.3rem; 391 font-size: 0.7rem; 392 color: var(--text-dim); 393 padding: 0.15rem 0 0.1rem 0.6rem; 394 border-left: 2px solid #ffffff18; 395 margin-bottom: 0.2rem; 396 overflow: hidden; 397 cursor: pointer; 398 } 399 400 .chat-reply-preview:hover { 401 border-left-color: #ffffff30; 402 } 403 404 .chat-reply-author { 405 font-weight: 600; 406 flex-shrink: 0; 407 } 408 409 .chat-reply-text { 410 white-space: nowrap; 411 overflow: hidden; 412 text-overflow: ellipsis; 413 opacity: 0.6; 414 } 415 416 .chat-empty { 417 flex: 1; 418 display: flex; 419 align-items: center; 420 justify-content: center; 421 color: var(--text-dim); 422 font-size: 0.8rem; 423 font-weight: 300; 424 opacity: 0.5; 425 } 426 427 /* ---- Tangled source link ---- */ 428 .tangled-link { 429 display: flex; 430 align-items: center; 431 gap: 0.4rem; 432 font-family: "JetBrains Mono", monospace; 433 font-size: 0.72rem; 434 color: var(--text-dim); 435 text-decoration: none; 436 padding: 0.4rem 0.75rem; 437 border-radius: 8px; 438 border: 1px solid transparent; 439 transition: all 0.15s; 440 white-space: nowrap; 441 } 442 443 .tangled-link svg { 444 width: 16px; 445 height: 16px; 446 flex-shrink: 0; 447 } 448 449 .tangled-link:hover { 450 color: var(--text); 451 border-color: var(--border); 452 background: var(--surface); 453 } 454 455 /* ---- Auth controls ---- */ 456 .auth-controls { 457 margin-left: auto; 458 display: flex; 459 align-items: center; 460 gap: 0.75rem; 461 } 462 463 .auth-handle { 464 font-family: "JetBrains Mono", monospace; 465 font-size: 0.75rem; 466 color: var(--accent); 467 font-weight: 500; 468 } 469 470 .btn-auth { 471 padding: 0.4rem 0.85rem; 472 border-radius: 8px; 473 font-family: "Outfit", sans-serif; 474 font-weight: 500; 475 font-size: 0.75rem; 476 cursor: pointer; 477 transition: all 0.15s; 478 white-space: nowrap; 479 background: transparent; 480 color: var(--text-dim); 481 border: 1px solid var(--border); 482 } 483 484 .btn-auth:hover { 485 border-color: var(--text-dim); 486 color: var(--text); 487 } 488 489 /* ---- Chat input ---- */ 490 .chat-input { 491 display: flex; 492 gap: 0.5rem; 493 padding: 0.65rem 0.75rem; 494 border-top: 1px solid var(--border); 495 flex-shrink: 0; 496 } 497 498 .chat-input input { 499 flex: 1; 500 padding: 0.5rem 0.75rem; 501 background: var(--bg); 502 border: 1px solid var(--border); 503 border-radius: 8px; 504 color: var(--text); 505 font-family: "Outfit", sans-serif; 506 font-size: 0.8rem; 507 outline: none; 508 transition: border-color 0.2s; 509 } 510 511 .chat-input input:focus { 512 border-color: var(--accent); 513 } 514 515 .chat-input input::placeholder { 516 color: var(--text-dim); 517 opacity: 0.4; 518 } 519 520 .chat-input button { 521 padding: 0.5rem 0.85rem; 522 background: var(--accent); 523 color: var(--bg); 524 border: none; 525 border-radius: 8px; 526 font-family: "Outfit", sans-serif; 527 font-weight: 600; 528 font-size: 0.75rem; 529 cursor: pointer; 530 transition: filter 0.15s; 531 flex-shrink: 0; 532 } 533 534 .chat-input button:hover { 535 filter: brightness(1.1); 536 } 537 538 .chat-input button:disabled { 539 opacity: 0.4; 540 cursor: default; 541 filter: none; 542 } 543 544 .chat-signin-prompt { 545 padding: 0.65rem 0.75rem; 546 border-top: 1px solid var(--border); 547 text-align: center; 548 flex-shrink: 0; 549 } 550 551 .chat-signin-prompt span { 552 font-size: 0.75rem; 553 color: var(--text-dim); 554 opacity: 0.6; 555 cursor: pointer; 556 transition: opacity 0.15s; 557 } 558 559 .chat-signin-prompt span:hover { 560 opacity: 1; 561 } 562 563 /* ---- Stream info bar ---- */ 564 .stream-info { 565 display: none; 566 align-items: center; 567 gap: 1rem; 568 padding: 0.6rem 0; 569 } 570 571 .stream-info.visible { 572 display: flex; 573 } 574 575 .stream-info-text { 576 display: flex; 577 flex-direction: column; 578 gap: 0.15rem; 579 min-width: 0; 580 flex: 1; 581 } 582 583 .stream-title { 584 font-weight: 600; 585 font-size: 0.95rem; 586 color: var(--text); 587 white-space: nowrap; 588 overflow: hidden; 589 text-overflow: ellipsis; 590 } 591 592 .stream-handle { 593 font-family: "JetBrains Mono", monospace; 594 font-size: 0.72rem; 595 color: var(--text-dim); 596 white-space: nowrap; 597 } 598 599 .stream-viewer-count { 600 font-family: "JetBrains Mono", monospace; 601 font-size: 0.72rem; 602 color: var(--text-dim); 603 display: flex; 604 align-items: center; 605 gap: 0.35rem; 606 white-space: nowrap; 607 flex-shrink: 0; 608 } 609 610 .viewer-dot { 611 width: 6px; 612 height: 6px; 613 border-radius: 50%; 614 background: var(--accent); 615 flex-shrink: 0; 616 } 617 618 /* ---- Log panel ---- */ 619 .log-panel { 620 margin-top: 0.5rem; 621 max-height: 100px; 622 overflow-y: auto; 623 background: var(--bg); 624 border: 1px solid var(--border); 625 border-radius: 8px; 626 padding: 0.6rem; 627 font-family: "JetBrains Mono", monospace; 628 font-size: 0.65rem; 629 line-height: 1.6; 630 color: var(--text-dim); 631 } 632 633 .log-panel .log-line.error { 634 color: var(--red); 635 } 636 .log-panel .log-line.success { 637 color: var(--accent); 638 } 639 640 /* ---- Media controls ---- */ 641 .media-controls { 642 position: absolute; 643 bottom: 0; 644 left: 0; 645 right: 0; 646 display: flex; 647 align-items: center; 648 gap: 0.5rem; 649 padding: 0.5rem 0.75rem; 650 background: rgba(0, 0, 0, 0.7); 651 opacity: 0; 652 transition: opacity 0.25s; 653 z-index: 2; 654 } 655 656 .video-frame:hover .media-controls, 657 .media-controls:focus-within { 658 opacity: 1; 659 } 660 661 .mc-btn { 662 background: none; 663 border: none; 664 cursor: pointer; 665 padding: 4px; 666 display: flex; 667 align-items: center; 668 justify-content: center; 669 color: var(--text); 670 transition: color 0.15s; 671 } 672 673 .mc-btn:hover { 674 color: var(--accent); 675 } 676 677 .mc-btn svg { 678 width: 22px; 679 height: 22px; 680 fill: currentColor; 681 } 682 683 .volume-slider { 684 -webkit-appearance: none; 685 appearance: none; 686 width: 90px; 687 height: 4px; 688 border-radius: 2px; 689 background: var(--border); 690 outline: none; 691 cursor: pointer; 692 } 693 694 .volume-slider::-webkit-slider-thumb { 695 -webkit-appearance: none; 696 appearance: none; 697 width: 12px; 698 height: 12px; 699 border-radius: 50%; 700 background: var(--accent); 701 cursor: pointer; 702 } 703 704 .volume-slider::-moz-range-thumb { 705 width: 12px; 706 height: 12px; 707 border-radius: 50%; 708 background: var(--accent); 709 border: none; 710 cursor: pointer; 711 } 712 713 .volume-slider::-moz-range-track { 714 height: 4px; 715 border-radius: 2px; 716 background: var(--border); 717 } 718 719 /* ---- Browse view (live streamers directory) ---- */ 720 .browse-view { 721 display: none; 722 padding: 0 2rem 2rem; 723 } 724 725 .browse-view.visible { 726 display: block; 727 } 728 729 .browse-header { 730 display: flex; 731 align-items: center; 732 gap: 0.75rem; 733 margin-bottom: 1.25rem; 734 } 735 736 .browse-live-count { 737 font-family: "JetBrains Mono", monospace; 738 font-size: 0.8rem; 739 color: var(--accent); 740 display: flex; 741 align-items: center; 742 gap: 0.4rem; 743 } 744 745 .browse-live-count .live-pulse { 746 width: 8px; 747 height: 8px; 748 border-radius: 50%; 749 background: var(--accent); 750 animation: pulse 2s ease-in-out infinite; 751 } 752 753 .browse-grid { 754 display: grid; 755 grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); 756 gap: 1rem; 757 } 758 759 .stream-tile { 760 background: var(--surface); 761 border: 1px solid var(--border); 762 border-radius: 12px; 763 overflow: hidden; 764 cursor: pointer; 765 transition: 766 border-color 0.2s, 767 transform 0.15s; 768 text-decoration: none; 769 color: inherit; 770 display: block; 771 } 772 773 .stream-tile:hover { 774 border-color: var(--accent); 775 transform: translateY(-2px); 776 } 777 778 .tile-thumb { 779 width: 100%; 780 aspect-ratio: 16 / 9; 781 object-fit: cover; 782 display: block; 783 background: var(--bg); 784 } 785 786 .tile-body { 787 padding: 0.75rem; 788 display: flex; 789 gap: 0.6rem; 790 align-items: flex-start; 791 } 792 793 .tile-avatar { 794 width: 36px; 795 height: 36px; 796 border-radius: 50%; 797 flex-shrink: 0; 798 object-fit: cover; 799 background: var(--border); 800 } 801 802 .tile-info { 803 min-width: 0; 804 flex: 1; 805 } 806 807 .tile-title { 808 font-weight: 600; 809 font-size: 0.85rem; 810 color: var(--text); 811 white-space: nowrap; 812 overflow: hidden; 813 text-overflow: ellipsis; 814 line-height: 1.3; 815 } 816 817 .tile-handle { 818 font-family: "JetBrains Mono", monospace; 819 font-size: 0.7rem; 820 color: var(--text-dim); 821 white-space: nowrap; 822 overflow: hidden; 823 text-overflow: ellipsis; 824 } 825 826 .tile-meta { 827 display: flex; 828 align-items: center; 829 gap: 0.35rem; 830 margin-top: 0.25rem; 831 } 832 833 .tile-viewers { 834 font-family: "JetBrains Mono", monospace; 835 font-size: 0.65rem; 836 color: var(--text-dim); 837 display: flex; 838 align-items: center; 839 gap: 0.3rem; 840 } 841 842 .tile-viewers .viewer-dot { 843 width: 5px; 844 height: 5px; 845 border-radius: 50%; 846 background: var(--accent); 847 } 848 849 /* ---- Responsive ---- */ 850 @media (max-width: 800px) { 851 .browse-view { 852 padding: 0 1rem 1rem; 853 } 854 .browse-grid { 855 grid-template-columns: 1fr; 856 } 857 .main-layout { 858 padding: 0 1rem 1rem; 859 } 860 .stream-row { 861 flex-direction: column; 862 } 863 .video-frame { 864 border-radius: 12px 12px 0 0; 865 border-right: 1px solid var(--border); 866 border-bottom: none; 867 } 868 .chat-panel { 869 width: 100%; 870 border-radius: 0 0 12px 12px; 871 max-height: 300px; 872 } 873 .connect-bar { 874 flex-direction: column; 875 padding: 1rem; 876 } 877 .input-wrapper { 878 max-width: none; 879 } 880 .header { 881 padding: 1rem; 882 } 883 .tangled-link .tangled-label { 884 display: none; 885 } 886 } 887 </style> 888 </head> 889 <body> 890 <div class="header"> 891 <div class="logo"> 892 <a href="/" style="text-decoration: none; color: inherit" 893 ><span>&#9654;</span> Bootleg stream.place</a 894 > 895 </div> 896 <a 897 href="https://tangled.org/did:plc:rnpkyqnmsw4ipey6eotbdnnf/bootleg-stream-dot-place" 898 target="_blank" 899 rel="noopener noreferrer" 900 class="tangled-link" 901 > 902 <svg viewBox="0 0 25 25" xmlns="http://www.w3.org/2000/svg"> 903 <g transform="translate(-0.42924038,-0.87777209)"> 904 <path 905 fill="currentColor" 906 style="stroke-width: 0.111183" 907 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" 908 ></path> 909 </g> 910 </svg> 911 <span class="tangled-label">View on Tangled</span> 912 </a> 913 <div class="auth-controls"> 914 <span 915 class="auth-handle" 916 id="authHandle" 917 style="display: none" 918 ></span> 919 <button class="btn-auth" id="signInBtn" onclick="signIn()"> 920 Sign In 921 </button> 922 <button 923 class="btn-auth" 924 id="signOutBtn" 925 onclick="signOut()" 926 style="display: none" 927 > 928 Sign Out 929 </button> 930 </div> 931 </div> 932 933 <div class="connect-bar"> 934 <div class="input-wrapper"> 935 <input 936 type="text" 937 id="username" 938 placeholder="yourfavestreamer.com" 939 spellcheck="false" 940 autocomplete="off" 941 /> 942 </div> 943 <button class="btn btn-connect" id="connectBtn" onclick="connect()"> 944 Connect 945 </button> 946 <button 947 class="btn btn-disconnect" 948 id="disconnectBtn" 949 onclick="disconnect()" 950 > 951 Disconnect 952 </button> 953 </div> 954 955 <div class="browse-view" id="browseView"> 956 <div class="browse-header"> 957 <div class="browse-live-count"> 958 <span class="live-pulse"></span> 959 <span id="browseCount">0 streamers live</span> 960 </div> 961 </div> 962 <div class="browse-grid" id="browseGrid"></div> 963 </div> 964 965 <div class="main-layout" id="mainLayout"> 966 <div class="stream-info" id="streamInfo"> 967 <div class="stream-info-text"> 968 <div class="stream-title" id="streamTitle"></div> 969 <div class="stream-handle" id="streamHandle"></div> 970 </div> 971 <div class="stream-viewer-count" id="streamViewerCount"> 972 <span class="viewer-dot"></span> 973 <span id="viewerCountNum">0</span> watching 974 </div> 975 </div> 976 <div class="stream-row"> 977 <div class="video-frame"> 978 <video id="video" autoplay playsinline muted></video> 979 <div class="media-controls" id="mediaControls"> 980 <button 981 class="mc-btn" 982 id="playPauseBtn" 983 onclick="togglePlayPause()" 984 title="Play/Pause" 985 > 986 <svg viewBox="0 0 24 24"> 987 <polygon points="6,3 20,12 6,21" /> 988 </svg> 989 </button> 990 <button 991 class="mc-btn" 992 id="muteBtn" 993 onclick="toggleMute()" 994 title="Mute/Unmute" 995 > 996 <svg viewBox="0 0 24 24" id="muteIcon"> 997 <path d="M3 9v6h4l5 5V4L7 9H3z" /> 998 <line 999 x1="23" 1000 y1="9" 1001 x2="17" 1002 y2="15" 1003 stroke="currentColor" 1004 stroke-width="2" 1005 /> 1006 <line 1007 x1="17" 1008 y1="9" 1009 x2="23" 1010 y2="15" 1011 stroke="currentColor" 1012 stroke-width="2" 1013 /> 1014 </svg> 1015 </button> 1016 <input 1017 type="range" 1018 class="volume-slider" 1019 id="volumeSlider" 1020 min="0" 1021 max="1" 1022 step="0.05" 1023 value="1" 1024 oninput="setVolume(this.value)" 1025 title="Volume" 1026 /> 1027 <button 1028 class="mc-btn" 1029 id="fullscreenBtn" 1030 onclick="toggleFullscreen()" 1031 title="Fullscreen" 1032 style="margin-left: auto" 1033 > 1034 <svg viewBox="0 0 24 24"> 1035 <path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/> 1036 </svg> 1037 </button> 1038 </div> 1039 <div class="video-overlay" id="overlay"> 1040 <div class="idle-icon"> 1041 <svg viewBox="0 0 24 24"> 1042 <polygon points="5,3 19,12 5,21" /> 1043 </svg> 1044 </div> 1045 <div class="idle-text"> 1046 Enter a username to start watching 1047 </div> 1048 </div> 1049 </div> 1050 1051 <div class="chat-panel"> 1052 <div class="chat-header"> 1053 <div class="chat-ws-dot" id="chatWsDot"></div> 1054 <div class="chat-header-title">Chat</div> 1055 <div class="chat-header-count" id="chatCount"></div> 1056 </div> 1057 <div class="chat-messages" id="chatMessages"> 1058 <div class="chat-empty" id="chatEmpty"> 1059 No messages yet 1060 </div> 1061 </div> 1062 <div 1063 class="chat-input" 1064 id="chatInput" 1065 style="display: none" 1066 > 1067 <input 1068 type="text" 1069 id="chatMsgInput" 1070 placeholder="Send a message..." 1071 autocomplete="off" 1072 /> 1073 <button id="chatSendBtn" onclick="sendChat()"> 1074 Send 1075 </button> 1076 </div> 1077 <div class="chat-signin-prompt" id="chatSigninPrompt"> 1078 <span onclick="signIn()">Sign in to chat</span> 1079 </div> 1080 </div> 1081 </div> 1082 1083 <details class="status-details"> 1084 <summary> 1085 <div class="status-dot" id="statusDot"></div> 1086 <div class="status-text" id="statusText">Idle</div> 1087 <div class="status-stats" id="statusStats"></div> 1088 <span class="toggle-arrow">&#9654;</span> 1089 </summary> 1090 <div class="log-panel" id="logPanel"></div> 1091 </details> 1092 </div> 1093 1094 <script type="module"> 1095 const production = window.location.host !== "127.0.0.1"; 1096 1097 import { 1098 configureOAuth, 1099 createAuthorizationUrl, 1100 finalizeAuthorization, 1101 OAuthUserAgent, 1102 getSession, 1103 deleteStoredSession, 1104 } from "https://cdn.jsdelivr.net/npm/@atcute/oauth-browser-client/+esm"; 1105 import { Client } from "https://cdn.jsdelivr.net/npm/@atcute/client/+esm"; 1106 import { 1107 LocalActorResolver, 1108 XrpcHandleResolver, 1109 CompositeDidDocumentResolver, 1110 PlcDidDocumentResolver, 1111 WebDidDocumentResolver, 1112 CompositeHandleResolver, 1113 DohJsonHandleResolver, 1114 WellKnownHandleResolver, 1115 } from "https://cdn.jsdelivr.net/npm/@atcute/identity-resolver/+esm"; 1116 1117 const handleResolver = new CompositeHandleResolver({ 1118 methods: { 1119 dns: new DohJsonHandleResolver({ 1120 dohUrl: "https://cloudflare-dns.com/dns-query", 1121 }), 1122 http: new WellKnownHandleResolver(), 1123 }, 1124 }); 1125 1126 // ---- State ---- 1127 let pc = null; 1128 let ws = null; 1129 let statsInterval = null; 1130 let chatMsgCount = 0; 1131 const MAX_CHAT_MESSAGES = 500; 1132 // Used to help with ordering chat messages 1133 let videoLoadedAt = new Date(); 1134 1135 let atClient = null; 1136 let agent = null; 1137 let loggedInDid = null; 1138 let loggedInHandle = null; 1139 let currentStreamerDid = null; 1140 let handlingPopstate = false; 1141 1142 // ---- DOM refs ---- 1143 const video = document.getElementById("video"); 1144 const overlay = document.getElementById("overlay"); 1145 const statusDot = document.getElementById("statusDot"); 1146 const statusText = document.getElementById("statusText"); 1147 const statusStats = document.getElementById("statusStats"); 1148 const logPanel = document.getElementById("logPanel"); 1149 const connectBtn = document.getElementById("connectBtn"); 1150 const disconnectBtn = document.getElementById("disconnectBtn"); 1151 const usernameInput = document.getElementById("username"); 1152 const chatMessages = document.getElementById("chatMessages"); 1153 const chatEmpty = document.getElementById("chatEmpty"); 1154 const chatWsDot = document.getElementById("chatWsDot"); 1155 const chatCount = document.getElementById("chatCount"); 1156 const authHandle = document.getElementById("authHandle"); 1157 const signInBtn = document.getElementById("signInBtn"); 1158 const signOutBtn = document.getElementById("signOutBtn"); 1159 const chatInput = document.getElementById("chatInput"); 1160 const chatMsgInput = document.getElementById("chatMsgInput"); 1161 const chatSendBtn = document.getElementById("chatSendBtn"); 1162 const chatSigninPrompt = 1163 document.getElementById("chatSigninPrompt"); 1164 const streamInfo = document.getElementById("streamInfo"); 1165 const streamTitle = document.getElementById("streamTitle"); 1166 const streamHandle = document.getElementById("streamHandle"); 1167 const viewerCountNum = document.getElementById("viewerCountNum"); 1168 1169 // ---- OAuth setup ---- 1170 const redirectUri = 1171 window.location.origin + window.location.pathname; 1172 1173 const oauthScope = "atproto include:place.stream.authFull"; 1174 let clientId; 1175 if (production) { 1176 clientId = `${window.location.origin}/oauth-client-metadata.json`; 1177 } else { 1178 clientId = 1179 `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}` + 1180 `&scope=${encodeURIComponent(oauthScope)}`; 1181 } 1182 configureOAuth({ 1183 metadata: { client_id: clientId, redirect_uri: redirectUri }, 1184 identityResolver: new LocalActorResolver({ 1185 handleResolver, 1186 didDocumentResolver: new CompositeDidDocumentResolver({ 1187 methods: { 1188 plc: new PlcDidDocumentResolver(), 1189 web: new WebDidDocumentResolver(), 1190 }, 1191 }), 1192 }), 1193 }); 1194 1195 // ---- Auth UI helpers ---- 1196 function updateAuthUI() { 1197 if (loggedInDid) { 1198 authHandle.textContent = `@${loggedInHandle || loggedInDid}`; 1199 authHandle.style.display = ""; 1200 signInBtn.style.display = "none"; 1201 signOutBtn.style.display = ""; 1202 chatInput.style.display = ""; 1203 chatSigninPrompt.style.display = "none"; 1204 } else { 1205 authHandle.style.display = "none"; 1206 signInBtn.style.display = ""; 1207 signOutBtn.style.display = "none"; 1208 chatInput.style.display = "none"; 1209 chatSigninPrompt.style.display = ""; 1210 } 1211 } 1212 1213 function setAuthSession(session) { 1214 agent = new OAuthUserAgent(session); 1215 atClient = new Client({ handler: agent }); 1216 loggedInDid = session.info.sub; 1217 localStorage.setItem("atproto_did", loggedInDid); 1218 // Resolve handle from DID 1219 resolveOwnHandle(); 1220 } 1221 1222 async function getMiniDoc(identity) { 1223 const res = await fetch( 1224 `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(identity)}`, 1225 ); 1226 if (res.ok) { 1227 const data = await res.json(); 1228 return data; 1229 } else { 1230 log( 1231 `Error resolving the mini doc for: ${identity}`, 1232 "error", 1233 ); 1234 console.error(res); 1235 } 1236 } 1237 1238 async function resolveOwnHandle() { 1239 const miniDoc = await getMiniDoc(loggedInDid); 1240 loggedInHandle = miniDoc.handle; 1241 updateAuthUI(); 1242 } 1243 1244 async function signIn() { 1245 const handle = window.prompt( 1246 "Enter your atmosphere handle (e.g. jcsalterego.bsky.social):", 1247 ); 1248 if (!handle) return; 1249 try { 1250 const authUrl = await createAuthorizationUrl({ 1251 target: { type: "account", identifier: handle.trim() }, 1252 scope: oauthScope, 1253 }); 1254 await new Promise((r) => setTimeout(r, 200)); 1255 const maybeAprofile = getProfileFromUrl(); 1256 if (maybeAprofile) { 1257 localStorage.setItem( 1258 "last-watched-streamer", 1259 maybeAprofile, 1260 ); 1261 } 1262 window.location.assign(authUrl); 1263 } catch (err) { 1264 log(`Auth error: ${err.message}`, "error"); 1265 console.error(err); 1266 } 1267 } 1268 1269 async function signOut() { 1270 try { 1271 if (agent) await agent.signOut(); 1272 } catch { 1273 if (loggedInDid) deleteStoredSession(loggedInDid); 1274 } 1275 localStorage.removeItem("atproto_did"); 1276 atClient = null; 1277 agent = null; 1278 loggedInDid = null; 1279 loggedInHandle = null; 1280 updateAuthUI(); 1281 log("Signed out"); 1282 } 1283 1284 // ---- OAuth callback / session resume on load ---- 1285 async function initAuth() { 1286 // Check for OAuth callback in hash 1287 if (location.hash && location.hash.length > 1) { 1288 try { 1289 const params = new URLSearchParams( 1290 location.hash.slice(1), 1291 ); 1292 history.replaceState( 1293 null, 1294 "", 1295 location.pathname + location.search, 1296 ); 1297 const { session } = await finalizeAuthorization(params); 1298 setAuthSession(session); 1299 log(`Signed in as ${loggedInDid}`, "success"); 1300 const maybeAprofile = getProfileFromUrl(); 1301 if (maybeAprofile) { 1302 localStorage.setItem( 1303 "last-watched-streamer", 1304 maybeAprofile, 1305 ); 1306 } 1307 return; 1308 } catch (err) { 1309 // Hash might not be OAuth params, ignore 1310 console.warn("OAuth finalize failed:", err); 1311 } 1312 } 1313 1314 // Try to resume existing session 1315 const storedDid = localStorage.getItem("atproto_did"); 1316 if (storedDid) { 1317 try { 1318 const session = await getSession(storedDid, { 1319 allowStale: true, 1320 }); 1321 setAuthSession(session); 1322 log(`Session resumed for ${loggedInDid}`, "success"); 1323 } catch (err) { 1324 localStorage.removeItem("atproto_did"); 1325 console.warn("Session resume failed:", err); 1326 } 1327 } 1328 } 1329 1330 // ---- Chat sending ---- 1331 async function sendChat() { 1332 if (!atClient || !loggedInDid) return; 1333 1334 const text = chatMsgInput.value.trim(); 1335 1336 if (!text) return; 1337 if (!currentStreamerDid) { 1338 log("Cannot send: streamer DID not resolved", "error"); 1339 return; 1340 } 1341 1342 chatSendBtn.disabled = true; 1343 try { 1344 await atClient.post("com.atproto.repo.createRecord", { 1345 input: { 1346 repo: loggedInDid, 1347 collection: "place.stream.chat.message", 1348 record: { 1349 $type: "place.stream.chat.message", 1350 text: text, 1351 streamer: currentStreamerDid, 1352 createdAt: new Date().toISOString(), 1353 }, 1354 }, 1355 }); 1356 chatMsgInput.value = ""; 1357 } catch (err) { 1358 log(`Send failed: ${err.message}`, "error"); 1359 console.error(err); 1360 } finally { 1361 chatSendBtn.disabled = false; 1362 chatMsgInput.focus(); 1363 } 1364 } 1365 1366 // ---- Streamer DID resolution ---- 1367 async function resolveStreamerDid(handle) { 1368 try { 1369 const streamersMiniDoc = await getMiniDoc(handle); 1370 currentStreamerDid = streamersMiniDoc.did; 1371 log(`Streamer DID: ${currentStreamerDid}`); 1372 } catch { 1373 log("Streamer DID resolution failed", "error"); 1374 currentStreamerDid = null; 1375 } 1376 } 1377 1378 // ---- Event listeners ---- 1379 usernameInput.addEventListener("keydown", (e) => { 1380 if (e.key === "Enter") connect(); 1381 }); 1382 1383 chatMsgInput.addEventListener("keydown", (e) => { 1384 if (e.key === "Enter") sendChat(); 1385 }); 1386 1387 // ---- Media controls ---- 1388 const playPauseBtn = document.getElementById("playPauseBtn"); 1389 const muteBtn = document.getElementById("muteBtn"); 1390 const volumeSlider = document.getElementById("volumeSlider"); 1391 const fullscreenBtn = document.getElementById("fullscreenBtn"); 1392 const videoFrame = document.querySelector(".video-frame"); 1393 let savedVolume = 1; 1394 1395 const iconPlay = 1396 '<svg viewBox="0 0 24 24"><polygon points="6,3 20,12 6,21" /></svg>'; 1397 const iconPause = 1398 '<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>'; 1399 const iconVolume = 1400 '<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>'; 1401 const iconMuted = 1402 '<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>'; 1403 const iconFullscreen = 1404 '<svg viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>'; 1405 const iconExitFullscreen = 1406 '<svg viewBox="0 0 24 24"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>'; 1407 1408 function updatePlayPauseIcon() { 1409 playPauseBtn.innerHTML = video.paused ? iconPlay : iconPause; 1410 } 1411 1412 function updateMuteIcon() { 1413 muteBtn.innerHTML = 1414 video.muted || video.volume === 0 ? iconMuted : iconVolume; 1415 } 1416 1417 function togglePlayPause() { 1418 if (video.paused) { 1419 video.play().catch(() => {}); 1420 } else { 1421 video.pause(); 1422 } 1423 } 1424 1425 function toggleMute() { 1426 if (video.muted) { 1427 video.muted = false; 1428 video.volume = savedVolume || 1; 1429 volumeSlider.value = video.volume; 1430 } else { 1431 savedVolume = video.volume; 1432 video.muted = true; 1433 volumeSlider.value = 0; 1434 } 1435 updateMuteIcon(); 1436 } 1437 1438 function toggleFullscreen() { 1439 // iOS Safari only supports fullscreen on <video> via webkit prefix 1440 if (video.webkitEnterFullscreen && !document.fullscreenEnabled) { 1441 video.webkitEnterFullscreen(); 1442 return; 1443 } 1444 if (!document.fullscreenElement) { 1445 videoFrame.requestFullscreen().catch((err) => { 1446 log(`Fullscreen error: ${err.message}`, "error"); 1447 }); 1448 } else { 1449 document.exitFullscreen(); 1450 } 1451 } 1452 1453 function updateFullscreenIcon() { 1454 fullscreenBtn.innerHTML = document.fullscreenElement 1455 ? iconExitFullscreen 1456 : iconFullscreen; 1457 } 1458 1459 document.addEventListener("fullscreenchange", updateFullscreenIcon); 1460 video.addEventListener("webkitendfullscreen", updateFullscreenIcon); 1461 1462 function setVolume(val) { 1463 val = parseFloat(val); 1464 video.volume = val; 1465 if (val > 0 && video.muted) { 1466 video.muted = false; 1467 } 1468 savedVolume = val > 0 ? val : savedVolume; 1469 updateMuteIcon(); 1470 } 1471 1472 video.addEventListener("play", updatePlayPauseIcon); 1473 video.addEventListener("pause", updatePlayPauseIcon); 1474 video.addEventListener("volumechange", () => { 1475 updateMuteIcon(); 1476 if (!video.muted) { 1477 volumeSlider.value = video.volume; 1478 } 1479 }); 1480 1481 // ---- Logging / status ---- 1482 function log(msg, type = "") { 1483 const line = document.createElement("div"); 1484 line.className = "log-line" + (type ? ` ${type}` : ""); 1485 const ts = new Date().toLocaleTimeString("en-US", { 1486 hour12: false, 1487 }); 1488 line.textContent = `${ts} ${msg}`; 1489 logPanel.appendChild(line); 1490 logPanel.scrollTop = logPanel.scrollHeight; 1491 } 1492 1493 function setStatus(text, state = "") { 1494 statusText.textContent = text; 1495 statusDot.className = "status-dot" + (state ? ` ${state}` : ""); 1496 } 1497 1498 // ---- Chat WebSocket ---- 1499 function connectChat(username) { 1500 if (ws) { 1501 ws.close(); 1502 ws = null; 1503 } 1504 1505 const wsUrl = `wss://stream.place/api/websocket/${encodeURIComponent(username)}`; 1506 log(`Chat WS: ${wsUrl}`); 1507 1508 ws = new WebSocket(wsUrl); 1509 1510 ws.onopen = () => { 1511 log("Chat connected", "success"); 1512 chatWsDot.classList.add("connected"); 1513 }; 1514 1515 ws.onclose = (e) => { 1516 log(`Chat disconnected (code ${e.code})`); 1517 chatWsDot.classList.remove("connected"); 1518 }; 1519 1520 ws.onerror = () => { 1521 log("Chat WebSocket error", "error"); 1522 chatWsDot.classList.remove("connected"); 1523 }; 1524 1525 ws.onmessage = (event) => { 1526 try { 1527 const data = JSON.parse(event.data); 1528 if ( 1529 data.$type === "place.stream.chat.defs#messageView" 1530 ) { 1531 appendChatMessage(data); 1532 } else if ( 1533 data.$type === 1534 "place.stream.livestream#livestreamView" 1535 ) { 1536 const title = data.record?.title || ""; 1537 const handle = data.author?.handle || ""; 1538 streamTitle.textContent = title; 1539 streamHandle.textContent = handle 1540 ? `@${handle}` 1541 : ""; 1542 streamInfo.classList.add("visible"); 1543 } else if ( 1544 data.$type === "place.stream.livestream#viewerCount" 1545 ) { 1546 viewerCountNum.textContent = data.count ?? 0; 1547 } 1548 } catch { 1549 // Ignore non-JSON or unknown message types 1550 } 1551 }; 1552 } 1553 1554 function disconnectChat() { 1555 if (ws) { 1556 ws.close(); 1557 ws = null; 1558 } 1559 chatWsDot.classList.remove("connected"); 1560 streamInfo.classList.remove("visible"); 1561 streamTitle.textContent = ""; 1562 streamHandle.textContent = ""; 1563 viewerCountNum.textContent = "0"; 1564 } 1565 1566 function appendChatMessage(data) { 1567 chatEmpty.style.display = "none"; 1568 1569 const handle = data.author?.handle || "unknown"; 1570 const text = data.record?.text || ""; 1571 const color = data.chatProfile?.color; 1572 const indexedAt = data.indexedAt; 1573 1574 let authorColor = "#4ade80"; 1575 if (color && color.red !== undefined) { 1576 authorColor = `rgb(${color.red}, ${color.green}, ${color.blue})`; 1577 } 1578 1579 let timeStr = ""; 1580 // if (indexedAt) { 1581 const indexedAtDate = new Date(indexedAt); 1582 timeStr = indexedAtDate.toLocaleTimeString("en-US", { 1583 hour12: false, 1584 hour: "2-digit", 1585 minute: "2-digit", 1586 second: "2-digit", 1587 }); 1588 // } 1589 1590 const msgEl = document.createElement("div"); 1591 msgEl.className = "chat-msg"; 1592 1593 if (data.uri) { 1594 msgEl.dataset.uri = data.uri; 1595 } 1596 if (data.record?.reply?.parent?.uri) { 1597 msgEl.dataset.parentUri = data.record.reply.parent.uri; 1598 } 1599 if (data.record?.reply?.root?.uri) { 1600 msgEl.dataset.rootUri = data.record.reply.root.uri; 1601 } 1602 1603 // Reply preview 1604 if (data.replyTo) { 1605 const replyPreview = document.createElement("div"); 1606 replyPreview.className = "chat-reply-preview"; 1607 1608 const replyAuthor = document.createElement("span"); 1609 replyAuthor.className = "chat-reply-author"; 1610 const replyHandle = data.replyTo.author?.handle || "unknown"; 1611 const replyColor = data.replyTo.chatProfile?.color; 1612 let replyAuthorColor = "#4ade80"; 1613 if (replyColor && replyColor.red !== undefined) { 1614 replyAuthorColor = `rgb(${replyColor.red}, ${replyColor.green}, ${replyColor.blue})`; 1615 } 1616 replyAuthor.style.color = replyAuthorColor; 1617 replyAuthor.textContent = replyHandle; 1618 1619 const replyText = document.createElement("span"); 1620 replyText.className = "chat-reply-text"; 1621 const parentText = data.replyTo.record?.text || ""; 1622 replyText.textContent = parentText.length > 80 1623 ? parentText.slice(0, 80) + "…" 1624 : parentText; 1625 1626 replyPreview.appendChild(replyAuthor); 1627 replyPreview.appendChild(replyText); 1628 1629 // Click to scroll to parent message 1630 if (data.replyTo.uri) { 1631 replyPreview.dataset.uri = data.replyTo.uri; 1632 replyPreview.addEventListener("click", () => { 1633 const parent = chatMessages.querySelector( 1634 `[data-uri="${CSS.escape(data.replyTo.uri)}"]` 1635 ); 1636 if (parent) { 1637 parent.scrollIntoView({ behavior: "smooth", block: "center" }); 1638 parent.style.background = "#ffffff12"; 1639 setTimeout(() => { parent.style.background = ""; }, 1500); 1640 } 1641 }); 1642 } 1643 1644 msgEl.appendChild(replyPreview); 1645 } 1646 1647 const msgContent = document.createElement("div"); 1648 1649 const authorSpan = document.createElement("span"); 1650 authorSpan.className = "chat-msg-author"; 1651 authorSpan.style.color = authorColor; 1652 authorSpan.textContent = handle; 1653 1654 const textSpan = document.createElement("span"); 1655 textSpan.className = "chat-msg-text"; 1656 textSpan.textContent = text; 1657 1658 const timeSpan = document.createElement("span"); 1659 timeSpan.className = "chat-msg-time"; 1660 timeSpan.textContent = timeStr; 1661 1662 msgContent.appendChild(timeSpan); 1663 msgContent.appendChild(authorSpan); 1664 msgContent.appendChild(textSpan); 1665 1666 msgEl.appendChild(msgContent); 1667 1668 if (indexedAtDate < videoLoadedAt) { 1669 chatMessages.appendChild(msgEl); 1670 } else { 1671 chatMessages.prepend(msgEl); 1672 } 1673 1674 chatMsgCount++; 1675 1676 while (chatMessages.children.length > MAX_CHAT_MESSAGES + 1) { 1677 const last = chatMessages.lastElementChild; 1678 if (last && last !== chatEmpty) { 1679 last.remove(); 1680 } else { 1681 break; 1682 } 1683 } 1684 1685 chatCount.textContent = `${chatMsgCount} msgs`; 1686 1687 // if (isAtNewest) { 1688 // chatMessages.scrollTop = 0; 1689 // } 1690 } 1691 1692 // ---- WebRTC ---- 1693 async function connect() { 1694 const streamersHandle = usernameInput.value.trim(); 1695 if (!streamersHandle) { 1696 usernameInput.focus(); 1697 return; 1698 } 1699 1700 if (pc) disconnect(); 1701 1702 chatMsgCount = 0; 1703 chatCount.textContent = ""; 1704 chatEmpty.style.display = ""; 1705 const existingMsgs = chatMessages.querySelectorAll(".chat-msg"); 1706 existingMsgs.forEach((m) => m.remove()); 1707 1708 const whepUrl = `https://stream.place/api/playback/${encodeURIComponent(streamersHandle)}/webrtc?rendition=source`; 1709 1710 setStatus("Connecting\u2026"); 1711 log(`WHEP endpoint: ${whepUrl}`); 1712 1713 connectBtn.style.display = "none"; 1714 disconnectBtn.style.display = ""; 1715 1716 connectChat(streamersHandle); 1717 await resolveStreamerDid(streamersHandle); 1718 1719 try { 1720 pc = new RTCPeerConnection({ 1721 iceServers: [{ urls: "stun:stun.l.google.com:19302" }], 1722 bundlePolicy: "max-bundle", 1723 }); 1724 1725 pc.addTransceiver("video", { direction: "recvonly" }); 1726 pc.addTransceiver("audio", { direction: "recvonly" }); 1727 1728 pc.ontrack = (event) => { 1729 log(`Track received: ${event.track.kind}`, "success"); 1730 if (event.streams && event.streams[0]) { 1731 video.srcObject = event.streams[0]; 1732 } else { 1733 if (!video.srcObject) { 1734 video.srcObject = new MediaStream(); 1735 } 1736 video.srcObject.addTrack(event.track); 1737 } 1738 overlay.classList.add("hidden"); 1739 setStatus("Live", "live"); 1740 video.play().catch(() => {}); 1741 }; 1742 1743 pc.oniceconnectionstatechange = () => { 1744 log(`PeerConnection: ${pc.iceConnectionState}`); 1745 if ( 1746 pc.iceConnectionState === "connected" || 1747 pc.iceConnectionState === "completed" 1748 ) { 1749 //This is when the video is successfully loaded 1750 videoLoadedAt = new Date(); 1751 1752 if (!handlingPopstate) { 1753 window.history.pushState( 1754 {}, 1755 "", 1756 `/${streamersHandle}`, 1757 ); 1758 } 1759 setStatus("Live", "live"); 1760 startStats(); 1761 } else if ( 1762 pc.iceConnectionState === "failed" || 1763 pc.iceConnectionState === "disconnected" 1764 ) { 1765 setStatus("Disconnected", "error"); 1766 log("Connection lost", "error"); 1767 stopStats(); 1768 } 1769 }; 1770 1771 pc.onconnectionstatechange = () => { 1772 log(`Connection: ${pc.connectionState}`); 1773 if (pc.connectionState === "failed") { 1774 setStatus("Failed", "error"); 1775 log("PeerConnection failed", "error"); 1776 stopStats(); 1777 } 1778 }; 1779 1780 const offer = await pc.createOffer(); 1781 await pc.setLocalDescription(offer); 1782 await waitForIceGathering(pc, 2000); 1783 1784 log("Sending SDP offer\u2026"); 1785 1786 const resp = await fetch(whepUrl, { 1787 method: "POST", 1788 headers: { "Content-Type": "application/sdp" }, 1789 body: pc.localDescription.sdp, 1790 }); 1791 1792 if (!resp.ok) { 1793 const errText = await resp.text(); 1794 throw new Error(`WHEP ${resp.status}: ${errText}`); 1795 } 1796 1797 const answerSdp = await resp.text(); 1798 log("Received SDP answer", "success"); 1799 1800 await pc.setRemoteDescription({ 1801 type: "answer", 1802 sdp: answerSdp, 1803 }); 1804 log("Remote description set, waiting for media\u2026"); 1805 } catch (err) { 1806 log(`Error: ${err.message}`, "error"); 1807 setStatus("Error", "error"); 1808 console.error(err); 1809 } 1810 } 1811 1812 function waitForIceGathering(peerConnection, timeout) { 1813 return new Promise((resolve) => { 1814 if (peerConnection.iceGatheringState === "complete") { 1815 resolve(); 1816 return; 1817 } 1818 const timer = setTimeout(() => { 1819 log( 1820 "PeerConnection gathering timed out, proceeding with candidates", 1821 ); 1822 resolve(); 1823 }, timeout); 1824 1825 peerConnection.onicegatheringstatechange = () => { 1826 if (peerConnection.iceGatheringState === "complete") { 1827 clearTimeout(timer); 1828 log("PeerConnection gathering complete"); 1829 resolve(); 1830 } 1831 }; 1832 }); 1833 } 1834 1835 function teardownStream() { 1836 stopStats(); 1837 disconnectChat(); 1838 if (pc) { 1839 pc.close(); 1840 pc = null; 1841 } 1842 currentStreamerDid = null; 1843 video.srcObject = null; 1844 overlay.classList.remove("hidden"); 1845 setStatus("Idle"); 1846 statusStats.textContent = ""; 1847 connectBtn.style.display = ""; 1848 disconnectBtn.style.display = "none"; 1849 } 1850 1851 function disconnect() { 1852 teardownStream(); 1853 log("Disconnected"); 1854 window.history.pushState({}, "", "/"); 1855 showBrowseView(); 1856 } 1857 1858 function startStats() { 1859 stopStats(); 1860 statsInterval = setInterval(async () => { 1861 if (!pc) return; 1862 try { 1863 const stats = await pc.getStats(); 1864 let resolution = ""; 1865 stats.forEach((report) => { 1866 if ( 1867 report.type === "inbound-rtp" && 1868 report.kind === "video" 1869 ) { 1870 if (report.frameWidth && report.frameHeight) { 1871 resolution = `${report.frameWidth}\u00d7${report.frameHeight}`; 1872 } 1873 } 1874 }); 1875 const parts = []; 1876 if (resolution) parts.push(resolution); 1877 statusStats.textContent = parts.join(" \u00b7 "); 1878 } catch {} 1879 }, 2000); 1880 } 1881 1882 function stopStats() { 1883 if (statsInterval) { 1884 clearInterval(statsInterval); 1885 statsInterval = null; 1886 } 1887 } 1888 1889 function getProfileFromUrl() { 1890 const path = window.location.pathname; 1891 const pathSplit = path.split("/"); 1892 if (pathSplit.length > 1) { 1893 const maybeAprofile = pathSplit[1]; 1894 if (maybeAprofile !== "") { 1895 return maybeAprofile; 1896 } 1897 } 1898 } 1899 1900 function urlProfileWatch() { 1901 const maybeAprofile = getProfileFromUrl(); 1902 if (maybeAprofile) { 1903 usernameInput.value = maybeAprofile; 1904 connect(); 1905 } else { 1906 showBrowseView(); 1907 } 1908 } 1909 1910 // ---- Browse view (live streamers directory) ---- 1911 const browseView = document.getElementById("browseView"); 1912 const browseGrid = document.getElementById("browseGrid"); 1913 const browseCount = document.getElementById("browseCount"); 1914 const mainLayout = document.getElementById("mainLayout"); 1915 1916 function getThumbnailUrl(did, thumb) { 1917 if (!thumb || !thumb.ref || !thumb.ref.$link) return ""; 1918 return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${thumb.ref.$link}@jpeg`; 1919 } 1920 1921 async function fetchLiveUsers() { 1922 try { 1923 const res = await fetch( 1924 "https://stream.place/xrpc/place.stream.live.getLiveUsers", 1925 ); 1926 if (!res.ok) throw new Error(`HTTP ${res.status}`); 1927 const data = await res.json(); 1928 return data.streams || []; 1929 } catch (err) { 1930 console.error("Failed to fetch live users:", err); 1931 return []; 1932 } 1933 } 1934 1935 async function fetchAvatars(handles) { 1936 if (!handles.length) return {}; 1937 try { 1938 // getProfiles is just TOO convient 1939 const params = handles 1940 .map((h) => `actors=${encodeURIComponent(h)}`) 1941 .join("&"); 1942 const res = await fetch( 1943 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles?${params}`, 1944 ); 1945 if (!res.ok) return {}; 1946 const data = await res.json(); 1947 const map = {}; 1948 for (const profile of data.profiles || []) { 1949 if (profile.avatar) { 1950 map[profile.handle] = profile.avatar; 1951 } 1952 } 1953 return map; 1954 } catch { 1955 return {}; 1956 } 1957 } 1958 1959 function renderBrowseView(streams, avatarMap) { 1960 browseGrid.replaceChildren(); 1961 const total = streams.length; 1962 browseCount.textContent = `${total} streamer${total !== 1 ? "s" : ""} live`; 1963 1964 for (const stream of streams) { 1965 const handle = stream.author?.handle || "unknown"; 1966 const did = stream.author?.did || ""; 1967 const title = stream.record?.title || "Untitled stream"; 1968 const viewers = stream.viewerCount?.count ?? 0; 1969 const thumbUrl = getThumbnailUrl(did, stream.record?.thumb); 1970 const avatarUrl = avatarMap[handle] || ""; 1971 1972 const tile = document.createElement("a"); 1973 tile.className = "stream-tile"; 1974 tile.href = `/${handle}`; 1975 tile.onclick = (e) => { 1976 e.preventDefault(); 1977 browseView.classList.remove("visible"); 1978 mainLayout.style.display = ""; 1979 usernameInput.value = handle; 1980 connect(); 1981 }; 1982 1983 if (thumbUrl) { 1984 const thumbImg = document.createElement("img"); 1985 thumbImg.className = "tile-thumb"; 1986 thumbImg.src = thumbUrl; 1987 thumbImg.alt = ""; 1988 thumbImg.loading = "lazy"; 1989 thumbImg.onerror = () => { thumbImg.style.display = "none"; }; 1990 tile.appendChild(thumbImg); 1991 } else { 1992 const thumbDiv = document.createElement("div"); 1993 thumbDiv.className = "tile-thumb"; 1994 tile.appendChild(thumbDiv); 1995 } 1996 1997 const tileBody = document.createElement("div"); 1998 tileBody.className = "tile-body"; 1999 2000 if (avatarUrl) { 2001 const avatarImg = document.createElement("img"); 2002 avatarImg.className = "tile-avatar"; 2003 avatarImg.src = avatarUrl; 2004 avatarImg.alt = ""; 2005 avatarImg.loading = "lazy"; 2006 avatarImg.onerror = () => { avatarImg.style.display = "none"; }; 2007 tileBody.appendChild(avatarImg); 2008 } else { 2009 const avatarDiv = document.createElement("div"); 2010 avatarDiv.className = "tile-avatar"; 2011 tileBody.appendChild(avatarDiv); 2012 } 2013 2014 const tileInfo = document.createElement("div"); 2015 tileInfo.className = "tile-info"; 2016 2017 const tileTitle = document.createElement("div"); 2018 tileTitle.className = "tile-title"; 2019 tileTitle.textContent = title; 2020 2021 const tileHandle = document.createElement("div"); 2022 tileHandle.className = "tile-handle"; 2023 tileHandle.textContent = `@${handle}`; 2024 2025 const tileMeta = document.createElement("div"); 2026 tileMeta.className = "tile-meta"; 2027 2028 const tileViewers = document.createElement("div"); 2029 tileViewers.className = "tile-viewers"; 2030 2031 const viewerDot = document.createElement("span"); 2032 viewerDot.className = "viewer-dot"; 2033 tileViewers.appendChild(viewerDot); 2034 tileViewers.appendChild(document.createTextNode(`${viewers} watching`)); 2035 2036 tileMeta.appendChild(tileViewers); 2037 tileInfo.appendChild(tileTitle); 2038 tileInfo.appendChild(tileHandle); 2039 tileInfo.appendChild(tileMeta); 2040 tileBody.appendChild(tileInfo); 2041 tile.appendChild(tileBody); 2042 2043 browseGrid.appendChild(tile); 2044 } 2045 } 2046 2047 async function showBrowseView() { 2048 mainLayout.style.display = "none"; 2049 browseView.classList.add("visible"); 2050 2051 const streams = await fetchLiveUsers(); 2052 // Sort by viewer count descending 2053 streams.sort( 2054 (a, b) => 2055 (b.viewerCount?.count ?? 0) - 2056 (a.viewerCount?.count ?? 0), 2057 ); 2058 2059 const handles = streams 2060 .map((s) => s.author?.handle) 2061 .filter(Boolean); 2062 const avatarMap = await fetchAvatars(handles); 2063 2064 renderBrowseView(streams, avatarMap); 2065 } 2066 2067 // ---- Expose to onclick handlers ---- 2068 window.connect = connect; 2069 window.disconnect = disconnect; 2070 window.signIn = signIn; 2071 window.signOut = signOut; 2072 window.sendChat = sendChat; 2073 window.togglePlayPause = togglePlayPause; 2074 window.toggleMute = toggleMute; 2075 window.setVolume = setVolume; 2076 window.toggleFullscreen = toggleFullscreen; 2077 2078 // ---- History navigation ---- 2079 window.addEventListener("popstate", () => { 2080 handlingPopstate = true; 2081 const profile = getProfileFromUrl(); 2082 if (profile) { 2083 if (pc) teardownStream(); 2084 browseView.classList.remove("visible"); 2085 mainLayout.style.display = ""; 2086 usernameInput.value = profile; 2087 connect(); 2088 } else { 2089 if (pc) teardownStream(); 2090 showBrowseView(); 2091 } 2092 handlingPopstate = false; 2093 }); 2094 2095 // ---- Init ---- 2096 updateAuthUI(); 2097 initAuth(); 2098 urlProfileWatch(); 2099 </script> 2100 </body> 2101</html>