Something went wrong. Try again.
A stream.place client in a single index.html
Something went wrong. Try again.
bootleg-stream-dot-place index.html
79 kB · 2101 lines
HTML
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102<!doctype html><html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <title>Bootleg stream.place</title> <meta name="description" content="What if the stream.place client was in a single index.html?" /> <meta name="og:description" content="What if the stream.place client was in a single index.html?" /> <style> @import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&family=Outfit:wght@300;400;600;700&display=swap");
:root { --bg: #0a0a0b; --surface: #141416; --border: #222228; --text: #e8e8ec; --text-dim: #6e6e7a; --accent: #4ade80; --accent-dim: #4ade8020; --red: #f87171; --red-dim: #f8717120; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg); color: var(--text); font-family: "Outfit", sans-serif; min-height: 100vh; display: flex; flex-direction: column; }
.header { width: 100%; padding: 1.25rem 2rem; display: flex; align-items: center; gap: 0.75rem; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.header .logo { font-family: "JetBrains Mono", monospace; font-weight: 600; font-size: 0.85rem; letter-spacing: -0.02em; color: var(--text-dim); }
.header .logo span { color: var(--accent); }
.connect-bar { width: 100%; padding: 1.25rem 2rem; display: flex; gap: 0.75rem; align-items: stretch; flex-shrink: 0; }
.input-wrapper { flex: 1; max-width: 400px; position: relative; display: flex; align-items: center; }
.input-wrapper input { width: 100%; padding: 0.75rem 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; color: var(--text); font-family: "JetBrains Mono", monospace; font-size: 0.85rem; outline: none; transition: border-color 0.2s, box-shadow 0.2s; }
.input-wrapper input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
.input-wrapper input::placeholder { color: var(--text-dim); opacity: 0.5; }
.btn { padding: 0.75rem 1.5rem; border-radius: 10px; font-family: "Outfit", sans-serif; font-weight: 600; font-size: 0.85rem; cursor: pointer; border: none; transition: all 0.15s; white-space: nowrap; }
.btn-connect { background: var(--accent); color: var(--bg); } .btn-connect:hover { filter: brightness(1.1); transform: translateY(-1px); } .btn-connect:active { transform: translateY(0); }
.btn-disconnect { background: var(--red-dim); color: var(--red); border: 1px solid var(--red); display: none; } .btn-disconnect:hover { background: var(--red); color: var(--bg); }
/* ---- Main layout: video + chat side by side ---- */ .main-layout { padding: 0 2rem 1.5rem; }
.stream-row { display: flex; align-items: stretch; height: 750px; }
.video-frame { position: relative; flex: 1; min-width: 0; aspect-ratio: 16 / 9; background: var(--surface); border-radius: 12px 0 0 12px; overflow: hidden; border: 1px solid var(--border); border-right: none; }
.video-frame video { width: 100%; height: 100%; object-fit: contain; display: block; }
.video-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; pointer-events: none; transition: opacity 0.3s; }
.video-overlay.hidden { opacity: 0; }
.video-overlay .idle-icon { width: 48px; height: 48px; border-radius: 50%; border: 2px solid var(--border); display: flex; align-items: center; justify-content: center; }
.video-overlay .idle-icon svg { width: 20px; height: 20px; fill: var(--text-dim); }
.video-overlay .idle-text { font-size: 0.85rem; color: var(--text-dim); font-weight: 300; }
.status-details { padding-top: 0.5rem; }
.status-details summary { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; font-family: "JetBrains Mono", monospace; font-size: 0.72rem; color: var(--text-dim); list-style: none; user-select: none; }
.status-details summary::-webkit-details-marker { display: none; }
.status-details summary .toggle-arrow { font-size: 0.6rem; transition: transform 0.2s; flex-shrink: 0; }
.status-details[open] summary .toggle-arrow { transform: rotate(90deg); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--border); transition: background 0.3s; flex-shrink: 0; }
.status-dot.live { background: var(--accent); box-shadow: 0 0 8px var(--accent-dim); animation: pulse 2s ease-in-out infinite; }
.status-dot.error { background: var(--red); }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.status-text { flex: 1; }
.status-stats { color: var(--text-dim); opacity: 0.6; }
/* ---- Chat panel ---- */ .chat-panel { width: 420px; flex-shrink: 0; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 0 12px 12px 0; background: var(--surface); overflow: hidden; }
.chat-header { padding: 0.85rem 1rem; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.chat-header-title { font-family: "JetBrains Mono", monospace; font-size: 0.75rem; font-weight: 500; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; }
.chat-header-count { font-family: "JetBrains Mono", monospace; font-size: 0.65rem; color: var(--text-dim); opacity: 0.5; margin-left: auto; }
.chat-ws-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--border); flex-shrink: 0; transition: background 0.3s; }
.chat-ws-dot.connected { background: var(--accent); }
.chat-messages { flex: 1; overflow-y: auto; padding: 0.5rem 0; display: flex; flex-direction: column-reverse; min-height: 0; height: 0; }
.chat-messages::-webkit-scrollbar { width: 4px; } .chat-messages::-webkit-scrollbar-track { background: transparent; } .chat-messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.chat-msg { padding: 0.35rem 1rem; font-size: 0.82rem; line-height: 1.45; transition: background 0.15s; word-break: break-word; }
.chat-msg:hover { background: #ffffff06; }
.chat-msg-author { font-weight: 600; margin-right: 0.35rem; cursor: default; }
.chat-msg-text { color: var(--text); font-weight: 300; }
.chat-msg-time { font-family: "JetBrains Mono", monospace; font-size: 0.6rem; color: var(--text-dim); opacity: 0.45; margin-right: 0.4rem; flex-shrink: 0; }
.chat-reply-preview { display: flex; align-items: baseline; gap: 0.3rem; font-size: 0.7rem; color: var(--text-dim); padding: 0.15rem 0 0.1rem 0.6rem; border-left: 2px solid #ffffff18; margin-bottom: 0.2rem; overflow: hidden; cursor: pointer; }
.chat-reply-preview:hover { border-left-color: #ffffff30; }
.chat-reply-author { font-weight: 600; flex-shrink: 0; }
.chat-reply-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; opacity: 0.6; }
.chat-empty { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--text-dim); font-size: 0.8rem; font-weight: 300; opacity: 0.5; }
/* ---- Tangled source link ---- */ .tangled-link { display: flex; align-items: center; gap: 0.4rem; font-family: "JetBrains Mono", monospace; font-size: 0.72rem; color: var(--text-dim); text-decoration: none; padding: 0.4rem 0.75rem; border-radius: 8px; border: 1px solid transparent; transition: all 0.15s; white-space: nowrap; }
.tangled-link svg { width: 16px; height: 16px; flex-shrink: 0; }
.tangled-link:hover { color: var(--text); border-color: var(--border); background: var(--surface); }
/* ---- Auth controls ---- */ .auth-controls { margin-left: auto; display: flex; align-items: center; gap: 0.75rem; }
.auth-handle { font-family: "JetBrains Mono", monospace; font-size: 0.75rem; color: var(--accent); font-weight: 500; }
.btn-auth { padding: 0.4rem 0.85rem; border-radius: 8px; font-family: "Outfit", sans-serif; font-weight: 500; font-size: 0.75rem; cursor: pointer; transition: all 0.15s; white-space: nowrap; background: transparent; color: var(--text-dim); border: 1px solid var(--border); }
.btn-auth:hover { border-color: var(--text-dim); color: var(--text); }
/* ---- Chat input ---- */ .chat-input { display: flex; gap: 0.5rem; padding: 0.65rem 0.75rem; border-top: 1px solid var(--border); flex-shrink: 0; }
.chat-input input { flex: 1; padding: 0.5rem 0.75rem; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; color: var(--text); font-family: "Outfit", sans-serif; font-size: 0.8rem; outline: none; transition: border-color 0.2s; }
.chat-input input:focus { border-color: var(--accent); }
.chat-input input::placeholder { color: var(--text-dim); opacity: 0.4; }
.chat-input button { padding: 0.5rem 0.85rem; background: var(--accent); color: var(--bg); border: none; border-radius: 8px; font-family: "Outfit", sans-serif; font-weight: 600; font-size: 0.75rem; cursor: pointer; transition: filter 0.15s; flex-shrink: 0; }
.chat-input button:hover { filter: brightness(1.1); }
.chat-input button:disabled { opacity: 0.4; cursor: default; filter: none; }
.chat-signin-prompt { padding: 0.65rem 0.75rem; border-top: 1px solid var(--border); text-align: center; flex-shrink: 0; }
.chat-signin-prompt span { font-size: 0.75rem; color: var(--text-dim); opacity: 0.6; cursor: pointer; transition: opacity 0.15s; }
.chat-signin-prompt span:hover { opacity: 1; }
/* ---- Stream info bar ---- */ .stream-info { display: none; align-items: center; gap: 1rem; padding: 0.6rem 0; }
.stream-info.visible { display: flex; }
.stream-info-text { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; flex: 1; }
.stream-title { font-weight: 600; font-size: 0.95rem; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.stream-handle { font-family: "JetBrains Mono", monospace; font-size: 0.72rem; color: var(--text-dim); white-space: nowrap; }
.stream-viewer-count { font-family: "JetBrains Mono", monospace; font-size: 0.72rem; color: var(--text-dim); display: flex; align-items: center; gap: 0.35rem; white-space: nowrap; flex-shrink: 0; }
.viewer-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
/* ---- Log panel ---- */ .log-panel { margin-top: 0.5rem; max-height: 100px; overflow-y: auto; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem; font-family: "JetBrains Mono", monospace; font-size: 0.65rem; line-height: 1.6; color: var(--text-dim); }
.log-panel .log-line.error { color: var(--red); } .log-panel .log-line.success { color: var(--accent); }
/* ---- Media controls ---- */ .media-controls { position: absolute; bottom: 0; left: 0; right: 0; display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; background: rgba(0, 0, 0, 0.7); opacity: 0; transition: opacity 0.25s; z-index: 2; }
.video-frame:hover .media-controls, .media-controls:focus-within { opacity: 1; }
.mc-btn { background: none; border: none; cursor: pointer; padding: 4px; display: flex; align-items: center; justify-content: center; color: var(--text); transition: color 0.15s; }
.mc-btn:hover { color: var(--accent); }
.mc-btn svg { width: 22px; height: 22px; fill: currentColor; }
.volume-slider { -webkit-appearance: none; appearance: none; width: 90px; height: 4px; border-radius: 2px; background: var(--border); outline: none; cursor: pointer; }
.volume-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent); cursor: pointer; }
.volume-slider::-moz-range-thumb { width: 12px; height: 12px; border-radius: 50%; background: var(--accent); border: none; cursor: pointer; }
.volume-slider::-moz-range-track { height: 4px; border-radius: 2px; background: var(--border); }
/* ---- Browse view (live streamers directory) ---- */ .browse-view { display: none; padding: 0 2rem 2rem; }
.browse-view.visible { display: block; }
.browse-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.25rem; }
.browse-live-count { font-family: "JetBrains Mono", monospace; font-size: 0.8rem; color: var(--accent); display: flex; align-items: center; gap: 0.4rem; }
.browse-live-count .live-pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: pulse 2s ease-in-out infinite; }
.browse-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; }
.stream-tile { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; cursor: pointer; transition: border-color 0.2s, transform 0.15s; text-decoration: none; color: inherit; display: block; }
.stream-tile:hover { border-color: var(--accent); transform: translateY(-2px); }
.tile-thumb { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; display: block; background: var(--bg); }
.tile-body { padding: 0.75rem; display: flex; gap: 0.6rem; align-items: flex-start; }
.tile-avatar { width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0; object-fit: cover; background: var(--border); }
.tile-info { min-width: 0; flex: 1; }
.tile-title { font-weight: 600; font-size: 0.85rem; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.3; }
.tile-handle { font-family: "JetBrains Mono", monospace; font-size: 0.7rem; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tile-meta { display: flex; align-items: center; gap: 0.35rem; margin-top: 0.25rem; }
.tile-viewers { font-family: "JetBrains Mono", monospace; font-size: 0.65rem; color: var(--text-dim); display: flex; align-items: center; gap: 0.3rem; }
.tile-viewers .viewer-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); }
/* ---- Responsive ---- */ @media (max-width: 800px) { .browse-view { padding: 0 1rem 1rem; } .browse-grid { grid-template-columns: 1fr; } .main-layout { padding: 0 1rem 1rem; } .stream-row { flex-direction: column; } .video-frame { border-radius: 12px 12px 0 0; border-right: 1px solid var(--border); border-bottom: none; } .chat-panel { width: 100%; border-radius: 0 0 12px 12px; max-height: 300px; } .connect-bar { flex-direction: column; padding: 1rem; } .input-wrapper { max-width: none; } .header { padding: 1rem; } .tangled-link .tangled-label { display: none; } } </style> </head> <body> <div class="header"> <div class="logo"> <a href="/" style="text-decoration: none; color: inherit" ><span>▶</span> Bootleg stream.place</a > </div> <a href="https://tangled.org/did:plc:rnpkyqnmsw4ipey6eotbdnnf/bootleg-stream-dot-place" target="_blank" rel="noopener noreferrer" class="tangled-link" > <svg viewBox="0 0 25 25" xmlns="http://www.w3.org/2000/svg"> <g transform="translate(-0.42924038,-0.87777209)"> <path fill="currentColor" style="stroke-width: 0.111183" 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" ></path> </g> </svg> <span class="tangled-label">View on Tangled</span> </a> <div class="auth-controls"> <span class="auth-handle" id="authHandle" style="display: none" ></span> <button class="btn-auth" id="signInBtn" onclick="signIn()"> Sign In </button> <button class="btn-auth" id="signOutBtn" onclick="signOut()" style="display: none" > Sign Out </button> </div> </div>
<div class="connect-bar"> <div class="input-wrapper"> <input type="text" id="username" placeholder="yourfavestreamer.com" spellcheck="false" autocomplete="off" /> </div> <button class="btn btn-connect" id="connectBtn" onclick="connect()"> Connect </button> <button class="btn btn-disconnect" id="disconnectBtn" onclick="disconnect()" > Disconnect </button> </div>
<div class="browse-view" id="browseView"> <div class="browse-header"> <div class="browse-live-count"> <span class="live-pulse"></span> <span id="browseCount">0 streamers live</span> </div> </div> <div class="browse-grid" id="browseGrid"></div> </div>
<div class="main-layout" id="mainLayout"> <div class="stream-info" id="streamInfo"> <div class="stream-info-text"> <div class="stream-title" id="streamTitle"></div> <div class="stream-handle" id="streamHandle"></div> </div> <div class="stream-viewer-count" id="streamViewerCount"> <span class="viewer-dot"></span> <span id="viewerCountNum">0</span> watching </div> </div> <div class="stream-row"> <div class="video-frame"> <video id="video" autoplay playsinline muted></video> <div class="media-controls" id="mediaControls"> <button class="mc-btn" id="playPauseBtn" onclick="togglePlayPause()" title="Play/Pause" > <svg viewBox="0 0 24 24"> <polygon points="6,3 20,12 6,21" /> </svg> </button> <button class="mc-btn" id="muteBtn" onclick="toggleMute()" title="Mute/Unmute" > <svg viewBox="0 0 24 24" id="muteIcon"> <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> </button> <input type="range" class="volume-slider" id="volumeSlider" min="0" max="1" step="0.05" value="1" oninput="setVolume(this.value)" title="Volume" /> <button class="mc-btn" id="fullscreenBtn" onclick="toggleFullscreen()" title="Fullscreen" style="margin-left: auto" > <svg viewBox="0 0 24 24"> <path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/> </svg> </button> </div> <div class="video-overlay" id="overlay"> <div class="idle-icon"> <svg viewBox="0 0 24 24"> <polygon points="5,3 19,12 5,21" /> </svg> </div> <div class="idle-text"> Enter a username to start watching </div> </div> </div>
<div class="chat-panel"> <div class="chat-header"> <div class="chat-ws-dot" id="chatWsDot"></div> <div class="chat-header-title">Chat</div> <div class="chat-header-count" id="chatCount"></div> </div> <div class="chat-messages" id="chatMessages"> <div class="chat-empty" id="chatEmpty"> No messages yet </div> </div> <div class="chat-input" id="chatInput" style="display: none" > <input type="text" id="chatMsgInput" placeholder="Send a message..." autocomplete="off" /> <button id="chatSendBtn" onclick="sendChat()"> Send </button> </div> <div class="chat-signin-prompt" id="chatSigninPrompt"> <span onclick="signIn()">Sign in to chat</span> </div> </div> </div>
<details class="status-details"> <summary> <div class="status-dot" id="statusDot"></div> <div class="status-text" id="statusText">Idle</div> <div class="status-stats" id="statusStats"></div> <span class="toggle-arrow">▶</span> </summary> <div class="log-panel" id="logPanel"></div> </details> </div>
<script type="module"> const production = window.location.host !== "127.0.0.1";
import { configureOAuth, createAuthorizationUrl, finalizeAuthorization, OAuthUserAgent, getSession, deleteStoredSession, } from "https://cdn.jsdelivr.net/npm/@atcute/oauth-browser-client/+esm"; import { Client } from "https://cdn.jsdelivr.net/npm/@atcute/client/+esm"; import { LocalActorResolver, XrpcHandleResolver, CompositeDidDocumentResolver, PlcDidDocumentResolver, WebDidDocumentResolver, CompositeHandleResolver, DohJsonHandleResolver, WellKnownHandleResolver, } from "https://cdn.jsdelivr.net/npm/@atcute/identity-resolver/+esm";
const handleResolver = new CompositeHandleResolver({ methods: { dns: new DohJsonHandleResolver({ dohUrl: "https://cloudflare-dns.com/dns-query", }), http: new WellKnownHandleResolver(), }, });
// ---- State ---- let pc = null; let ws = null; let statsInterval = null; let chatMsgCount = 0; const MAX_CHAT_MESSAGES = 500; // Used to help with ordering chat messages let videoLoadedAt = new Date();
let atClient = null; let agent = null; let loggedInDid = null; let loggedInHandle = null; let currentStreamerDid = null; let handlingPopstate = false;
// ---- DOM refs ---- const video = document.getElementById("video"); const overlay = document.getElementById("overlay"); const statusDot = document.getElementById("statusDot"); const statusText = document.getElementById("statusText"); const statusStats = document.getElementById("statusStats"); const logPanel = document.getElementById("logPanel"); const connectBtn = document.getElementById("connectBtn"); const disconnectBtn = document.getElementById("disconnectBtn"); const usernameInput = document.getElementById("username"); const chatMessages = document.getElementById("chatMessages"); const chatEmpty = document.getElementById("chatEmpty"); const chatWsDot = document.getElementById("chatWsDot"); const chatCount = document.getElementById("chatCount"); const authHandle = document.getElementById("authHandle"); const signInBtn = document.getElementById("signInBtn"); const signOutBtn = document.getElementById("signOutBtn"); const chatInput = document.getElementById("chatInput"); const chatMsgInput = document.getElementById("chatMsgInput"); const chatSendBtn = document.getElementById("chatSendBtn"); const chatSigninPrompt = document.getElementById("chatSigninPrompt"); const streamInfo = document.getElementById("streamInfo"); const streamTitle = document.getElementById("streamTitle"); const streamHandle = document.getElementById("streamHandle"); const viewerCountNum = document.getElementById("viewerCountNum");
// ---- OAuth setup ---- const redirectUri = window.location.origin + window.location.pathname;
const oauthScope = "atproto include:place.stream.authFull"; let clientId; if (production) { clientId = `${window.location.origin}/oauth-client-metadata.json`; } else { clientId = `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}` + `&scope=${encodeURIComponent(oauthScope)}`; } configureOAuth({ metadata: { client_id: clientId, redirect_uri: redirectUri }, identityResolver: new LocalActorResolver({ handleResolver, didDocumentResolver: new CompositeDidDocumentResolver({ methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver(), }, }), }), });
// ---- Auth UI helpers ---- function updateAuthUI() { if (loggedInDid) { authHandle.textContent = `@${loggedInHandle || loggedInDid}`; authHandle.style.display = ""; signInBtn.style.display = "none"; signOutBtn.style.display = ""; chatInput.style.display = ""; chatSigninPrompt.style.display = "none"; } else { authHandle.style.display = "none"; signInBtn.style.display = ""; signOutBtn.style.display = "none"; chatInput.style.display = "none"; chatSigninPrompt.style.display = ""; } }
function setAuthSession(session) { agent = new OAuthUserAgent(session); atClient = new Client({ handler: agent }); loggedInDid = session.info.sub; localStorage.setItem("atproto_did", loggedInDid); // Resolve handle from DID resolveOwnHandle(); }
async function getMiniDoc(identity) { const res = await fetch( `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(identity)}`, ); if (res.ok) { const data = await res.json(); return data; } else { log( `Error resolving the mini doc for: ${identity}`, "error", ); console.error(res); } }
async function resolveOwnHandle() { const miniDoc = await getMiniDoc(loggedInDid); loggedInHandle = miniDoc.handle; updateAuthUI(); }
async function signIn() { const handle = window.prompt( "Enter your atmosphere handle (e.g. jcsalterego.bsky.social):", ); if (!handle) return; try { const authUrl = await createAuthorizationUrl({ target: { type: "account", identifier: handle.trim() }, scope: oauthScope, }); await new Promise((r) => setTimeout(r, 200)); const maybeAprofile = getProfileFromUrl(); if (maybeAprofile) { localStorage.setItem( "last-watched-streamer", maybeAprofile, ); } window.location.assign(authUrl); } catch (err) { log(`Auth error: ${err.message}`, "error"); console.error(err); } }
async function signOut() { try { if (agent) await agent.signOut(); } catch { if (loggedInDid) deleteStoredSession(loggedInDid); } localStorage.removeItem("atproto_did"); atClient = null; agent = null; loggedInDid = null; loggedInHandle = null; updateAuthUI(); log("Signed out"); }
// ---- OAuth callback / session resume on load ---- async function initAuth() { // Check for OAuth callback in hash if (location.hash && location.hash.length > 1) { try { const params = new URLSearchParams( location.hash.slice(1), ); history.replaceState( null, "", location.pathname + location.search, ); const { session } = await finalizeAuthorization(params); setAuthSession(session); log(`Signed in as ${loggedInDid}`, "success"); const maybeAprofile = getProfileFromUrl(); if (maybeAprofile) { localStorage.setItem( "last-watched-streamer", maybeAprofile, ); } return; } catch (err) { // Hash might not be OAuth params, ignore console.warn("OAuth finalize failed:", err); } }
// Try to resume existing session const storedDid = localStorage.getItem("atproto_did"); if (storedDid) { try { const session = await getSession(storedDid, { allowStale: true, }); setAuthSession(session); log(`Session resumed for ${loggedInDid}`, "success"); } catch (err) { localStorage.removeItem("atproto_did"); console.warn("Session resume failed:", err); } } }
// ---- Chat sending ---- async function sendChat() { if (!atClient || !loggedInDid) return;
const text = chatMsgInput.value.trim();
if (!text) return; if (!currentStreamerDid) { log("Cannot send: streamer DID not resolved", "error"); return; }
chatSendBtn.disabled = true; try { await atClient.post("com.atproto.repo.createRecord", { input: { repo: loggedInDid, collection: "place.stream.chat.message", record: { $type: "place.stream.chat.message", text: text, streamer: currentStreamerDid, createdAt: new Date().toISOString(), }, }, }); chatMsgInput.value = ""; } catch (err) { log(`Send failed: ${err.message}`, "error"); console.error(err); } finally { chatSendBtn.disabled = false; chatMsgInput.focus(); } }
// ---- Streamer DID resolution ---- async function resolveStreamerDid(handle) { try { const streamersMiniDoc = await getMiniDoc(handle); currentStreamerDid = streamersMiniDoc.did; log(`Streamer DID: ${currentStreamerDid}`); } catch { log("Streamer DID resolution failed", "error"); currentStreamerDid = null; } }
// ---- Event listeners ---- usernameInput.addEventListener("keydown", (e) => { if (e.key === "Enter") connect(); });
chatMsgInput.addEventListener("keydown", (e) => { if (e.key === "Enter") sendChat(); });
// ---- Media controls ---- const playPauseBtn = document.getElementById("playPauseBtn"); const muteBtn = document.getElementById("muteBtn"); const volumeSlider = document.getElementById("volumeSlider"); const fullscreenBtn = document.getElementById("fullscreenBtn"); const videoFrame = document.querySelector(".video-frame"); let savedVolume = 1;
const iconPlay = '<svg viewBox="0 0 24 24"><polygon points="6,3 20,12 6,21" /></svg>'; const iconPause = '<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>'; const iconVolume = '<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>'; const iconMuted = '<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>'; const iconFullscreen = '<svg viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>'; const iconExitFullscreen = '<svg viewBox="0 0 24 24"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>';
function updatePlayPauseIcon() { playPauseBtn.innerHTML = video.paused ? iconPlay : iconPause; }
function updateMuteIcon() { muteBtn.innerHTML = video.muted || video.volume === 0 ? iconMuted : iconVolume; }
function togglePlayPause() { if (video.paused) { video.play().catch(() => {}); } else { video.pause(); } }
function toggleMute() { if (video.muted) { video.muted = false; video.volume = savedVolume || 1; volumeSlider.value = video.volume; } else { savedVolume = video.volume; video.muted = true; volumeSlider.value = 0; } updateMuteIcon(); }
function toggleFullscreen() { // iOS Safari only supports fullscreen on <video> via webkit prefix if (video.webkitEnterFullscreen && !document.fullscreenEnabled) { video.webkitEnterFullscreen(); return; } if (!document.fullscreenElement) { videoFrame.requestFullscreen().catch((err) => { log(`Fullscreen error: ${err.message}`, "error"); }); } else { document.exitFullscreen(); } }
function updateFullscreenIcon() { fullscreenBtn.innerHTML = document.fullscreenElement ? iconExitFullscreen : iconFullscreen; }
document.addEventListener("fullscreenchange", updateFullscreenIcon); video.addEventListener("webkitendfullscreen", updateFullscreenIcon);
function setVolume(val) { val = parseFloat(val); video.volume = val; if (val > 0 && video.muted) { video.muted = false; } savedVolume = val > 0 ? val : savedVolume; updateMuteIcon(); }
video.addEventListener("play", updatePlayPauseIcon); video.addEventListener("pause", updatePlayPauseIcon); video.addEventListener("volumechange", () => { updateMuteIcon(); if (!video.muted) { volumeSlider.value = video.volume; } });
// ---- Logging / status ---- function log(msg, type = "") { const line = document.createElement("div"); line.className = "log-line" + (type ? ` ${type}` : ""); const ts = new Date().toLocaleTimeString("en-US", { hour12: false, }); line.textContent = `${ts} ${msg}`; logPanel.appendChild(line); logPanel.scrollTop = logPanel.scrollHeight; }
function setStatus(text, state = "") { statusText.textContent = text; statusDot.className = "status-dot" + (state ? ` ${state}` : ""); }
// ---- Chat WebSocket ---- function connectChat(username) { if (ws) { ws.close(); ws = null; }
const wsUrl = `wss://stream.place/api/websocket/${encodeURIComponent(username)}`; log(`Chat WS: ${wsUrl}`);
ws = new WebSocket(wsUrl);
ws.onopen = () => { log("Chat connected", "success"); chatWsDot.classList.add("connected"); };
ws.onclose = (e) => { log(`Chat disconnected (code ${e.code})`); chatWsDot.classList.remove("connected"); };
ws.onerror = () => { log("Chat WebSocket error", "error"); chatWsDot.classList.remove("connected"); };
ws.onmessage = (event) => { try { const data = JSON.parse(event.data); if ( data.$type === "place.stream.chat.defs#messageView" ) { appendChatMessage(data); } else if ( data.$type === "place.stream.livestream#livestreamView" ) { const title = data.record?.title || ""; const handle = data.author?.handle || ""; streamTitle.textContent = title; streamHandle.textContent = handle ? `@${handle}` : ""; streamInfo.classList.add("visible"); } else if ( data.$type === "place.stream.livestream#viewerCount" ) { viewerCountNum.textContent = data.count ?? 0; } } catch { // Ignore non-JSON or unknown message types } }; }
function disconnectChat() { if (ws) { ws.close(); ws = null; } chatWsDot.classList.remove("connected"); streamInfo.classList.remove("visible"); streamTitle.textContent = ""; streamHandle.textContent = ""; viewerCountNum.textContent = "0"; }
function appendChatMessage(data) { chatEmpty.style.display = "none";
const handle = data.author?.handle || "unknown"; const text = data.record?.text || ""; const color = data.chatProfile?.color; const indexedAt = data.indexedAt;
let authorColor = "#4ade80"; if (color && color.red !== undefined) { authorColor = `rgb(${color.red}, ${color.green}, ${color.blue})`; }
let timeStr = ""; // if (indexedAt) { const indexedAtDate = new Date(indexedAt); timeStr = indexedAtDate.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit", }); // }
const msgEl = document.createElement("div"); msgEl.className = "chat-msg";
if (data.uri) { msgEl.dataset.uri = data.uri; } if (data.record?.reply?.parent?.uri) { msgEl.dataset.parentUri = data.record.reply.parent.uri; } if (data.record?.reply?.root?.uri) { msgEl.dataset.rootUri = data.record.reply.root.uri; }
// Reply preview if (data.replyTo) { const replyPreview = document.createElement("div"); replyPreview.className = "chat-reply-preview";
const replyAuthor = document.createElement("span"); replyAuthor.className = "chat-reply-author"; const replyHandle = data.replyTo.author?.handle || "unknown"; const replyColor = data.replyTo.chatProfile?.color; let replyAuthorColor = "#4ade80"; if (replyColor && replyColor.red !== undefined) { replyAuthorColor = `rgb(${replyColor.red}, ${replyColor.green}, ${replyColor.blue})`; } replyAuthor.style.color = replyAuthorColor; replyAuthor.textContent = replyHandle;
const replyText = document.createElement("span"); replyText.className = "chat-reply-text"; const parentText = data.replyTo.record?.text || ""; replyText.textContent = parentText.length > 80 ? parentText.slice(0, 80) + "…" : parentText;
replyPreview.appendChild(replyAuthor); replyPreview.appendChild(replyText);
// Click to scroll to parent message if (data.replyTo.uri) { replyPreview.dataset.uri = data.replyTo.uri; replyPreview.addEventListener("click", () => { const parent = chatMessages.querySelector( `[data-uri="${CSS.escape(data.replyTo.uri)}"]` ); if (parent) { parent.scrollIntoView({ behavior: "smooth", block: "center" }); parent.style.background = "#ffffff12"; setTimeout(() => { parent.style.background = ""; }, 1500); } }); }
msgEl.appendChild(replyPreview); }
const msgContent = document.createElement("div");
const authorSpan = document.createElement("span"); authorSpan.className = "chat-msg-author"; authorSpan.style.color = authorColor; authorSpan.textContent = handle;
const textSpan = document.createElement("span"); textSpan.className = "chat-msg-text"; textSpan.textContent = text;
const timeSpan = document.createElement("span"); timeSpan.className = "chat-msg-time"; timeSpan.textContent = timeStr;
msgContent.appendChild(timeSpan); msgContent.appendChild(authorSpan); msgContent.appendChild(textSpan);
msgEl.appendChild(msgContent);
if (indexedAtDate < videoLoadedAt) { chatMessages.appendChild(msgEl); } else { chatMessages.prepend(msgEl); }
chatMsgCount++;
while (chatMessages.children.length > MAX_CHAT_MESSAGES + 1) { const last = chatMessages.lastElementChild; if (last && last !== chatEmpty) { last.remove(); } else { break; } }
chatCount.textContent = `${chatMsgCount} msgs`;
// if (isAtNewest) { // chatMessages.scrollTop = 0; // } }
// ---- WebRTC ---- async function connect() { const streamersHandle = usernameInput.value.trim(); if (!streamersHandle) { usernameInput.focus(); return; }
if (pc) disconnect();
chatMsgCount = 0; chatCount.textContent = ""; chatEmpty.style.display = ""; const existingMsgs = chatMessages.querySelectorAll(".chat-msg"); existingMsgs.forEach((m) => m.remove());
const whepUrl = `https://stream.place/api/playback/${encodeURIComponent(streamersHandle)}/webrtc?rendition=source`;
setStatus("Connecting\u2026"); log(`WHEP endpoint: ${whepUrl}`);
connectBtn.style.display = "none"; disconnectBtn.style.display = "";
connectChat(streamersHandle); await resolveStreamerDid(streamersHandle);
try { pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }], bundlePolicy: "max-bundle", });
pc.addTransceiver("video", { direction: "recvonly" }); pc.addTransceiver("audio", { direction: "recvonly" });
pc.ontrack = (event) => { log(`Track received: ${event.track.kind}`, "success"); if (event.streams && event.streams[0]) { video.srcObject = event.streams[0]; } else { if (!video.srcObject) { video.srcObject = new MediaStream(); } video.srcObject.addTrack(event.track); } overlay.classList.add("hidden"); setStatus("Live", "live"); video.play().catch(() => {}); };
pc.oniceconnectionstatechange = () => { log(`PeerConnection: ${pc.iceConnectionState}`); if ( pc.iceConnectionState === "connected" || pc.iceConnectionState === "completed" ) { //This is when the video is successfully loaded videoLoadedAt = new Date();
if (!handlingPopstate) { window.history.pushState( {}, "", `/${streamersHandle}`, ); } setStatus("Live", "live"); startStats(); } else if ( pc.iceConnectionState === "failed" || pc.iceConnectionState === "disconnected" ) { setStatus("Disconnected", "error"); log("Connection lost", "error"); stopStats(); } };
pc.onconnectionstatechange = () => { log(`Connection: ${pc.connectionState}`); if (pc.connectionState === "failed") { setStatus("Failed", "error"); log("PeerConnection failed", "error"); stopStats(); } };
const offer = await pc.createOffer(); await pc.setLocalDescription(offer); await waitForIceGathering(pc, 2000);
log("Sending SDP offer\u2026");
const resp = await fetch(whepUrl, { method: "POST", headers: { "Content-Type": "application/sdp" }, body: pc.localDescription.sdp, });
if (!resp.ok) { const errText = await resp.text(); throw new Error(`WHEP ${resp.status}: ${errText}`); }
const answerSdp = await resp.text(); log("Received SDP answer", "success");
await pc.setRemoteDescription({ type: "answer", sdp: answerSdp, }); log("Remote description set, waiting for media\u2026"); } catch (err) { log(`Error: ${err.message}`, "error"); setStatus("Error", "error"); console.error(err); } }
function waitForIceGathering(peerConnection, timeout) { return new Promise((resolve) => { if (peerConnection.iceGatheringState === "complete") { resolve(); return; } const timer = setTimeout(() => { log( "PeerConnection gathering timed out, proceeding with candidates", ); resolve(); }, timeout);
peerConnection.onicegatheringstatechange = () => { if (peerConnection.iceGatheringState === "complete") { clearTimeout(timer); log("PeerConnection gathering complete"); resolve(); } }; }); }
function teardownStream() { stopStats(); disconnectChat(); if (pc) { pc.close(); pc = null; } currentStreamerDid = null; video.srcObject = null; overlay.classList.remove("hidden"); setStatus("Idle"); statusStats.textContent = ""; connectBtn.style.display = ""; disconnectBtn.style.display = "none"; }
function disconnect() { teardownStream(); log("Disconnected"); window.history.pushState({}, "", "/"); showBrowseView(); }
function startStats() { stopStats(); statsInterval = setInterval(async () => { if (!pc) return; try { const stats = await pc.getStats(); let resolution = ""; stats.forEach((report) => { if ( report.type === "inbound-rtp" && report.kind === "video" ) { if (report.frameWidth && report.frameHeight) { resolution = `${report.frameWidth}\u00d7${report.frameHeight}`; } } }); const parts = []; if (resolution) parts.push(resolution); statusStats.textContent = parts.join(" \u00b7 "); } catch {} }, 2000); }
function stopStats() { if (statsInterval) { clearInterval(statsInterval); statsInterval = null; } }
function getProfileFromUrl() { const path = window.location.pathname; const pathSplit = path.split("/"); if (pathSplit.length > 1) { const maybeAprofile = pathSplit[1]; if (maybeAprofile !== "") { return maybeAprofile; } } }
function urlProfileWatch() { const maybeAprofile = getProfileFromUrl(); if (maybeAprofile) { usernameInput.value = maybeAprofile; connect(); } else { showBrowseView(); } }
// ---- Browse view (live streamers directory) ---- const browseView = document.getElementById("browseView"); const browseGrid = document.getElementById("browseGrid"); const browseCount = document.getElementById("browseCount"); const mainLayout = document.getElementById("mainLayout");
function getThumbnailUrl(did, thumb) { if (!thumb || !thumb.ref || !thumb.ref.$link) return ""; return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${thumb.ref.$link}@jpeg`; }
async function fetchLiveUsers() { try { const res = await fetch( "https://stream.place/xrpc/place.stream.live.getLiveUsers", ); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); return data.streams || []; } catch (err) { console.error("Failed to fetch live users:", err); return []; } }
async function fetchAvatars(handles) { if (!handles.length) return {}; try { // getProfiles is just TOO convient const params = handles .map((h) => `actors=${encodeURIComponent(h)}`) .join("&"); const res = await fetch( `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles?${params}`, ); if (!res.ok) return {}; const data = await res.json(); const map = {}; for (const profile of data.profiles || []) { if (profile.avatar) { map[profile.handle] = profile.avatar; } } return map; } catch { return {}; } }
function renderBrowseView(streams, avatarMap) { browseGrid.replaceChildren(); const total = streams.length; browseCount.textContent = `${total} streamer${total !== 1 ? "s" : ""} live`;
for (const stream of streams) { const handle = stream.author?.handle || "unknown"; const did = stream.author?.did || ""; const title = stream.record?.title || "Untitled stream"; const viewers = stream.viewerCount?.count ?? 0; const thumbUrl = getThumbnailUrl(did, stream.record?.thumb); const avatarUrl = avatarMap[handle] || "";
const tile = document.createElement("a"); tile.className = "stream-tile"; tile.href = `/${handle}`; tile.onclick = (e) => { e.preventDefault(); browseView.classList.remove("visible"); mainLayout.style.display = ""; usernameInput.value = handle; connect(); };
if (thumbUrl) { const thumbImg = document.createElement("img"); thumbImg.className = "tile-thumb"; thumbImg.src = thumbUrl; thumbImg.alt = ""; thumbImg.loading = "lazy"; thumbImg.onerror = () => { thumbImg.style.display = "none"; }; tile.appendChild(thumbImg); } else { const thumbDiv = document.createElement("div"); thumbDiv.className = "tile-thumb"; tile.appendChild(thumbDiv); }
const tileBody = document.createElement("div"); tileBody.className = "tile-body";
if (avatarUrl) { const avatarImg = document.createElement("img"); avatarImg.className = "tile-avatar"; avatarImg.src = avatarUrl; avatarImg.alt = ""; avatarImg.loading = "lazy"; avatarImg.onerror = () => { avatarImg.style.display = "none"; }; tileBody.appendChild(avatarImg); } else { const avatarDiv = document.createElement("div"); avatarDiv.className = "tile-avatar"; tileBody.appendChild(avatarDiv); }
const tileInfo = document.createElement("div"); tileInfo.className = "tile-info";
const tileTitle = document.createElement("div"); tileTitle.className = "tile-title"; tileTitle.textContent = title;
const tileHandle = document.createElement("div"); tileHandle.className = "tile-handle"; tileHandle.textContent = `@${handle}`;
const tileMeta = document.createElement("div"); tileMeta.className = "tile-meta";
const tileViewers = document.createElement("div"); tileViewers.className = "tile-viewers";
const viewerDot = document.createElement("span"); viewerDot.className = "viewer-dot"; tileViewers.appendChild(viewerDot); tileViewers.appendChild(document.createTextNode(`${viewers} watching`));
tileMeta.appendChild(tileViewers); tileInfo.appendChild(tileTitle); tileInfo.appendChild(tileHandle); tileInfo.appendChild(tileMeta); tileBody.appendChild(tileInfo); tile.appendChild(tileBody);
browseGrid.appendChild(tile); } }
async function showBrowseView() { mainLayout.style.display = "none"; browseView.classList.add("visible");
const streams = await fetchLiveUsers(); // Sort by viewer count descending streams.sort( (a, b) => (b.viewerCount?.count ?? 0) - (a.viewerCount?.count ?? 0), );
const handles = streams .map((s) => s.author?.handle) .filter(Boolean); const avatarMap = await fetchAvatars(handles);
renderBrowseView(streams, avatarMap); }
// ---- Expose to onclick handlers ---- window.connect = connect; window.disconnect = disconnect; window.signIn = signIn; window.signOut = signOut; window.sendChat = sendChat; window.togglePlayPause = togglePlayPause; window.toggleMute = toggleMute; window.setVolume = setVolume; window.toggleFullscreen = toggleFullscreen;
// ---- History navigation ---- window.addEventListener("popstate", () => { handlingPopstate = true; const profile = getProfileFromUrl(); if (profile) { if (pc) teardownStream(); browseView.classList.remove("visible"); mainLayout.style.display = ""; usernameInput.value = profile; connect(); } else { if (pc) teardownStream(); showBrowseView(); } handlingPopstate = false; });
// ---- Init ---- updateAuthUI(); initAuth(); urlProfileWatch(); </script> </body></html>