A stream.place client in a single index.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-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>▶</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">▶</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>