1<!DOCTYPE html> 2<html lang="en"> 3<head> 4 <meta charset="UTF-8"/> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/> 6 <title>Coviyo . Watch Together</title> 7 <link rel="stylesheet" href="/static/theme.css?v=1"> 8 <link rel="stylesheet" href="static/style.css?v=1900475.1900475"> 9 <link rel="preconnect" href="https://fonts.googleapis.com"> 10<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 11<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap"> 12<link rel="stylesheet" href="/static/vs-header-compat.css?v=7"> 13 <!-- Needed for #room-edit-overlay's actual appearance -- same file 14 watch.html and crooms-chat.html both rely on for this; without it 15 the overlay renders but with none of its real styling (confirmed 16 root cause of that exact symptom earlier this session). --> 17 <link rel="stylesheet" href="/static/watch-overlays.css?v=1"> 18 <link rel="stylesheet" href="/static/crooms-chat-premium.css?v=1"> 19 <link rel="preconnect" href="https://fonts.googleapis.com"> 20 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 21<!-- Loaded so this page can reuse croom.js's actual editRoomName()/ 22 saveRoomEdit()/closeRoomEdit() etc. directly -- the same, real edit 23 overlay logic crooms-chat.html uses, not a rewritten duplicate. 24 Checked directly for collision risk first (the concern that ruled 25 out this same approach for the earlier Join Party feature): only 26 one top-level name collided (escapeHtml), and this page's own 27 version was renamed to _escapeHtmlLocal with its call sites 28 updated, since it behaves differently for null input than 29 croom.js's version does and silently losing that difference would 30 have been a real, unintended behavior change. No other function or 31 top-level variable names collide. --> 32
32<script src="/static/croom.js?v=1900478.1900478" defer></script>
32 33 <style> 34 /* ========================================================= 35 VIZSURGE INSTANT â SOCIAL / PLAYFUL MOBILE-FIRST UI 36 Page-scoped styles. Existing JS/IDs intentionally preserved. 37 ========================================================= */ 38 :root { 39 --instant-bg: #070713; 40 --instant-bg-2: #0d0b22; 41 --instant-surface: rgba(18, 17, 43, .72); 42 --instant-surface-2: rgba(28, 24, 62, .66); 43 --instant-border: rgba(157, 126, 255, .20); 44 --instant-border-strong: rgba(137, 207, 255, .34); 45 --instant-text: #f8f7ff; 46 --instant-muted: #a8a5bf; 47 --instant-dim: #706d86; 48 --instant-purple: var(--theme-accent); 49 --instant-violet: #b46cff; 50 --instant-blue: #4f8cff; 51 --instant-cyan: #45d7ff; 52 --instant-pink: #ff5ec4; 53 --instant-green: #52e3a5; 54 --instant-shadow: 0 22px 70px rgba(0,0,0,.34); 55 } 56 57 *, *::before, *::after { box-sizing:border-box; } 58 html, body { min-height:100%; overflow-x:hidden; } 59 body.instant-page, body.index-page { 60 margin:0; 61 color:var(--instant-text); 62 font-family:Manrope, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 63 background: 64 radial-gradient(700px 430px at 8% -4%, rgba(112,74,255,.28), transparent 68%), 65 radial-gradient(600px 420px at 94% 8%, rgba(41,194,255,.17), transparent 67%), 66 radial-gradient(620px 500px at 52% 72%, rgba(213,55,177,.08), transparent 72%), 67 linear-gradient(180deg, #080817 0%, #070713 46%, #05050e 100%); 68 -webkit-font-smoothing:antialiased; 69 padding-bottom:calc(84px + env(safe-area-inset-bottom, 0px)); 70 } 71 72 body.instant-page::before { 73 content:""; 74 position:fixed; inset:0; pointer-events:none; z-index:0; 75 background-image:radial-gradient(rgba(255,255,255,.055) 1px, transparent 1px); 76 background-size:22px 22px; 77 mask-image:linear-gradient(to bottom, rgba(0,0,0,.28), transparent 52%); 78 opacity:.18; 79 } 80 81 body.instant-page .wrap { 82 position:relative; z-index:1; 83 width:min(100% - 32px, 820px); 84 max-width:820px; 85 margin:0 auto; 86 padding:28px 0 42px; 87 display:flex; flex-direction:column; 88 } 89 90 /* ---------- HERO ---------- */ 91 .instant-hero { position:relative; padding:14px 2px 26px; } 92 .instant-hero::after { 93 content:""; position:absolute; width:180px; height:180px; right:-100px; top:-80px; 94 border-radius:50%; background:rgba(116,78,255,.18); filter:blur(45px); pointer-events:none; 95 } 96 .instant-kicker { 97 display:inline-flex; align-items:center; gap:8px; 98 padding:7px 11px 7px 9px; border-radius:999px; 99 border:1px solid rgba(111,215,255,.22); 100 background:linear-gradient(135deg, rgba(88,83,255,.16), rgba(51,211,255,.09)); 101 box-shadow:inset 0 1px 0 rgba(255,255,255,.08), 0 8px 28px rgba(61,83,255,.08); 102 color:#d9d7ff; font-size:10px; font-weight:800; letter-spacing:.12em; text-transform:uppercase; 103 } 104 .instant-kicker .dot { width:7px; height:7px; border-radius:50%; background:var(--instant-cyan); box-shadow:0 0 14px var(--instant-cyan); animation:instantPulse 1.9s ease-in-out infinite; } 105 .instant-title { 106 margin:15px 0 8px; font-size:clamp(34px, 6vw, 56px); line-height:.98; 107 letter-spacing:-.055em; font-weight:800; 108 background:linear-gradient(100deg, #fff 6%, #dcd5ff 42%, #91ddff 90%); 109 -webkit-background-clip:text; background-clip:text; color:transparent; 110 } 111 .instant-title .spark { color:var(--instant-violet); -webkit-text-fill-color:var(--instant-violet); } 112 .instant-sub { max-width:650px; color:var(--instant-muted); font-size:15px; line-height:1.55; margin:0; } 113 .instant-sub strong { color:#e8e4ff; font-weight:700; } 114 115 /* ---------- CREATE GRID ---------- */ 116 /* Video URL spans the full row on its own (still the primary/most-used 117 path); Local File, Live Stream and Screen Share sit as three equal 118 cards beneath it. Was a single 3-column row before Screen Share was 119 added â a 4-across row got too cramped, so it's now 2 rows instead. */ 120 .instant-create-grid { display:grid; grid-template-columns:repeat(3, 1fr); gap:13px; } 121 122 /* ââ Join Party ââââââââââââââââââââââââââââââââââââââââââ */ 123 .join-party-card { 124 width:100%; margin: 0 0 16px; min-width:0; 125 padding:14px; border-radius:17px; 126 background:linear-gradient(145deg, rgba(19,18,43,.78), rgba(10,10,25,.72)); 127 border:1px solid rgba(151,126,255,.14); 128 box-shadow:inset 0 1px 0 rgba(255,255,255,.045), 0 10px 30px rgba(0,0,0,.18); 129 } 130 .join-party-row { display: flex; gap: 10px; } 131 .join-party-row + .join-party-row { margin-top: 10px; } 132 .join-party-input { 133 flex: 1; height: 48px; padding: 0 16px; min-width:0; 134 border-radius: 11px; border: 1px solid rgba(255,255,255,.07); 135 background: rgba(255,255,255,.035); color: var(--instant-text); 136 font-size: 14px; font-family: inherit; 137 } 138 .join-party-input::placeholder { color: var(--instant-dim); } 139 .join-party-input:focus { outline: none; border-color: var(--theme-accent); } 140 .join-party-btn { 141 height: 48px; padding: 0 24px; border-radius: 11px; border: none; 142 background: var(--theme-accent); color: #fff; font-weight: 700; 143 font-size: 14px; cursor: pointer; white-space: nowrap; 144 font-family: inherit; transition: opacity .15s; flex-shrink:0; 145 } 146 .join-party-btn:hover { opacity: .88; } 147 .join-party-btn:disabled { opacity: .55; cursor: default; } 148 .join-party-error { margin-top: 8px; font-size: 12.5px; color: #f87171; } 149 150 /* ââ Create Party teaser (collapsed by default) ââââââââââââ */ 151 .create-party-wrap { margin-bottom: 22px; } 152 .create-party-privacy-note { 153 font-size: 12px; color: var(--instant-muted); 154 padding: 10px 13px; margin: 10px 0; 155 border-radius: 10px; 156 background: rgba(139,92,246,.08); 157 border: 1px solid rgba(139,92,246,.16); 158 } 159 .create-party-teaser { 160 width:100%; min-width:0; 161 display: flex; align-items: center; justify-content: space-between; 162 gap: 12px; padding:14px; border-radius:17px; 163 background:linear-gradient(145deg, rgba(19,18,43,.78), rgba(10,10,25,.72)); 164 border:1px solid rgba(151,126,255,.14); 165 box-shadow:inset 0 1px 0 rgba(255,255,255,.045), 0 10px 30px rgba(0,0,0,.18); 166 cursor: pointer; text-align: left; font-family: inherit; 167 transition: transform .2s, border-color .2s; 168 } 169 .create-party-teaser:hover { transform:translateY(-2px); border-color:rgba(92,206,255,.28); }
170 .create-party-teaser-text { display: flex; flex-direction: column; gap: 3px; min-width: 0; } 171 .create-party-teaser-title { font-size: 15px; font-weight: 700; color: var(--instant-text); } 172 .create-party-teaser-sub { font-size: 12.5px; color: var(--instant-muted); } 173 .create-party-teaser-icon { 174 flex-shrink: 0; width: 30px; height: 30px; border-radius: 50%; 175 display: flex; align-items: center; justify-content: center; 176 background: rgba(139,92,246,.14); color: var(--theme-accent); 177 font-size: 18px; font-weight: 600; line-height: 1; 178 transition: transform .2s; 179 } 180 .create-party-teaser[aria-expanded="true"] .create-party-teaser-icon { transform: rotate(45deg); } 181 .create-party-wrap .instant-create-grid { margin-top: 12px; } 182 .party-type-card { 183 position:relative; overflow:hidden; min-width:0; margin:0; 184 padding:19px; border-radius:22px; 185 background:linear-gradient(145deg, rgba(24,21,53,.82), rgba(11,11,28,.76)); 186 border:1px solid var(--instant-border); 187 box-shadow:var(--instant-shadow), inset 0 1px 0 rgba(255,255,255,.055); 188 backdrop-filter:blur(18px); -webkit-backdrop-filter:blur(18px); 189 transition:transform .25s ease, border-color .25s ease, box-shadow .25s ease; 190 } 191 .party-type-card::before { 192 content:""; position:absolute; left:-45px; top:-70px; width:160px; height:150px; 193 border-radius:50%; background:rgba(116,86,255,.16); filter:blur(38px); pointer-events:none; 194 } 195 .party-type-card::after { 196 content:""; position:absolute; inset:0; pointer-events:none; 197 background:linear-gradient(120deg, rgba(255,255,255,.06), transparent 24%, transparent 72%, rgba(69,215,255,.035)); 198 } 199 .party-type-card:hover { transform:translateY(-3px); border-color:var(--instant-border-strong); box-shadow:0 26px 80px rgba(0,0,0,.4), 0 0 38px rgba(101,77,255,.09), inset 0 1px 0 rgba(255,255,255,.08); } 200 .party-type-card:first-child { grid-column:1 / -1; } 201 .party-type-card .party-type-head { position:relative; z-index:1; display:flex; align-items:center; gap:10px; margin-bottom:11px; } 202 .party-type-icon { 203 width:36px; height:36px; flex:0 0 36px; display:grid; place-items:center; 204 border-radius:12px; font-size:18px; 205 background:linear-gradient(145deg, rgba(129,95,255,.26), rgba(48,190,255,.11)); 206 border:1px solid rgba(145,123,255,.24); 207 box-shadow:inset 0 1px 0 rgba(255,255,255,.09), 0 8px 22px rgba(85,65,255,.12); 208 } 209 .party-type-card:nth-child(2) .party-type-icon { background:linear-gradient(145deg, rgba(62,196,255,.20), rgba(90,87,255,.14)); } 210 .party-type-card:nth-child(3) .party-type-icon { background:linear-gradient(145deg, rgba(255,91,190,.19), rgba(126,79,255,.16)); } 211 .party-type-card:nth-child(4) .party-type-icon { background:linear-gradient(145deg, rgba(82,227,165,.20), rgba(69,215,255,.14)); } 212 .party-type-title { font-family:Manrope,sans-serif; font-size:15px; font-weight:800; letter-spacing:-.015em; } 213 .party-type-desc, .hint { position:relative; z-index:1; color:var(--instant-muted); font-size:12px; line-height:1.48; } 214 .party-type-desc { min-height:54px; margin-bottom:13px; } 215 .hint { margin-top:11px; color:#85819c; } 216 217 /* ---------- INPUT / BUTTONS ---------- */ 218 .create-row { position:relative; z-index:1; display:flex; gap:9px; align-items:stretch; } 219 #videoUrlInput { 220 width:100%; min-width:0; height:48px; padding:0 15px; 221 color:#fff; font:500 13px Manrope,sans-serif; 222 border-radius:14px; border:1px solid rgba(159,139,255,.24); 223 background:rgba(5,5,18,.48); 224 box-shadow:inset 0 1px 0 rgba(255,255,255,.04), 0 8px 20px rgba(0,0,0,.12); 225 transition:border-color .2s, box-shadow .2s, background .2s; 226 } 227 #videoUrlInput::placeholder { color:#77738d; } 228 #videoUrlInput:focus { outline:none; border-color:rgba(80,211,255,.52); background:rgba(9,8,27,.72); box-shadow:0 0 0 4px rgba(69,215,255,.07), 0 12px 30px rgba(0,0,0,.2); } 229 230 #createBtn, #deviceShareBtn, #liveStreamBtn, #screenShareBtn, #joinNowBtn { 231 border:0; cursor:pointer; color:#fff; font:800 12.5px Manrope,sans-serif; 232 border-radius:13px; white-space:nowrap; 233 background:linear-gradient(135deg, #7b52ff 0%, #9b5cff 46%, #4c9fff 100%); 234 box-shadow:0 9px 26px rgba(91,67,255,.28), inset 0 1px 0 rgba(255,255,255,.22); 235 transition:transform .18s ease, filter .18s ease, box-shadow .18s ease; 236 } 237 #createBtn { height:48px; padding:0 20px; } 238 #deviceShareBtn, #liveStreamBtn, #screenShareBtn { position:relative; z-index:1; height:43px; width:100%; padding:0 15px; } 239 #deviceShareBtn { background:linear-gradient(135deg, #3c9dff, #5c6dff 55%, #8456ff); } 240 #liveStreamBtn { background:linear-gradient(135deg, #ff4fac, #a85cff 56%, #655fff); } 241 #screenShareBtn { background:linear-gradient(135deg, #34d399, #22c1c3 55%, #4f8cff); } 242 #createBtn:hover, #deviceShareBtn:hover, #liveStreamBtn:hover, #screenShareBtn:hover, #joinNowBtn:hover { filter:brightness(1.08); transform:translateY(-2px); box-shadow:0 13px 32px rgba(91,67,255,.34), inset 0 1px 0 rgba(255,255,255,.25); } 243 #createBtn:active, #deviceShareBtn:active, #liveStreamBtn:active, #screenShareBtn:active, #joinNowBtn:active { transform:translateY(0) scale(.98); } 244 #createBtn:disabled, #deviceShareBtn:disabled, #liveStreamBtn:disabled, #
244screenShareBtn:disabled { opacity:.55; cursor:default; transform:none; filter:none; } 245 #deviceStatus, #liveStreamStatus, #screenShareStatus { position:relative; z-index:1; display:none; margin-top:9px; color:#aaa6c1; font-size:11px; line-height:1.4; } 246 247 /* ---------- CREATE SHEET: icon picker, one panel at a time ---------- */ 248 .create-sheet { 249 margin-top:10px; padding:18px 14px 14px; border-radius:17px; 250 background:linear-gradient(145deg, rgba(19,18,43,.78), rgba(10,10,25,.72)); 251 border:1px solid rgba(151,126,255,.14); 252 box-shadow:inset 0 1px 0 rgba(255,255,255,.045), 0 10px 30px rgba(0,0,0,.18); 253 animation:srcSheetIn .24s ease both; 254 } 255 .create-sheet[hidden] { display:none; } 256 257 .src-picker { display:flex; justify-content:center; gap:8px; } 258 .src-tab { 259 --c1:#7b52ff; --c2:#4c9fff; --tint:#a996ff; --glow:rgba(91,67,255,.42); 260 flex:1 1 0; min-width:0; max-width:104px; 261 display:flex; flex-direction:column; align-items:center; gap:8px; 262 padding:2px 0; margin:0; background:none; border:0; cursor:pointer; 263 color:var(--instant-muted); font:700 11px Manrope,sans-serif; letter-spacing:.01em; 264 -webkit-tap-highlight-color:transparent; transition:opacity .18s ease, color .18s ease; 265 } 266 .src-tab[hidden] { display:none; } 267 .src-tab[data-src="browse"] { --c1:#22c1c3; --c2:#4f8cff; --tint:#5fe3e6; --glow:rgba(34,193,195,.38); } 268 .src-tab[data-src="file"] { --c1:#3c9dff; --c2:#8456ff; --tint:#7db8ff; --glow:rgba(76,120,255,.42); } 269 .src-tab[data-src="live"] { --c1:#ff4fac; --c2:#655fff; --tint:#ff86c8; --glow:rgba(255,79,172,.40); } 270 .src-tab[data-src="screen"] { --c1:#34d399; --c2:#4f8cff; --tint:#5eead4; --glow:rgba(52,211,153,.36); } 271 .src-ico { 272 width:54px; height:54px; border-radius:18px; display:grid; place-items:center; 273 color:var(--tint); 274 background:rgba(255,255,255,.04); 275 border:1px solid rgba(255,255,255,.09); 276 box-shadow:inset 0 1px 0 rgba(255,255,255,.05); 277 transition:transform .18s ease, background .18s ease, box-shadow .18s ease, color .18s ease, border-color .18s ease; 278 } 279 .src-ico svg { width:24px; height:24px; } 280 .src-tab:hover .src-ico { transform:translateY(-2px); border-color:rgba(255,255,255,.18); } 281 .src-tab:active .src-ico { transform:scale(.94); } 282 .src-tab:focus-visible { outline:none; } 283 .src-tab:focus-visible .src-ico { outline:2px solid #7ee0ff; outline-offset:2px; } 284 .src-tab.is-active { color:#fff; } 285 .src-tab.is-active .src-ico { 286 color:#fff; border-color:transparent; 287 background:linear-gradient(135deg, var(--c1), var(--c2)); 288 box-shadow:0 10px 26px var(--glow), inset 0 1px 0 rgba(255,255,255,.28); 289 transform:translateY(-2px); 290 } 291 .src-picker.has-active .src-tab:not(.is-active) { opacity:.6; } 292 .src-picker.has-active .src-tab:not(.is-active):hover { opacity:1; } 293 .src-tab.is-desktop-only .src-ico { opacity:.55; } 294 295 .src-panels { margin-top:18px; } 296 .src-panel { padding:0 2px; animation:srcPanelIn .22s ease both; } 297 .src-panel[hidden] { display:none; } 298 .src-panel-desc { margin:0; color:var(--instant-muted); font-size:12.5px; line-height:1.5; } 299 .src-panel-body { display:flex; flex-direction:column; gap:12px; } 300 .src-panel .hint { margin-top:10px; font-size:11.5px; line-height:1.5; color:#85819c; } 301 .src-panel-desc + .src-chips { margin-top:12px; } 302 .src-chips { display:flex; flex-wrap:wrap; gap:8px; } 303 .src-chip { 304 padding:9px 15px; border-radius:999px; cursor:pointer; white-space:nowrap; 305 color:#e8e4ff; font:700 12px Manrope,sans-serif; 306 background:rgba(255,255,255,.05); border:1px solid rgba(255,255,255,.11); 307 transition:background .16s ease, border-color .16s ease, transform .16s ease; 308 } 309 .src-chip:hover { background:rgba(115,91,255,.18); border-color:rgba(134,112,255,.35); transform:translateY(-1px); } 310 .src-chip:active { transform:scale(.97); } 311 .src-panel #resultBox { margin-top:12px; } 312 313 .create-privacy { margin:16px 0 0; font-size:11.5px; line-height:1.45; color:#8a86a0; text-align:center; } 314 .create-privacy svg { display:inline-block; vertical-align:-1px; margin-right:6px; opacity:.85; } 315 316 @media (min-width: 640px) { 317 .src-panel-body { flex-direction:row; align-items:center; justify-content:space-between; gap:18px; } 318 #deviceShareBtn, #liveStreamBtn, #screenShareBtn { width:auto; min-width:170px; flex:0 0 auto; } 319 .src-ico { width:58px; height:58px; } 320 } 321 @media (max-width: 460px) { 322 .create-sheet { padding:16px 10px 12px; } 323 .src-picker { gap:4px; } 324 .src-ico { width:50px; height:50px; border-radius:16px; } 325 .src-ico svg { width:22px; height:22px; } 326 } 327 @keyframes srcSheetIn { from { opacity:0; transform:translateY(-6px); } to { opacity:1; transform:none; } } 328 @keyframes srcPanelIn { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:none; } } 329 330 /* ---------- READY CARD ---------- */ 331 #resultBox { 332 display:none; position:relative; z-index:2; margin-top:14px; padding:14px; 333 border-radius:17px; border:1px solid rgba(72,218,255,.22); 334 background:linear-gradient(135deg, rgba(52,53,125,.32), rgba(18,19,48,.58)); 335 box-shadow:inset 0 1px 0 rgba(255,255,255,.06), 0 12px 35px rgba(0,0,0,.18); 336 } 337 #resultBox .title { margin-bottom:9px; font-size:12px; font-weight:800; color:#d9f8ff; } 338 .link-row { display:flex; align-items:center; gap:5px; padding:8px; margin-bottom:10px; border-radius:11px; background:rgba(3,4,14,.43); border:1px solid rgba(255,255,255,.06); } 339 #shareLinkText { flex:1; min-width:0; color:#9edcff; font:500 10px JetBrains Mono,monospace; word-break:break-all; } 340 .link-row button { width:31px; height:31px; flex:0 0 31px; display:grid; place-items:center; border:1px solid rgba(255,255,255,.07); border-radius:9px; color:#a9a5be; background:rgba(255,255,255,.035); cursor:pointer; } 341 .link-row button:hover { color:#fff; background:rgba(115,91,255,.16); } 342 #joinNowBtn { display:inline-flex; align-items:center; justify-content:center; padding:10px 15px; text-decoration:none; } 343 344 /* ---------- YOUR PARTIES ---------- */ 345 .section-hdr { width:100%; display:flex; align-items:center; gap:10px; margin:34px 0 13px; } 346 .section-hdr h2 { font-size:17px; line-height:1; font-weight:800; letter-spacing:-.02em; } 347 .section-hdr::after { content:""; flex:1; height:1px; background:linear-gradient(90deg, rgba(145,117,255,.25), transparent); } 348 .section-hdr span { display:inline-flex; min-width:24px; height:23px; padding:0 8px; align-items:center; justify-content:center; border-radius:999px; color:#dcd6ff; font:800 10px JetBrains Mono,monospace; background:rgba(115,85,255,.13); border:1px solid rgba(137,113,255,.2); } 349 .party-list { width:100%; display:flex; flex-direction:column; gap:10px; } 350 .party-card { 351 width:100%; display:flex; align-items:center; gap:14px; min-width:0; 352 padding:14px; border-radius:17px; 353 background:linear-gradient(145deg, rgba(19,18,43,.78), rgba(10,10,25,.72)); 354 border:1px solid rgba(151,126,255,.14); 355 box-shadow:inset 0 1px 0 rgba(255,255,255,.045), 0 10px 30px rgba(0,0,0,.18); 356 transition:transform .2s, border-color .2s, background .2s; 357 } 358 .party-card:hover { transform:translateY(-2px); border-color:rgba(92,206,255,.28); background:linear-gradient(145deg, rgba(25,23,56,.84), rgba(11,11,28,.78)); } 359 .party-card .info { flex:1; min-width:0; } 360 .party-card .name { font-size:14px; font-weight:800; color:#f4f2ff; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-bottom:4px; } 361 .party-card .meta { color:#8f8ba4; font-size:10.5px; } 362 .instant-playlist-card-badge { color:#b9a8ff; font-weight:700; letter-spacing:.1px; white-space:nowrap; } 363 .party-card .url { margin-top:5px; color:#67637b; font:10px JetBrains Mono,monospace; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } 364 .party-card .actions { display:flex; align-items:center; gap:6px; flex-shrink:0; } 365 .party-card .join-btn { display:inline-flex; align-items:center; justify-content:center; padding:9px 13px; border-radius:11px; color:#fff; text-decoration:none; font-size:11px; font-weight:800; background:linear-gradient(135deg,#6354ff,#3e9cff); box-shadow:0 7px 20px rgba(74,78,255,.22); } 366 .party-card .icon-btn { width:34px; height:34px; display:grid; place-items:center; padding:0; border-radius:10px; color:#9d99b2; background:rgba(255,255,255,.035); border:1px solid rgba(255,255,255,.07); cursor:pointer; transition:.18s; } 367 .party-card .icon-btn:hover { color:#fff; background:rgba(112,87,255,.15); border-color:rgba(134,112,255,.24); } 368 .party-card .icon-btn.delete-btn:hover { color:#ff86bb; background:rgba(255,66,141,.08); border-color:rgba(255,86,155,.2); } 369 .empty-state { padding:28px 15px; text-align:center; color:#747087; font-size:12px; border:1px dashed rgba(141,118,255,.13); border-radius:16px; background:rgba(12,11,29,.34); } 370 371 /* ---------- MOBILE ---------- */ 372 @media (max-width: 760px) { 373 body.instant-page .wrap { width:min(100% - 22px, 620px); padding-top:18px; padding-bottom:26px; } 374 .instant-hero { padding:8px 2px 21px; } 375 .instant-title { font-size:clamp(32px, 10vw, 44px); } 376 .instant-sub { font-size:13px; max-width:560px; } 377 .instant-create-grid { grid-template-columns:1fr; gap:11px; } 378 .party-type-card { padding:17px; border-radius:20px; } 379 .party-type-card:first-child { grid-column:auto; } 380 .party-type-desc { min-height:0; margin-bottom:12px; } 381 .create-row { flex-direction:column; } 382 #createBtn { width:100%; } 383 #videoUrlInput { height:50px; } 384 .party-type-card:nth-child(2), .party-type-card:nth-child(3), .party-type-card:nth-child(4) { display:grid; grid-template-columns:40px 1fr; column-gap:11px; align-items:center; } 385 .party-type-card:nth-child(2) .party-type-head, .party-type-card:nth-child(3) .party-type-head, .party-type-card:nth-child(4) .party-type-head { grid-column:1 / -1; margin-bottom:7px; } 386 .party-type-card:nth-child(2) .party-type-desc, .party-type-card:nth-child(3) .party-type-desc, .party-type-card:nth-child(4) .party-type-desc { grid-column:1 / -1; } 387 .party-type-card:nth-child(2) button, .party-type-card:nth-child(3) button, .party-type-card:nth-child(4) button { grid-column:1 / -1; } 388 .section-hdr { margin-top:27px; } 389 .party-card { align-items:flex-start; padding:13px; } 390 .party-card .actions { align-self:center; } 391 .party-card .join-btn { padding:9px 11px; } 392 } 393 394 @media (max-width: 460px) { 395 .instant-kicker { font-size:9px; } 396 .instant-title { font-size:35px; } 397 .instant-sub { font-size:12.5px; } 398 .party-type-card { padding:15px; } 399 .party-card { gap:9px; } 400 .party-card .actions { gap:4px; } 401 .party-card .icon-btn { width:31px; height:31px; } 402 .party-card .join-btn { font-size:10px; padding:8px 9px; } 403 .party-card .meta { font-size:9.5px; } 404 } 405 406 @media (prefers-reduced-motion: reduce) { 407 *, *::before, *::after { animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; scroll-behavior:auto !important; } 408 } 409 @keyframes instantPulse { 0%,100%{transform:scale(.85);opacity:.65} 50%{transform:scale(1.18);opacity:1} } 410 /* #room-edit-overlay here references --vs-* variables throughout 411 (copied from crooms-chat.html), but this page has no such
412 namespace at all -- it uses --instant-* instead. Mapped locally, 413 scoped to just this overlay, to the exact same values 414 crooms-chat.html's own :root uses, so the overlay looks identical 415 across every page it appears on rather than picking up whatever 416 this page's own theme happens to be. */ 417 #room-edit-overlay { 418 --vs-bg: #07050a; 419 --vs-surface: #0e0b14; 420 --vs-card: rgba(255,255,255,0.04); 421 --vs-border: rgba(255,255,255,0.07); 422 --vs-purple: var(--theme-accent); 423 --vs-purple-l: var(--theme-accent-light); 424 --vs-muted: #5c5668; 425 --vs-text: #ede9f4; 426 --vs-dim: #6e6151; 427 } 428 429 /* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 430 Base structural CSS for #room-edit-overlay -- copied from 431 watch.html's own inline <style>. Confirmed necessary: deleting 432 this entirely (on the theory that watch-overlays.css alone would 433 be enough) broke the overlay, since that file is an ENHANCEMENT 434 layer only -- it overrides colors/visual polish on specific 435 properties, but relies on this base layer already existing for 436 fundamental structure (input { display:none } hiding the native 437 radio circle being the one that broke visibly, but the same 438 applies to layout/sizing throughout). watch-overlays.css's own 439 !important, ID-scoped rules still correctly win for the specific 440 properties they target on top of this. */ 441 #room-edit-overlay.vs-overlay { 442 display: none; position: fixed; inset: 0; z-index: 10000; 443 background: rgba(0,0,0,.7); 444 backdrop-filter: blur(8px); 445 align-items: flex-end; justify-content: center; 446 overflow-y: auto; 447 padding-bottom: calc(62px + env(safe-area-inset-bottom, 0px)); 448 } 449 #room-edit-overlay.vs-overlay.open { display: flex; } 450 #room-edit-overlay .vs-sheet { 451 width: 100%; max-width: 540px; 452 background: #13111a; 453 border: 1px solid var(--vs-border); 454 border-radius: 20px 20px 0 0; 455 padding: 20px 20px 36px; 456 animation: editSheetSlideUp .22s ease-out; 457 max-height: 88vh; 458 overflow-y: auto; 459 } 460 @keyframes editSheetSlideUp { 461 from { transform: translateY(40px); opacity: 0; } 462 to { transform: translateY(0); opacity: 1; } 463 } 464 #room-edit-overlay .vs-sheet-handle { 465 width: 36px; height: 4px; border-radius: 999px; 466 background: var(--vs-dim); 467 margin: 0 auto 20px; 468 } 469 #room-edit-overlay .vs-sheet h3 { 470 font-size: 18px; font-weight: 800; 471 color: #fff; margin-bottom: 18px; 472 letter-spacing: -.02em; 473 } 474 #room-edit-overlay .vs-field { margin-bottom: 14px; } 475 #room-edit-overlay .vs-field label { 476 display: flex; align-items: center; justify-content: space-between; 477 font-size: 11px; font-weight: 700; 478 letter-spacing: .06em; text-transform: uppercase; 479 color: var(--vs-muted); margin-bottom: 6px; 480 } 481 #room-edit-overlay .vs-field label em { 482 font-style: normal; opacity: .55; font-weight: 600; 483 } 484 #room-edit-overlay .vs-field input, #room-edit-overlay .vs-field select { 485 width: 100%; height: 44px; padding: 0 14px; 486 background: rgba(255,255,255,.05); 487 border: 1px solid var(--vs-border); 488 border-radius: 10px; color: #fff; 489 font-size: 14px; font-family: inherit; 490 transition: border-color .15s; 491 } 492 #room-edit-overlay .vs-field input:focus, #room-edit-overlay .vs-field select:focus { 493 outline: none; border-color: var(--vs-purple-l); 494 } 495 #room-edit-overlay .vs-field input::placeholder { color: var(--vs-dim); } 496 #room-edit-overlay .vs-radio-row { display: flex; gap: 10px; margin-top: 2px; } 497 #room-edit-overlay .vs-radio-opt { 498 flex: 1; display: flex; align-items: center; 499 justify-content: center; gap: 6px; 500 height: 40px; border-radius: 10px; 501 border: 1px solid var(--vs-border); 502 background: var(--vs-card); 503 color: var(--vs-muted); font-size: 13px; font-weight: 600; 504 cursor: pointer; transition: background .15s, border-color .15s, color .15s; 505 } 506 #room-edit-overlay .vs-radio-opt input { display: none; } 507 #room-edit-overlay .vs-radio-opt:has(input:checked) { 508 background: rgba(124,58,237,.15); 509 border-color: rgba(124,58,237,.45); 510 color: var(--vs-purple-l); 511 } 512 #room-edit-overlay .vs-source-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-top: 2px; } 513 #room-edit-overlay .vs-notice { 514 display: flex; gap: 10px; 515 background: rgba(255,255,255,.03); 516 border: 1px solid var(--vs-border); 517 border-radius: 10px; padding: 12px; 518 font-size: 11px; color: var(--vs-muted); 519 line-height: 1.6; margin-bottom: 12px; 520 } 521 #room-edit-overlay .vs-notice-icon { font-size: 14px; flex-shrink: 0; } 522 #room-edit-overlay .vs-sheet-actions { display: flex; gap: 10px; margin-top: 6px; } 523 #room-edit-overlay .vs-btn-cancel { 524 flex: 1; height: 44px; border-radius: 10px; 525 background: var(--vs-card); border: 1px solid var(--vs-border); 526 color: rgba(255,255,255,.6); font-size: 14px; font-weight: 600; 527 cursor: pointer; 528 } 529 #room-edit-overlay .vs-btn-submit { 530 flex: 2; height: 44px; border-radius: 10px; 531 background: var(--vs-purple); border: none; 532 color: #fff; font-size: 14px; font-weight: 700; 533 cursor: pointer; transition: opacity .15s; 534 } 535 #room-edit-overlay .vs-btn-submit:hover { opacity: .88; } 536 #room-edit-overlay .vs-toggle-row { 537 display: flex; align-items: center; justify-content: space-between; 538 padding: 12px 14px; 539 background: rgba(255,255,255,.03); 540 border: 1px solid var(--vs-border); 541 border-radius: 10px; 542 margin-bottom: 14px; 543 gap: 12px; 544 } 545 #room-edit-overlay .vs-toggle-info { flex: 1; min-width: 0; } 546 #room-edit-overlay .vs-toggle-label { 547 font-size: 13px; font-weight: 600; color: rgba(255,255,255,.85); 548 display: block; margin-bottom: 2px; 549 } 550 #room-edit-overlay .vs-toggle-desc { 551 font-size: 11px; color: var(--vs-muted); line-height: 1.45; 552 } 553 #room-edit-overlay .vs-toggle { 554 position: relative; width: 44px;
554 height: 26px; flex-shrink: 0; 555 } 556 #room-edit-overlay .vs-toggle input { opacity: 0; width: 0; height: 0; } 557 #room-edit-overlay .vs-toggle-track { 558 position: absolute; inset: 0; border-radius: 999px; 559 background: rgba(255,255,255,.1); 560 border: 1px solid rgba(255,255,255,.12); 561 transition: background .2s, border-color .2s; 562 cursor: pointer; 563 } 564 #room-edit-overlay .vs-toggle-track::after { 565 content: ''; 566 position: absolute; top: 3px; left: 3px; 567 width: 18px; height: 18px; border-radius: 50%; 568 background: rgba(255,255,255,.4); 569 transition: transform .2s, background .2s; 570 } 571 #room-edit-overlay .vs-toggle input:checked + .vs-toggle-track { 572 background: rgba(124,58,237,.5); 573 border-color: rgba(124,58,237,.7); 574 } 575 #room-edit-overlay .vs-toggle input:checked + .vs-toggle-track::after { 576 transform: translateX(18px); 577 background: #a78bfa; 578 } 579 #room-edit-overlay .vs-field input[type="datetime-local"] { 580 color-scheme: dark; 581 } 582 #room-edit-overlay .vs-schedule-wrap { position: relative; } 583 #room-edit-overlay .vs-schedule-note { 584 font-size: 10px; color: var(--vs-muted); 585 margin-top: 5px; padding-left: 2px; 586 } 587 #room-edit-overlay .vs-sheet-divider { 588 height: 1px; background: var(--vs-border); 589 margin: 18px 0 16px; 590 } 591 #room-edit-overlay .vs-sheet-section-label { 592 font-size: 10px; font-weight: 700; letter-spacing: .08em; 593 text-transform: uppercase; color: var(--vs-dim); 594 margin-bottom: 12px; 595 } 596 @media(min-width:600px) { 597 #room-edit-overlay .vs-sheet { border-radius: 20px; margin-bottom: 24px; } 598 #room-edit-overlay.vs-overlay.open { align-items: flex-start; padding-top: 40px; } 599 } 600 601 /* Share-with chips (Friends visibility) + its picker overlay, and the 602 "Shared with you" party-card pill -- same feature as the Watch 603 page's Create/Edit sheets, added here for Instant parties too. 604 z-index cleared against the REAL #room-edit-overlay z-index 605 (11000, set in style.css via an ID-selector rule, not the 1000 a 606 class-level rule alone would suggest -- see the Watch-page fix 607 for the full story on why that number matters). */ 608 .vs-share-chip { 609 display: inline-flex; align-items: center; gap: 6px; 610 background: rgba(255,255,255,.05); border: 1px solid var(--vs-border); 611 border-radius: 999px; padding: 5px 8px 5px 5px; font-size: 12px; font-weight: 700; color: var(--vs-text); 612 } 613 .vs-share-chip .vs-share-chip-x { color: var(--vs-dim); cursor: pointer; padding: 0 2px; } 614 .vs-share-add-chip { 615 background: none; border: 1px dashed var(--vs-border); border-radius: 999px; 616 padding: 6px 12px; font-size: 12px; font-weight: 700; color: var(--vs-muted, var(--vs-dim)); 617 cursor: pointer; font-family: inherit; 618 } 619 /* z-index 999999, not a number matched to a specific known overlay: 620 this page loads watch-overlays.css, a file not available while 621 building this patch, and the same comment block above already 622 warns it can override z-index with its own !important, ID-scoped 623 rules -- exactly the trap that bit the Watch page's own picker 624 once already (style.css turned out to set #create-room-overlay to 625 11000, not the 1000 its class-level rule alone suggested). Rather 626 than guess another specific number from an unseen file, this 627 clears every value actually observed in this codebase (10000, 628 11000, 100000) with real margin. */ 629 .vs-share-picker-overlay { 630 display: none; position: fixed; inset: 0; z-index: 999999; 631 background: rgba(0,0,0,.65); align-items: flex-end; justify-content: center; 632 } 633 .vs-share-picker-overlay.open { display: flex; } 634 .vs-share-picker-sheet { 635 width: 100%; max-width: 540px; max-height: 74vh; overflow-y: auto; 636 background: #13111a; border: 1px solid var(--vs-border); border-radius: 20px 20px 0 0; padding: 16px; 637 } 638 .vs-share-picker-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } 639 .vs-share-picker-title { font-size: 16px; font-weight: 800; color: var(--vs-text); } 640 .vs-share-picker-tabs { display: flex; gap: 6px; background: rgba(255,255,255,.04); border-radius: 12px; padding: 4px; margin-bottom: 12px; } 641 .vs-share-picker-tab { 642 flex: 1; text-align: center; padding: 8px 4px; border-radius: 9px; font-size: 12.5px; font-weight: 700; 643 color: var(--vs-muted, var(--vs-dim)); cursor: pointer; background: none; border: none; font-family: inherit; 644 } 645 .vs-share-picker-tab.active { background: linear-gradient(135deg,#7c3aed,#4f46e5); color: #fff; } 646 .vs-share-pick-row { 647 display: flex; align-items: center; gap: 11px; padding: 10px; border-radius: 12px; 648 background: rgba(255,255,255,.03); border: 1px solid var(--vs-border); margin-bottom: 8px; cursor: pointer; 649 } 650 .vs-share-pick-row.selected { border-color: rgba(124,58,237,.5); background: rgba(124,58,237,.1); } 651 .vs-share-pick-avatar { 652 width: 36px; height: 36px; border-radius: 50%; flex: 0 0 auto; display: flex; align-items: center; 653 justify-content: center; font-weight: 800; font-size: 13px; color: #fff; overflow: hidden; 654 } 655 .vs-share-pick-avatar img { width: 100%; height: 100%; object-fit: cover; } 656 .vs-share-pick-name { font-size: 13px; font-weight: 700; color: var(--vs-text); flex: 1; min-width: 0; } 657 .vs-share-pick-check {
658 width: 20px; height: 20px; border-radius: 6px; border: 1px solid var(--vs-border); flex: 0 0 auto; 659 display: flex; align-items: center; justify-content: center; font-size: 12px; color: transparent; 660 } 661 .vs-share-pick-row.selected .vs-share-pick-check { background: #7c3aed; border-color: #7c3aed; color: #fff; } 662 .vs-card-pill-shared { 663 background: rgba(124,58,237,.16) !important; color: #c4b5fd !important; 664 border-color: rgba(124,58,237,.35) !important; 665 } 666 667/* ============================================================ 668 INSTANT PARTY CARD â COMPACT ACTION MENU 669 Matches the Watch/Public party-card overflow menu. 670 ============================================================ */ 671.instant-party-more-wrap{position:relative;flex:0 0 auto} 672.instant-party-overflow-menu{ 673 position:absolute; 674 bottom:calc(100% + 6px); 675 right:0; 676 min-width:180px; 677 z-index:9999; 678 background:#1e1b2e; 679 border:1px solid rgba(255,255,255,.15); 680 border-radius:10px; 681 padding:4px; 682 box-shadow:0 4px 16px rgba(0,0,0,.3); 683} 684.instant-party-overflow-menu .overflow-item{ 685 display:block; 686 width:100%; 687 text-align:left; 688 padding:9px 12px; 689 font-size:13px; 690 background:none; 691 border:none; 692 border-radius:7px; 693 cursor:pointer; 694 color:var(--text,#eee); 695} 696.instant-party-overflow-menu .overflow-item:hover{background:rgba(255,255,255,.07)} 697.instant-party-overflow-menu .overflow-item.danger{color:#E24B4A} 698.instant-party-more-btn{ 699 width:34px!important; 700 height:34px!important; 701 display:inline-flex!important; 702 align-items:center!important; 703 justify-content:center!important; 704 padding:0!important; 705 border:1px solid rgba(255,255,255,.10)!important; 706 border-radius:9px!important; 707 background:rgba(255,255,255,.045)!important; 708 color:rgba(255,255,255,.72)!important; 709 font-size:17px!important; 710 line-height:1!important; 711 letter-spacing:1px; 712 cursor:pointer; 713} 714.instant-party-more-btn:hover{ 715 background:rgba(255,255,255,.08)!important; 716 color:#fff!important; 717} 718/* ============================================================ 719 Party card v2 -- same look as the Watch/Public/crooms-chat.html 720 room card (renderRoomItem() in croom.js), duplicated here since 721 this page doesn't load that file. Keep the two in sync by hand 722 if either changes. The old .party-card rules above are unused 723 now but left in place. 724 ============================================================ */ 725.vs-room-card { 726 position: relative; 727 border-radius: 17px; 728 background: linear-gradient(145deg, rgba(19,18,43,.78), rgba(10,10,25,.72)); 729 border: 1px solid rgba(151,126,255,.14); 730 box-shadow: inset 0 1px 0 rgba(255,255,255,.045), 0 10px 30px rgba(0,0,0,.18); 731 padding: 12px; 732 cursor: pointer; 733 transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease; 734} 735.vs-room-card:hover { 736 transform: translateY(-2px); 737 border-color: rgba(92,206,255,.28); 738 background: linear-gradient(145deg, rgba(25,23,56,.84), rgba(11,11,28,.78)); 739} 740.vs-card-top { display: flex; gap: 12px; } 741.vs-card-thumb { 742 flex: 0 0 88px; width: 88px; height: 62px; border-radius: 11px; 743 position: relative; overflow: hidden; background: #0c0b1c; 744 border: 1px solid rgba(255,255,255,.06); 745} 746.vs-card-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } 747.vs-card-thumb.is-generic { 748 background: linear-gradient(160deg, rgba(139,92,246,.30), rgba(41,194,255,.14) 55%, rgba(10,10,25,.9) 100%); 749} 750.vs-card-thumb-icon, .vs-card-thumb-play { 751 position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; 752 color: #fff; pointer-events: none; 753} 754.vs-card-thumb-icon svg { width: 22px; height: 22px; opacity: .92; } 755.vs-card-thumb-play svg { width: 22px; height: 22px; filter: drop-shadow(0 2px 6px rgba(0,0,0,.55)); } 756.vs-card-thumb-tag { 757 position: absolute; top: 5px; right: 5px; font-size: 8.5px; font-weight: 800; 758 background: rgba(0,0,0,.6); color: #fff; padding: 2px 6px; border-radius: 999px; letter-spacing: .03em; 759} 760.vs-card-body { flex: 1; min-width: 0; display: flex; flex-direction: column; } 761.vs-card-row1 { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; } 762.vs-card-name { 763 font-size: 15px; font-weight: 700; color: #fff; 764 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; 765} 766.vs-badge { 767 flex: 0 0 auto; font-size: 9.5px; font-weight: 800; letter-spacing: .04em; 768 padding: 3px 8px; border-radius: 999px; text-transform: uppercase; 769} 770.vs-badge-pub { background: rgba(56,189,248,.14); color: #7dd3fc; border: 1px solid rgba(56,189,248,.28); } 771.vs-badge-priv { background: rgba(167,139,250,.14); color: #c4b5fd; border: 1px solid rgba(167,139,250,.28); } 772.vs-card-owner-row { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; margin-top: 3px; } 773.vs-card-owner { font-size: 12px; font-weight: 600; color: rgba(224,231,255,.62); white-space: nowrap; } 774.vs-card-sep { font-size: 11px; color: rgba(224,231,255,.35); flex-shrink: 0; } 775.vs-card-pills { display: flex; gap: 6px; margin-top: 7px; flex-wrap: wrap; } 776.vs-card-pill { 777 display: inline-flex; align-items: center; gap: 4px; font-size: 9.5px; font-weight: 700; 778 letter-spacing: .03em; padding: 3px 8px 3px 6px; border-radius: 999px; 779 background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.09); color: #cfc9e6; 780} 781.vs-card-pill svg { width: 11px; height: 11px; opacity: .85; flex: 0 0 auto; } 782.vs-card-pill-playlist { background: rgba(251,191,36,.10); border-color: rgba(251,191,36,.24); color: #fcd34d; } 783.vs-card-actions { display: flex; gap: 8px; margin-top: 11px; padding-top: 10px; 784 border-top: 1px solid rgba(255,255,255,.06); align-items: center; justify-content: space-between; } 785.vs-card-primary-btn { 786 flex: 0 1 auto; min-width: 0; display: flex; align-items: center; justify-content: center; gap: 5px; 787 height: 40px; padding: 0 14px; border-radius: 10px; color: #fff; font-size: 12px; font-weight: 700; 788 background: linear-gradient(135deg,#7c3aed,#4f46e5 55%,#0891b2); white-space: nowrap; 789 border: none; cursor: pointer; font-family: inherit; 790} 791.vs-card-primary-btn svg { width: 12px; height: 12px; flex: 0 0 auto; } 792.vs-card-primary-btn:hover { filter: brightness(1.08); } 793.vs-card-icon-group { display: flex; gap: 8px; flex: 0 0 auto; } 794.vs-card-icon-btn { 795 width: 40px; height: 40px; flex: 0 0 40px; border-radius: 10px; background: rgba(255,255,255,.05); 796 border: 1px solid rgba(255,255,255,.09); display: flex; align-items: center; justify-content: center; 797 color: #cfc9e6; cursor: pointer; font-size: 16px; padding: 0; 798} 799.vs-card-icon-btn svg { width: 16px; height: 16px; } 800.vs-card-icon-btn:hover { background: rgba(255,255,255,.09); color: #fff; } 801@media (max-width: 420px) { 802 .vs-card-thumb { flex-basis: 76px; width: 76px; height: 54px; } 803 .vs-card-primary-btn { padding: 0 11px; } 804} 805@media (max-width: 380px) { 806 .vs-card-icon-group { gap: 6px; } 807 .vs-card-icon-btn { width: 38px; height: 38px; flex-basis: 38px; } 808} 809 810 811</style>
812<script src="https://accounts.google.com/gsi/client" async defer></script>
812 813<link rel="manifest" href="/manifest.json"> 814<meta name="theme-color" content="#080608"> 815 816<style> 817.powered-by-vizsurge{ 818 display:flex; 819 align-items:center; 820 justify-content:center; 821 gap:8px; 822 margin:10px auto 0; 823 padding:0 16px; 824 color:#8f9bc1; 825 font-size:11px; 826 line-height:1.3; 827 letter-spacing:.02em; 828} 829.powered-by-vizsurge img{ 830 width:22px; 831 height:22px; 832 object-fit:contain; 833 display:block; 834 filter:drop-shadow(0 3px 8px rgba(80,130,255,.18)); 835} 836</style> 837 838</head> 839 840<body class="index-page instant-page"> 841 842<div id="vs-header-mount"></div> 843 844<div class="powered-by-vizsurge" aria-label="Powered by VizSurge"> 845 <img src="/static/logo_vizsurge.webp" alt="VizSurge"> 846 <span>Powered by VizSurge</span> 847</div> 848 849 850<div class="wrap"> 851 <section class="instant-hero" aria-labelledby="instant-title"> 852 <div class="instant-kicker"><span class="dot"></span> No sign-up needed</div> 853 <h1 id="instant-title" class="instant-title">Watch together.<br><span class="spark">Right now.</span> â¨</h1> 854 </section> 855 856 <!-- Join Party -- lets a pasted party link work from inside the native 857 app, where a shared link has no way to open the app at all today 858 (desktop opens a new browser tab; the app can't). Public rooms 859 navigate straight in; private ones prompt for a code inline, 860 reusing the exact same validation the browser already uses. --> 861 <section class="join-party-card" aria-label="Join a party"> 862 <div class="join-party-row"> 863 <input type="text" id="join-party-link" class="join-party-input" 864 placeholder="Paste party link" autocomplete="off" 865 autocapitalize="off" spellcheck="false"> 866 <button type="button" id="join-party-btn" class="join-party-btn">Join</button> 867 </div> 868 <div class="join-party-row join-party-code-row" id="join-party-code-row" style="display:none;"> 869 <input type="text" id="join-party-code-input" class="join-party-input" 870 placeholder="Enter party code" autocomplete="off" 871 autocapitalize="characters" spellcheck="false"> 872 <button type="button" id="join-party-code-btn" class="join-party-btn">Join</button> 873 </div> 874 <div class="join-party-error" id="join-party-error" style="display:none;"></div> 875 </section> 876 877 <!-- Create Party -- collapsed by default. Tapping the teaser reveals a row 878 of source icons (Link, Browse [native app only], File, Live, Screen); 879 tapping an icon reveals just that source's panel, one at a time. 880 Every element ID the create JS below uses is unchanged. --> 881 <section class="create-party-wrap"> 882 <button type="button" class="create-party-teaser" id="create-party-teaser" aria-expanded="false" aria-controls="instant-create-grid"> 883 <div class="create-party-teaser-text"> 884 <span class="create-party-teaser-title">Create Party</span> 885 <span class="create-party-teaser-sub">Video link, local file, camera, or screen share</span> 886 </div> 887 <span class="create-party-teaser-icon" id="create-party-teaser-icon">+</span> 888 </button> 889 890 <div class="create-sheet" id="instant-create-grid" aria-label="Create a watch party" hidden> 891 <div class="src-picker" role="tablist" aria-label="How do you want to start?"> 892 <button type="button" class="src-tab" id="src-tab-link" data-src="link" role="tab" aria-selected="false" aria-controls="src-panel-link"> 893 <span class="src-ico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span> 894 <span class="src-lbl">Link</span> 895 </button> 896 <button type="button" class="src-tab" id="src-tab-browse" data-src="browse" role="tab" aria-selected="false" aria-controls="src-panel-browse" hidden> 897 <span class="src-ico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9.5"/><polygon points="16.2 7.8 14.1 14.1 7.8 16.2 9.9 9.9 16.2 7.8"/></svg></span>
898 <span class="src-lbl">Browse</span> 899 </button> 900 <button type="button" class="src-tab" id="src-tab-file" data-src="file" role="tab" aria-selected="false" aria-controls="src-panel-file"> 901 <span class="src-ico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="3"/><polygon points="10 9 15.5 12 10 15 10 9"/></svg></span> 902 <span class="src-lbl">File</span> 903 </button> 904 <button type="button" class="src-tab" id="src-tab-live" data-src="live" role="tab" aria-selected="false" aria-controls="src-panel-live"> 905 <span class="src-ico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8a6 6 0 0 1 0 8.5M7.8 16.2a6 6 0 0 1 0-8.5M19 5a10 10 0 0 1 0 14M5 19A10 10 0 0 1 5 5"/></svg></span> 906 <span class="src-lbl">Live</span> 907 </button> 908 <button type="button" class="src-tab" id="src-tab-screen" data-src="screen" role="tab" aria-selected="false" aria-controls="src-panel-screen"> 909 <span class="src-ico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="3.5" width="19" height="13" rx="2.5"/><path d="M8 21h8M12 16.5V21"/></svg></span> 910 <span class="src-lbl">Screen</span> 911 </button> 912 </div> 913 914 <div class="src-panels"> 915 <section class="src-panel" id="src-panel-link" data-src="link" role="tabpanel" aria-labelledby="src-tab-link" hidden> 916 <div class="create-row"> 917 <input type="text" id="videoUrlInput" placeholder="Paste video linkâ¦" autocomplete="off" inputmode="url" aria-label="Video URL"> 918 <button id="createBtn" onclick="createInstantParty()">Create Party â¨</button> 919 </div> 920 <div class="hint">Works with YouTube, YT Music, Dailymotion, TED, Twitch, Vimeo, Google Drive shared videos and public video links.</div> 921 922 <div id="resultBox"> 923 <div class="title">ð Your party is ready</div> 924 <div class="link-row"> 925 <span id="shareLinkText"></span> 926 <button onclick="copyLinkToClipboard(_currentShareLink, this)" title="Copy link" aria-label="Copy link"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button> 927 <button onclick="shareLink(_currentShareLink, this)" title="Share" aria-label="Share"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg></button> 928 </div> 929 <a id="joinNowBtn" href="#">Join Party â</a> 930 </div> 931 </section> 932 933 <section class="src-panel" id="src-panel-browse" data-src="browse" role="tabpanel" aria-labelledby="src-tab-browse" hidden> 934 <p class="src-panel-desc">Pick a platform, find a video, then tap âCreate Partyâ. Share the link to watch it together.</p> 935 <div class="src-chips"> 936 <button type="button" class="src-chip" onclick="startStreamSyncParty('youtube')">YouTube</button> 937 <button type="button" class="src-chip" onclick="startStreamSyncParty('youtubemusic')">YT Music</button> 938 <button type="button" class="src-chip" onclick="startStreamSyncParty('dailymotion')">Dailymotion</button> 939 <button type="button" class="src-chip" onclick="startStreamSyncParty('twitch')">Twitch</button> 940 <button type="button" class="src-chip" onclick="startStreamSyncParty('ted')">TED</button> 941 <button type="button" class="src-chip" onclick="startStreamSyncParty('browseweb')">Browse Web</button> 942 </div> 943 <!-- Prime Video intentionally not offered here -- it has no 944 embeddable-URL playback story the way YouTube/Twitch/ 945 Dailymotion do, which is exactly why it hit the Widevine DRM 946 wall when we tried the injected-WebView approach for actual 947 playback. This flow is scoped to platforms where a captured 948 URL can play back through the existing, proven YT.Player 949 pathway -- not ones needing that different, harder mechanism. --> 950 </section> 951 952 <section class="src-panel" id="src-panel-file" data-src="file" role="tabpanel" aria-labelledby="src-tab-file" hidden> 953 <div class="src-panel-body"> 954 <p class="src-panel-desc">Play a video straight from your device. No upload, no hosting.</p> 955 <button id="deviceShareBtn" onclick="startDeviceShare()">Start Party</button> 956 </div> 957 <div id="deviceStatus"></div> 958 </section> 959 960 <section class="src-panel" id="src-panel-live" data-src="live" role="tabpanel" aria-labelledby="src-tab-live" hidden> 961 <div class="src-panel-body"> 962 <p class="src-panel-desc">Broadcast from your camera for a function, event, game night or anything happening now.</p> 963 <button id="liveStreamBtn" onclick="startLiveStream()">Go Live</button> 964 </div> 965 <div id="liveStreamStatus"></div> 966 </section> 967 968 <section class="src-panel" id="src-panel-screen" data-src="screen" role="tabpanel" aria-labelledby="src-tab-screen" hidden> 969 <div class="src-panel-body"> 970 <p class="src-panel-desc" id="screenShareDesc">Share your screen â stream anything happening on your device right now.</p> 971 <button id="screenShareBtn" onclick="startScreenShare()">Start Party</button> 972 </div> 973 <div id="screenShareStatus"></div> 974 </section> 975 </div> 976 977 <p class="create-privacy" id="create-party-privacy-note"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="11" width="16" height="10" rx="2.5"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>Parties are private by default. Use Edit â anytime to make your party public.</p> 978 </div> 979 </section> 980 981 <div class="section-hdr"> 982 <h2>Your Parties</h2> 983 <span id="mineCount"></span> 984 </div> 985 <div class="party-list" id="mineList"> 986 <div class="empty-state">Loading your partiesâ¦</div> 987 </div> 988</div> 989
990<script> 991let _currentShareLink = ""; 992 993// Maps known backend error codes to a message worth actually showing â 994// mirrors croom.js's _roomErrorMessage (kept in sync there, since this 995// page doesn't load croom.js and has no shared code with it). 996function _partyErrorMessage(err) { 997 const code = err?.message || ""; 998 if (code === "unsupported_source") { 999 return "That video link isn't supported. Please try a different link."; 1000 } 1001 if (code === "video_url required") { 1002 return "Please add a video link."; 1003 } 1004 return null; // unrecognized â caller falls back to its own generic message 1005} 1006 1007async function createInstantParty() { 1008 const input = document.getElementById("videoUrlInput"); 1009 const btn = document.getElementById("createBtn"); 1010 const url = input.value.trim(); 1011 if (!url) { input.focus(); return; } 1012 1013 btn.disabled = true; 1014 btn.textContent = "Creatingâ¦"; 1015 try { 1016 await finishPartyCreation(url); 1017 input.value = ""; 1018 } catch (err) { 1019 alert(_partyErrorMessage(err) || "Couldn't create the party â please check the video URL and try again."); 1020 } finally { 1021 btn.disabled = false; 1022 btn.textContent = "Create Party â¨"; 1023 } 1024} 1025 1026async function finishPartyCreation(videoUrl) { 1027 const res = await fetch("/api/instant-party", { 1028 method: "POST", 1029 headers: { "Content-Type": "application/json" }, 1030 body: JSON.stringify({ video_url: videoUrl, guest_name: (!window.CURRENT_USER || !window.CURRENT_USER.sub) ? getGuestName() : null }) 1031 }); 1032 const data = await res.json(); 1033 if (!data.ok) throw new Error(data.error || "Failed to create party"); 1034 1035 const link = `${location.origin}/watch/${data.slug}?code=${encodeURIComponent(data.access_code)}`; 1036 _currentShareLink = link; 1037 document.getElementById("shareLinkText").textContent = link; 1038 document.getElementById("joinNowBtn").href = link; 1039 document.getElementById("resultBox").style.display = "block"; 1040 loadMineList(); 1041 return data; 1042} 1043 1044// Streaming Platform card -- only relevant inside the native app, since 1045// it depends on the native StreamSync browse WebView, which a regular 1046// browser tab has no equivalent for. Confirmed via earlier diagnosis: 1047// something in the sign-in completion path re-renders or resets this 1048// section of the page, silently hiding it again after a one-time show -- 1049// polling (rather than a single DOMContentLoaded check) is what actually 1050// keeps this correctly visible afterward, not a redundant safety net. 1051function _ensureStreamingPlatformCardVisible() { 1052 if (!(window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform())) return; 1053 // The "Browse" icon in the Create Party picker -- shown only inside the app. 1054 const tab = document.getElementById("src-tab-browse"); 1055 if (tab && tab.hidden) tab.hidden = false; 1056} 1057document.addEventListener("DOMContentLoaded", _ensureStreamingPlatformCardVisible); 1058setInterval(_ensureStreamingPlatformCardVisible, 1000); 1059 1060function startStreamSyncParty(platform) { 1061 // No party creation here at all -- nothing gets created until a real 1062 // video URL is actually captured on streamplayer.html itself. 1063 location.href = `${location.origin}/streamplayer.html?platform=${platform}`; 1064} 1065 1066// Create Party -- teaser opens a row of source icons; tapping an icon shows 1067// only that source's panel (tap it again to collapse). Each panel's own 1068// button keeps its existing onclick handler, so party creation itself works 1069// exactly as before. 1070(function () { 1071 // Which source opens automatically when the sheet opens. null = show the 1072 // icon row only (nothing pre-selected). Set to "link" to open the video 1073 // link panel straight away. 1074 const CREATE_DEFAULT_SOURCE = null; 1075 1076 const teaser = document.getElementById("create-party-teaser"); 1077 const sheet = document.getElementById("instant-create-grid"); 1078 if (!teaser || !sheet) return; 1079 const picker = sheet.querySelector(".src-picker"); 1080 const tabs = Array.from(sheet.querySelectorAll(".src-tab")); 1081 const panels = Array.from(sheet.querySelectorAll(".src-panel")); 1082 1083 function select(src) {
1084 tabs.forEach(function (t) { 1085 const on = t.dataset.src === src; 1086 t.classList.toggle("is-active", on); 1087 t.setAttribute("aria-selected", String(on)); 1088 }); 1089 panels.forEach(function (p) { p.hidden = p.dataset.src !== src; }); 1090 if (picker) picker.classList.toggle("has-active", !!src); 1091 } 1092 1093 tabs.forEach(function (t) { 1094 t.addEventListener("click", function () { 1095 const already = t.classList.contains("is-active"); 1096 select(already ? null : t.dataset.src); 1097 if (already) return; 1098 const panel = sheet.querySelector('.src-panel[data-src="' + t.dataset.src + '"]'); 1099 if (panel && panel.scrollIntoView) panel.scrollIntoView({ block: "nearest", behavior: "smooth" }); 1100 // On a desktop pointer, drop the cursor straight into the link field. 1101 // Not on touch devices -- it would pop the keyboard up uninvited. 1102 if (t.dataset.src === "link" && window.matchMedia && window.matchMedia("(hover: hover) and (pointer: fine)").matches) { 1103 const input = document.getElementById("videoUrlInput"); 1104 if (input) input.focus({ preventScroll: true }); 1105 } 1106 }); 1107 // Left/Right arrows move between the icons. 1108 t.addEventListener("keydown", function (e) { 1109 if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return; 1110 const visible = tabs.filter(function (x) { return !x.hidden; }); 1111 const i = visible.indexOf(t); 1112 const next = visible[(i + (e.key === "ArrowRight" ? 1 : visible.length - 1)) % visible.length]; 1113 if (next) { next.focus(); e.preventDefault(); } 1114 }); 1115 }); 1116 1117 teaser.addEventListener("click", function () { 1118 const willOpen = sheet.hidden; 1119 sheet.hidden = !willOpen; 1120 teaser.setAttribute("aria-expanded", String(willOpen)); 1121 select(willOpen ? CREATE_DEFAULT_SOURCE : null); 1122 }); 1123})(); 1124 1125async function startDeviceShare() { 1126 const btn = document.getElementById("deviceShareBtn"); 1127 const status = document.getElementById("deviceStatus"); 1128 1129 btn.disabled = true; 1130 btn.textContent = "Creatingâ¦"; 1131 status.style.display = "block"; 1132 status.textContent = "Creating your partyâ¦"; 1133 1134 try { 1135 const res = await fetch("/api/instant-party", { 1136 method: "POST", 1137 headers: { "Content-Type": "application/json" }, 1138 body: JSON.stringify({ video_url: "pending:livekit-video", guest_name: (!window.CURRENT_USER || !window.CURRENT_USER.sub) ? getGuestName() : null }) 1139 }); 1140 const data = await res.json(); 1141 if (!data.ok) throw new Error(data.error || "Failed to create party"); 1142 1143 status.textContent = "Taking you to your partyâ¦"; 1144 location.href = `${location.origin}/watch/${data.slug}${data.access_code ? `?code=${encodeURIComponent(data.access_code)}` : ""}`; 1145 } catch (err) { 1146 status.textContent = _partyErrorMessage(err) || "Something went wrong creating the party. Please try again."; 1147 btn.disabled = false; 1148 btn.textContent = "Start Party"; 1149 } 1150} 1151 1152async function startLiveStream() { 1153 const btn = document.getElementById("liveStreamBtn"); 1154 const status = document.getElementById("liveStreamStatus"); 1155 1156 btn.disabled = true; 1157 btn.textContent = "Creatingâ¦"; 1158 status.style.display = "block"; 1159 status.textContent = "Creating your streamâ¦"; 1160 1161 try { 1162 const res = await fetch("/api/instant-party", { 1163 method: "POST", 1164 headers: { "Content-Type": "application/json" }, 1165 body: JSON.stringify({ video_url: "pending:livekit-live", guest_name: (!window.CURRENT_USER || !window.CURRENT_USER.sub) ? getGuestName() : null }) 1166 }); 1167 const data = await res.json(); 1168 if (!data.ok) throw new Error(data.error || "Failed to create stream"); 1169 1170 status.textContent = "Taking you to your streamâ¦"; 1171 location.href = `${location.origin}/watch/${data.slug}${data.access_code ? `?code=${encodeURIComponent(data.access_code)}` : ""}`; 1172 } catch (err) { 1173 status.textContent = _partyErrorMessage(err) || "Something went wrong creating the stream. Please try again."; 1174 btn.disabled = false; 1175 btn.textContent = "Go Live"; 1176 } 1177} 1178 1179async function startScreenShare() { 1180 const btn = document.getElementById("screenShareBtn"); 1181 const status = document.getElementById("screenShareStatus"); 1182 1183 // getDisplayMedia() has no mobile browser support at all (Chrome/Safari/ 1184 // Firefox/Samsung Internet on Android and iOS alike) -- _initScreenShareSupport() 1185 // below already relabels the card on load for this case, but a party 1186 // could still be reached via a shared link, so this button click is 1187 // guarded independently too rather than relying on that alone. 1188 if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) { 1189 status.style.display = "block"; 1190 status.textContent = "Screen sharing needs a laptop or desktop browser to start â try again from a computer. Viewers can still join from any device."; 1191 return; 1192 } 1193 1194 btn.disabled = true; 1195 btn.textContent = "Creatingâ¦"; 1196 status.style.display = "block"; 1197 status.textContent = "Creating your partyâ¦"; 1198 1199 try { 1200 // The actual getDisplayMedia() picker only opens once the host lands 1201 // on crooms-chat.html (same pattern as Local File / Live Stream) -- 1202 // this just reserves the room with the "pending:livekit-screen" 1203 // marker so croom.js knows to render the screen-share start UI there. 1204 const res = await fetch("/api/instant-party", { 1205 method: "POST", 1206 headers: { "Content-Type": "application/json" }, 1207 body: JSON.stringify({ video_url: "pending:livekit-screen" }) 1208 }); 1209 const data = await res.json(); 1210 if (!data.ok) throw new Error(data.error || "Failed to create party"); 1211 1212 status.textContent = "Taking you to your partyâ¦"; 1213 location.href = `${location.origin}/watch/${data.slug}${data.access_code ? `?code=${encodeURIComponent(data.access_code)}` : ""}`; 1214 } catch (err) { 1215 status.textContent = _partyErrorMessage(err) || "Something went wrong creating the party. Please try again."; 1216 btn.disabled = false;
1217 btn.textContent = "Start Party"; 1218 } 1219} 1220 1221// Screen sharing (getDisplayMedia) has no mobile browser support -- relabel 1222// the card up front on a phone/tablet rather than letting someone tap 1223// "Start Party" and hit a dead end after already being redirected. Viewers 1224// are unaffected either way; this only concerns who can START a share. 1225function _initScreenShareSupport() { 1226 if (navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia) return; 1227 const desc = document.getElementById("screenShareDesc"); 1228 const btn = document.getElementById("screenShareBtn"); 1229 if (desc) desc.textContent = "Needs a laptop or desktop browser to start â viewers can still join from any device."; 1230 if (btn) { btn.textContent = "Desktop only"; btn.disabled = true; btn.title = "Open VizSurge on a laptop or desktop browser to start a screen share."; } 1231 const tab = document.getElementById("src-tab-screen"); 1232 if (tab) { tab.classList.add("is-desktop-only"); tab.title = "Needs a laptop or desktop browser to start"; } 1233} 1234_initScreenShareSupport(); 1235 1236async function copyLinkToClipboard(url, btn) { 1237 try { 1238 await navigator.clipboard.writeText(url); 1239 const original = btn.innerHTML; 1240 btn.innerHTML = "â"; 1241 setTimeout(() => { btn.innerHTML = original; }, 1500); 1242 } catch { 1243 alert(url); 1244 } 1245} 1246async function shareLink(link, btn) { 1247 const orig = btn.textContent; 1248 // Confirmed root cause of "Share always shows Copied, even on mobile": 1249 // Android's WebView has never implemented navigator.share at all 1250 // (confirmed directly against Chromium's own tracking issue and 1251 // Capacitor's own docs) -- unlike a real mobile browser tab, where it 1252 // works fine. Inside the native app specifically, this reaches for 1253 // Capacitor's own Share plugin instead, which bridges to the actual 1254 // Android share sheet from inside the WebView. Requires 1255 // @capacitor/share to be installed (npm install @capacitor/share && 1256 // npx cap sync) -- falls through to the existing navigator.share path 1257 // if that plugin isn't present, same as before this fix. 1258 const nativeShare = window.Capacitor?.Plugins?.Share; 1259 if (window.Capacitor?.isNativePlatform?.() && nativeShare) { 1260 try { 1261 await nativeShare.share({ title: "Join my watch party on VizSurge", url: link }); 1262 } catch (err) { 1263 // User cancelled the share sheet -- not an error, do nothing. 1264 } 1265 return; 1266 } 1267 if (navigator.share) { 1268 try { 1269 await navigator.share({ title: "Join my watch party on VizSurge", url: link }); 1270 } catch (err) { 1271 // User cancelled the share sheet -- not an error, do nothing. 1272 } 1273 return; 1274 } 1275 // Fallback for browsers without the native Share API 1276 try { 1277 await navigator.clipboard.writeText(link); 1278 btn.textContent = "Copied!"; 1279 setTimeout(() => { btn.textContent = orig; }, 1500); 1280 } catch { 1281 alert(link); 1282 } 1283} 1284 1285function timeAgo(isoString) { 1286 const diffMs = Date.now() - new Date(isoString).getTime(); 1287 const mins = Math.floor(diffMs / 60000); 1288 if (mins < 1) return "just now"; 1289 if (mins < 60) return `${mins}m ago`; 1290 const hrs = Math.floor(mins / 60); 1291 if (hrs < 24) return `${hrs}h ago`; 1292 return `${Math.floor(hrs / 24)}d ago`; 1293} 1294 1295function _escapeHtmlLocal(s) { 1296 const d = document.createElement("div"); 1297 d.textContent = s; 1298 return d.innerHTML; 1299} 1300 1301// The three "pending:*" sentinel values are meant for croom.js, not for a 1302// human to read -- without this, "Your Parties" would show the literal 1303// string "pending:livekit-screen" etc. as the party's video source. 1304function _friendlyPartySource(url) { 1305 if (!url) return ""; 1306 if (url === "pending:livekit-video") return "LOCAL FILE"; 1307 if (url === "pending:livekit-live") return "LIVE STREAM"; 1308 if (url === "pending:livekit-screen") return "SCREEN SHARE"; 1309 1310 try { 1311 const host = new URL(url).hostname.toLowerCase().replace(/^www\./, ""); 1312 const known = { 1313 "youtube.com": "YOUTUBE", 1314 "youtu.be": "YOUTUBE", 1315 "music.youtube.com": "YOUTUBE MUSIC", 1316 "dailymotion.com": "DAILYMOTION", 1317 "vimeo.com": "VIMEO", 1318 "ted.com": "TED", 1319 "twitch.tv": "TWITCH", 1320 "archive.org": "INTERNET ARCHIVE", 1321 "drive.google.com": "GOOGLE DRIVE" 1322 }; 1323 if (known[host]) return known[host]; 1324 const parts = host.split("."); 1325 return (parts.length >= 2 ? parts[parts.length - 2] : host).toUpperCase(); 1326 } catch { 1327 return String(url).replace(/^https?:\/\//i, "").split("/")[0].toUpperCase(); 1328 } 1329} 1330 1331 1332function closeInstantPartyMenus() { 1333 document.querySelectorAll(".instant-party-overflow-menu").forEach(menu => { 1334 menu.style.display = "none"; 1335 }); 1336} 1337 1338function toggleInstantPartyMenu(event, partyId) { 1339 event?.stopPropagation?.(); 1340 const menu = document.querySelector(`.instant-party-overflow-menu[data-
1340party-menu="${partyId}"]`); 1341 if (!menu) return; 1342 const wasOpen = menu.style.display !== "none"; 1343 closeInstantPartyMenus(); 1344 if (!wasOpen) menu.style.display = "block"; 1345} 1346 1347document.addEventListener("click", (event) => { 1348 if (!event.target.closest(".instant-party-more-wrap")) { 1349 closeInstantPartyMenus(); 1350 } 1351}); 1352 1353window.__instantPartiesById = window.__instantPartiesById || {}; 1354 1355 1356// Fires on a real YouTube thumbnail 404 -- swap the broken <img> for the 1357// same generic icon a non-YouTube source gets. (_pcSourceInfo, PC_ICON_SVG 1358// and PC_ACTION_ICON_SVG below come from croom.js, already loaded on this 1359// page via
1359<script src="/static/croom.js"> for editRoomName() etc.) 1360function _instThumbFallback(img) { 1361 const thumb = img.closest(".vs-card-thumb"); 1362 if (!thumb) return; 1363 thumb.classList.add("is-generic"); 1364 const icon = document.createElement("span"); 1365 icon.className = "vs-card-thumb-icon"; 1366 icon.innerHTML = PC_ICON_SVG.video; 1367 thumb.insertBefore(icon, thumb.firstChild); 1368 img.remove(); 1369 thumb.querySelector(".vs-card-thumb-play")?.remove(); 1370} 1371 1372function _instThumbHtml(info, playlistCount) { 1373 const tag = playlistCount > 1 ? `<span class="vs-card-thumb-tag">${playlistCount} videos</span>` : ""; 1374 if (info.kind === "youtube" && info.youtubeId) { 1375 return `<div class="vs-card-thumb"> 1376 <img loading="lazy" alt="" src="https://i.ytimg.com/vi/${info.youtubeId}/hqdefault.jpg" onerror="_instThumbFallback(this)"> 1377 <span class="vs-card-thumb-play">${PC_ACTION_ICON_SVG.play}</span> 1378 ${tag} 1379 </div>`; 1380 } 1381 return `<div class="vs-card-thumb is-generic"><span class="vs-card-thumb-icon">${PC_ICON_SVG[info.kind] || PC_ICON_SVG.video}</span>${tag}</div>`; 1382} 1383 1384 1385async function loadMineList() { 1386 const list = document.getElementById("mineList"); 1387 const countEl = document.getElementById("mineCount"); 1388 try { 1389 const res = await fetch("/api/instant-parties/mine"); 1390 const data = await res.json(); 1391 const parties = (data.ok && data.parties) || []; 1392 1393 countEl.textContent = parties.length ? `${parties.length}` : ""; 1394 1395 // Keep the API response in memory so card actions can reuse it 1396 // immediately without another network request. 1397 window.__instantPartiesById = {}; 1398 parties.forEach(p => { 1399 window.__instantPartiesById[String(p.id)] = p; 1400 }); 1401 1402 if (!parties.length) { 1403 list.innerHTML = '<div class="empty-state">You haven\'t created any instant parties yet.</div>'; 1404 return; 1405 } 1406 1407 list.innerHTML = parties.map(p => { 1408 const joinUrl = `${location.origin}/watch/${p.slug}${p.access_code ? `?code=${encodeURIComponent(p.access_code)}` : ""}`; 1409 // _pcSourceInfo / PC_ICON_SVG / PC_ACTION_ICON_SVG come from croom.js 1410 // (already loaded on this page); _pcIsVideoUrlParty is defined there too. 1411 const info = _pcSourceInfo(p.video_url); 1412 const isVideoUrlParty = _pcIsVideoUrlParty(p); 1413 return ` 1414 <div class="vs-room-card" data-room-id="${p.id}" onclick="location.href='${joinUrl}'" style="cursor:pointer;"> 1415 <div class="vs-card-top"> 1416 ${_instThumbHtml(info, Number(p.playlist_count || 0))} 1417 <div class="vs-card-body"> 1418 <div class="vs-card-row1"> 1419 <div class="vs-card-name">${_escapeHtmlLocal(p.name || "Instant Party")}</div> 1420 <span class="vs-badge ${p.room_type === "open" ? "vs-badge-pub" : "vs-badge-priv"}">${p.room_type === "open" ? "Public" : "Private"}</span> 1421 </div> 1422 <div class="vs-card-owner-row"> 1423 <span class="vs-card-owner">You</span> 1424 <span class="vs-card-sep">·</span> 1425 <span class="vs-card-owner" style="color:rgba(224,231,255,.45);font-weight:500;">Created ${timeAgo(p.created_at)}</span> 1426 </div> 1427 <div class="vs-card-pills"> 1428 <span class="vs-card-pill">${PC_ICON_SVG[info.kind] || PC_ICON_SVG.video}<span>${_escapeHtmlLocal(info.label)}</span></span> 1429 ${Number(p.playlist_count || 0) > 1 ? `<span class="vs-card-pill vs-card-pill-playlist">${Number(p.playlist_count)} videos</span>` : ""} 1430 </div> 1431 </div> 1432 </div> 1433 <div class="vs-card-actions"> 1434 <button type="button" class="vs-card-primary-btn" onclick="event.stopPropagation(); location.href='${joinUrl}'">${PC_ACTION_ICON_SVG.play}<span>Open</span></button> 1435 <div class="vs-card-icon-group"> 1436 <button type="button" class="vs-card-icon-btn" aria-label="Share" title="Share" 1437 onclick="event.stopPropagation(); shareLink(\`${joinUrl}\`, this)">${PC_ACTION_ICON_SVG.share}</button> 1438 <button type="button" class="vs-card-icon-btn" aria-label="Share to Instagram Story" title="Share to Instagram Story" 1439 onclick="event.stopPropagation(); shareInstantPartyToStory(${p.id})">${PC_ACTION_ICON_SVG.instagram}</button> 1440 <button type="button" class="vs-card-icon-btn" aria-label="Copy link" title="Copy link" 1441 onclick="event.stopPropagation(); copyLinkToClipboard(\`${joinUrl}\`, this)">${PC_ACTION_ICON_SVG.copy}</button> 1442 <div class="croom-more-wrap instant-party-more-wrap"> 1443 <button class="vs-card-icon-btn" type="button" 1444 aria-label="More options" title="More options" 1445 onclick="event.stopPropagation(); toggleInstantPartyMenu(event, ${p.id})">⋮</button> 1446 <div class="croom-overflow-menu instant-party-overflow-menu" 1447 data-party-menu="${p.id}" style="display:none;"> 1448 ${isVideoUrlParty ? `<button class="overflow-item" type="button" 1449 onclick="event.stopPropagation(); closeInstantPartyMenus(); openInstantPlaylist(${p.id})"> 1450 Playlist 1451 </button>` : ""} 1452 <button class="overflow-item" type="button" 1453 onclick="event.stopPropagation(); closeInstantPartyMenus(); editInstantParty(${p.id})"> 1454 Edit 1455 </button> 1456 <button class="overflow-item danger" type="button" 1457 onclick="event.stopPropagation(); closeInstantPartyMenus(); deleteParty(${p.id}, this)"> 1458 Delete 1459 </button> 1460 </div> 1461 </div> 1462 </div> 1463 </div> 1464 </div> 1465 `; 1466 }).join(""); 1467 1468 1469 1470 // Prepare Story images in the background. This is intentionally after 1471 // the cards are rendered, so it never delays the Instant page itself. 1472 window.__instantStoryBlobCache = window.__instantStoryBlobCache || {};
1473 parties.forEach(p => { 1474 const key = String(p.id); 1475 if (!window.__instantStoryBlobCache[key]) { 1476 const storyRoom = { 1477 ...p, 1478 party_status: "live", 1479 owner_name: p.owner_name || "You", 1480 thumbnail_url: p.thumbnail_url || null, 1481 room_type: "private", 1482 room_category: "watch" 1483 }; 1484 window.__instantStoryBlobCache[key] = 1485 _generateStoryCardBlob(storyRoom).catch(err => { 1486 console.warn("[instant-story] background generation failed", err); 1487 return null; 1488 }); 1489 } 1490 }); 1491 } catch { 1492 list.innerHTML = '<div class="empty-state">Couldn\'t load your parties right now.</div>'; 1493 } 1494} 1495 1496// Hook for saveRoomEdit()'s existing post-save refresh (croom.js calls 1497// refreshWatchMine?.() after a successful edit for any room_category 1498// "watch" room, which every instant party is) -- defining it here means 1499// the list refreshes correctly after an edit with zero changes needed to 1500// croom.js itself; the optional-chaining call there already handles it 1501// safely not existing on pages that don't define it. 1502function refreshWatchMine() { 1503 loadMineList(); 1504} 1505 1506// Edit -- fetches the full room first (the list's own data is 1507// deliberately minimal: id/name/video_url/created_at only, nowhere near 1508// enough for the edit overlay) via the same endpoint crooms-chat.html's 1509// own page-load relies on, which already correctly handles a guest-host 1510// the same as a signed-in owner. 1511async function editInstantParty(roomId) { 1512 try { 1513 const res = await apiFetch(`/api/crooms/${roomId}`); 1514 if (!res || !res.ok || !res.room) { 1515 alert("Couldn't load this party's details. Please try again."); 1516 return; 1517 } 1518 editRoomName(res.room); 1519 } catch (err) { 1520 alert("Couldn't load this party's details. Please try again."); 1521 } 1522} 1523 1524async function deleteParty(roomId, btn) { 1525 if (!confirm("Delete this party? This can't be undone.")) return; 1526 btn.disabled = true; 1527 try { 1528 const res = await fetch(`/api/instant-party/${roomId}`, { method: "DELETE" }); 1529 const data = await res.json(); 1530 if (!data.ok) throw new Error(data.error || "Failed to delete"); 1531 loadMineList(); 1532 } catch { 1533 alert("Couldn't delete this party â please try again."); 1534 btn.disabled = false; 1535 } 1536} 1537 1538document.getElementById("videoUrlInput").addEventListener("keydown", (e) => { 1539 if (e.key === "Enter") createInstantParty(); 1540}); 1541 1542loadMineList(); 1543 1544let __instantPlaylistRoomId = null; 1545let __instantPlaylistItems = []; 1546 1547function _instantPlaylistEscape(v) { 1548 return String(v ?? "").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""); 1549} 1550 1551async function openInstantPlaylist(roomId) { 1552 __instantPlaylistRoomId = Number(roomId); 1553 const overlay = document.getElementById("instant-playlist-overlay"); 1554 overlay?.classList.add("open"); 1555 overlay?.setAttribute("aria-hidden","false"); 1556 document.body.style.overflow = "hidden"; 1557 await loadInstantPlaylist(); 1558} 1559 1560function closeInstantPlaylist() { 1561 const overlay = document.getElementById("instant-playlist-overlay"); 1562 // Move focus out of the overlay before aria-hidden is applied. Otherwise 1563 // Chrome correctly warns that a focused descendant is being hidden from 1564 // assistive technology. 1565 const active = document.activeElement; 1566 if (overlay && active && overlay.contains(active)) { 1567 active.blur(); 1568 } 1569 overlay?.classList.remove("open"); 1570 overlay?.setAttribute("aria-hidden","true"); 1571 document.body.style.overflow = ""; 1572 __instantPlaylistRoomId = null; 1573} 1574 1575async function loadInstantPlaylist() { 1576 const itemsEl = document.getElementById("instant-playlist-items"); 1577 const statusEl = document.getElementById("instant-playlist-status"); 1578 if (!__instantPlaylistRoomId) return; 1579 itemsEl.innerHTML = '<div class="instant-playlist-loading">Loading playlistâ¦</div>'; 1580 statusEl.textContent = ""; 1581 try { 1582 const res = await fetch(`/api/crooms/${__instantPlaylistRoomId}/playlist`, {credentials:"include"}); 1583 const text = await res.text(); 1584 let data; 1585 try { data = JSON.parse(text); } catch { throw new Error(`HTTP ${res.status}: ${text.slice(0,120) || res.statusText}`); } 1586 if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); 1587 if (!data.ok) throw new Error(data.error || "Could not load playlist"); 1588 __instantPlaylistItems = data.items || []; 1589 renderInstantPlaylist(data); 1590 } catch (e) { 1591 statusEl.textContent = e.message === "forbidden" 1592 ? "Only the party host can edit this playlist." 1593 : "Couldn't load the playlist. Please try again."; 1594 itemsEl.innerHTML = ""; 1595 } 1596} 1597 1598function renderInstantPlaylist(data) { 1599 const itemsEl = document.getElementById("instant-playlist-items"); 1600 const current = Number(data.current_position || 1); 1601 if (!data.items?.length) { 1602 itemsEl.innerHTML = '<div class="instant-playlist-loading">No videos in this playlist.</div>'; 1603 return; 1604 } 1605 itemsEl.innerHTML = data.items.map((item, idx) => ` 1606 <div class="instant-playlist-item ${Number(item.position)===current ? "current" : ""}"> 1607 <div class="instant-playlist-num">${item.position}</div> 1608 <div class="instant-playlist-info"> 1609 <div class="instant-playlist-item-title"> 1610 ${_instantPlaylistEscape(item.title || "Video")} 1611 ${Number(item.position)===current ? '<span class="instant-playlist-badge">Playing</span>' : ""} 1612 </div> 1613 <div class="instant-playlist-item-url" title="${_instantPlaylistEscape(item.video_url)}">${_instantPlaylistEscape(item.video_url)}</div> 1614 </div> 1615 <div class="instant-playlist-actions"> 1616 ${idx > 0 ? `<button title="Move up" onclick="moveInstantPlaylistItem(${item.id},-1)">â</button>` : ""} 1617 ${idx < data.items.length-1 ? `<button title="Move down" onclick="moveInstantPlaylistItem(${item.id},1)">â</button>` : ""} 1618 ${data.items.length > 1 ? `<button class="remove" title="Remove" onclick="removeInstantPlaylistItem(${item.id})">Ã</button>` : ""} 1619 </div> 1620 </div> 1621 `).join(""); 1622} 1623 1624async function addInstantPlaylistItem() { 1625 const input = document.getElementById("instant-playlist-url"); 1626 const btn = document.getElementById("instant-playlist-add-btn"); 1627 const url = input.value.trim(); 1628 if (!url || !__instantPlaylistRoomId) { input.focus(); return; } 1629 btn.disabled = true; 1630 btn.textContent = "Addingâ¦"; 1631 try { 1632 const res = await fetch(`/api/crooms/${__instantPlaylistRoomId}/playlist/items`, { 1633 method:"POST", credentials:"include", 1634 headers:{"Content-Type":"application/json"}, 1635 body:JSON.stringify({video_url:url}) 1636 }); 1637 const text = await res.text(); 1638 let data; 1639 try { data = JSON.parse(text); } catch { throw new Error(`HTTP ${res.status}: ${text.slice(0,120) || res.statusText}`); } 1640 if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); 1641 if (!data.ok) throw new Error(data.error || "Couldn't add video"); 1642 input.value = ""; 1643 await loadInstantPlaylist(); 1644 } catch(e) { 1645 document.getElementById("instant-playlist-status").textContent = 1646 e.message === "unsupported_source" 1647 ? "That video link isn't supported." 1648 : (e.message === "playlist_full" ? "This playlist is full (100 videos max)." 1649 : (e.message === "forbidden" ? "Only the party host can edit this playlist." : "Couldn't add that video.")); 1650 } finally { 1651 btn.disabled = false;
1652 btn.textContent = "+ Add Video"; 1653 } 1654} 1655 1656async function removeInstantPlaylistItem(itemId) { 1657 if (!confirm("Remove this video from the playlist?")) return; 1658 try { 1659 const res = await fetch(`/api/crooms/${__instantPlaylistRoomId}/playlist/items/${itemId}`, { 1660 method:"DELETE", credentials:"include" 1661 }); 1662 const data = await res.json(); 1663 if (!data.ok) throw new Error(data.error || "Couldn't remove video"); 1664 await loadInstantPlaylist(); 1665 } catch(e) { 1666 document.getElementById("instant-playlist-status").textContent = "Couldn't remove that video."; 1667 } 1668} 1669 1670async function moveInstantPlaylistItem(itemId, direction) { 1671 const items = [...__instantPlaylistItems].sort((a,b)=>a.position-b.position); 1672 const idx = items.findIndex(x => Number(x.id) === Number(itemId)); 1673 const swap = idx + direction; 1674 if (idx < 0 || swap < 0 || swap >= items.length) return; 1675 [items[idx], items[swap]] = [items[swap], items[idx]]; 1676 try { 1677 const res = await fetch(`/api/crooms/${__instantPlaylistRoomId}/playlist/reorder`, { 1678 method:"PUT", credentials:"include", 1679 headers:{"Content-Type":"application/json"}, 1680 body:JSON.stringify({items:items.map(x=>x.id)}) 1681 }); 1682 const data = await res.json(); 1683 if (!data.ok) throw new Error(data.error || "Couldn't reorder playlist"); 1684 // The reorder API returns the authoritative order. Reload the overlay 1685 // from the server rather than rendering the transient response object. 1686 // This keeps the DOM and local cache in sync and prevents rows from 1687 // disappearing while the positions are being reassigned. 1688 await loadInstantPlaylist(); 1689 } catch(e) { 1690 document.getElementById("instant-playlist-status").textContent = "Couldn't reorder the playlist."; 1691 } 1692} 1693 1694document.getElementById("instant-playlist-url")?.addEventListener("keydown", e => { 1695 if (e.key === "Enter") addInstantPlaylistItem(); 1696}); 1697 1698 1699// ââ Browser extension hand-off ââââââââââââââââââââââââââââââââââââââââââ 1700// When arriving from the VizSurge browser extension (or any external link) 1701// with a video_url query param, auto-fill and auto-submit the create form 1702// so the whole flow is genuinely one-click from wherever the video lives. 1703(function () { 1704 const params = new URLSearchParams(location.search); 1705 const incomingUrl = params.get("video_url"); 1706 if (incomingUrl) { 1707 const input = document.getElementById("videoUrlInput"); 1708 if (input) { 1709 input.value = incomingUrl; 1710 createInstantParty(); 1711 } 1712 // Strip the query params so a page refresh doesn't silently try to 1713 // create a second party with the same URL. 1714 history.replaceState({}, document.title, location.origin + location.pathname); 1715 } 1716})(); 1717async function shareInstantPartyToStory(partyId) { 1718 // Reuse the party object already loaded for the Instant page. The 1719 // previous implementation made a second /api/crooms/<id> request here, 1720 // which was the main reason the Story action felt slower than Watch/Public. 1721 const cached = window.__instantPartiesById?.[String(partyId)]; 1722 if (!cached) { 1723 alert("Party details are still loading. Please try again."); 1724 return; 1725 } 1726 1727 // The Instant list endpoint returns the fields needed for the card. 1728 // Supply the additional Story-renderer defaults without another request. 1729 const room = { 1730 ...cached, 1731 party_status: "live", 1732 owner_name: cached.owner_name || "You", 1733 thumbnail_url: cached.thumbnail_url || null, 1734 room_type: "private", 1735 room_category: "watch" 1736 }; 1737 1738 const shareUrl = 1739 `${location.origin}/watch/${room.slug}${room.access_code ? `?code=${encodeURIComponent(room.access_code)}` : ""}`; 1740 1741 let blob; 1742 try { 1743 const cachedStory = window.__instantStoryBlobCache?.[String(partyId)]; 1744 blob = cachedStory 1745 ? await cachedStory 1746 : await _generateStoryCardBlob(room); 1747 if (!blob) throw new Error("Story image unavailable"); 1748 } catch (e) { 1749 console.error("[ig-story] card generation failed", e); 1750 alert("Couldn't generate the share image. Please try again."); 1751 return; 1752 } 1753 1754 const file = new File([blob], "vizsurge-party.png", { type: "image/png" }); 1755 1756 // Prompt 1 â same instructional prompt shown by Watch/Public. 1757 // It deliberately happens before the native share sheet (Prompt 2). 1758 try { 1759 await navigator.clipboard.writeText(shareUrl); 1760 } catch (e) { 1761 // Clipboard access can be denied/unavailable -- not fatal. 1762 } 1763 1764 alert(
1765 "Image ready, and your join link is copied!\\n\\n" + 1766 "Once Instagram opens: tap the sticker icon, choose \"Link,\" and paste â that makes it tappable on your Story. " + 1767 "(The link is also printed on the image itself either way.)" 1768 ); 1769 1770 // Prompt 2 â native browser/OS share sheet. 1771 if (navigator.canShare && navigator.canShare({ files: [file] })) { 1772 try { 1773 await navigator.share({ 1774 files: [file], 1775 title: `Join "${room.name}" on VizSurge`, 1776 text: `Join "${room.name}" on VizSurge â ${shareUrl}`, 1777 }); 1778 } catch (e) { 1779 if (e && e.name !== "AbortError") { 1780 console.error("[ig-story] share failed", e); 1781 } 1782 } 1783 return; 1784 } 1785 1786 const blobUrl = URL.createObjectURL(blob); 1787 const a = document.createElement("a"); 1788 a.href = blobUrl; 1789 a.download = "vizsurge-party.png"; 1790 document.body.appendChild(a); 1791 a.click(); 1792 a.remove(); 1793 setTimeout(() => URL.revokeObjectURL(blobUrl), 10000); 1794 alert("Image saved. Open Instagram, start a new Story, and choose this image from your gallery."); 1795} 1796 1797function _generateStoryCardBlob(room) { 1798 return new Promise(async (resolve, reject) => { 1799 try { 1800 const W = 1080, H = 1920; 1801 const canvas = document.createElement("canvas"); 1802 canvas.width = W; 1803 canvas.height = H; 1804 const ctx = canvas.getContext("2d"); 1805 1806 // Background: dark base + two soft radial glows, matching the 1807 // rest of the app's dark indigo/cyan palette. 1808 ctx.fillStyle = "#05060f"; 1809 ctx.fillRect(0, 0, W, H); 1810 1811 const glow1 = ctx.createRadialGradient(W * 0.18, H * 0.05, 0, W * 0.18, H * 0.05, W * 0.75); 1812 glow1.addColorStop(0, "rgba(139,92,246,.35)"); 1813 glow1.addColorStop(1, "rgba(139,92,246,0)"); 1814 ctx.fillStyle = glow1; 1815 ctx.fillRect(0, 0, W, H); 1816 1817 const glow2 = ctx.createRadialGradient(W * 0.9, H * 0.92, 0, W * 0.9, H * 0.92, W * 0.7); 1818 glow2.addColorStop(0, "rgba(34,211,238,.20)"); 1819 glow2.addColorStop(1, "rgba(34,211,238,0)"); 1820 ctx.fillStyle = glow2; 1821 ctx.fillRect(0, 0, W, H); 1822 1823 const base = ctx.createLinearGradient(0, 0, W * 0.5, H); 1824 base.addColorStop(0, "rgba(10,12,30,.0)"); 1825 base.addColorStop(1, "rgba(2,3,9,.65)"); 1826 ctx.fillStyle = base; 1827 ctx.fillRect(0, 0, W, H); 1828 1829 // Safe zones: top ~14% (Instagram's own profile/username chrome) 1830 // and bottom ~16% (its reply bar / where a link sticker, if the 1831 // viewer's Instagram version offers one, tends to land) are kept 1832 // clear of anything essential. 1833 const safeTop = H * 0.14; 1834 const safeBottom = H * 0.84; 1835 const contentCenterX = W / 2; 1836 1837 // --- Logo (bolt + wordmark, provided as one image) --- 1838 try { 1839 const logo = await _loadImage("/static/vizsurge-logo.webp"); 1840 const logoH = 130, logoW = logoH * (logo.width / logo.height); 1841 ctx.drawImage(logo, contentCenterX - logoW / 2, 46, logoW, logoH); 1842 } catch (e) { 1843 // Missing logo asset shouldn't block the whole card -- fall 1844 // back to a plain text wordmark instead. 1845 ctx.fillStyle = "#ffffff"; 1846 ctx.font = "800 34px Manrope, sans-serif"; 1847 ctx.textAlign = "center"; 1848 ctx.fillText("VizSurge", contentCenterX, 90); 1849 } 1850 1851 let cursorY = safeTop + 60; 1852 1853 // --- LIVE pill (only when the party is actually live) --- 1854 if (room.party_status === "live") { 1855 ctx.font = "800 26px Manrope, sans-serif"; 1856 const pillText = "â LIVE NOW"; 1857 const pillW = ctx.measureText(pillText).width + 60; 1858 const pillH = 56; 1859 _roundRect(ctx, contentCenterX - pillW / 2, cursorY, pillW, pillH, pillH / 2); 1860 ctx.fillStyle = "rgba(236,72,153,.16)"; 1861 ctx.fill(); 1862 ctx.lineWidth = 2; 1863 ctx.strokeStyle = "rgba(236,72,153,.5)"; 1864 ctx.stroke(); 1865 ctx.fillStyle = "#ff9ecb"; 1866 ctx.textBaseline = "middle"; 1867 ctx.fillText(pillText, contentCenterX, cursorY + pillH / 2 + 2); 1868 ctx.textBaseline = "alphabetic"; // reset -- the kicker/name text below assumes this 1869 cursorY += pillH + 46; 1870 } else { 1871 cursorY += 20; 1872 } 1873 1874 // --- Kicker --- 1875 ctx.font = "800 24px Manrope, sans-serif"; 1876 ctx.fillStyle = "#8dd7ea"; 1877 ctx.textAlign = "center"; 1878 ctx.textBaseline = "alphabetic"; 1879 ctx.fillText("W A T C H P A R T Y", contentCenterX, cursorY); 1880 cursorY += 56; 1881 1882 // --- Party name, auto-fit up to 2 lines --- 1883 const maxTextWidth = W - 160; 1884 const nameLines = _fitTextLines(ctx, room.name || "Watch Party", maxTextWidth, 78, 52, 2); 1885 ctx.fillStyle = "#ffffff";
1886 nameLines.forEach((line, i) => { 1887 ctx.font = `700 ${nameLines.fontSize}px Fraunces, Georgia, serif`; 1888 ctx.fillText(line, contentCenterX, cursorY + i * (nameLines.fontSize * 1.18)); 1889 }); 1890 cursorY += nameLines.length * (nameLines.fontSize * 1.18) + 34; 1891 1892 // --- Host line --- 1893 if (room.owner_name) { 1894 ctx.font = "600 30px Manrope, sans-serif"; 1895 ctx.fillStyle = "#aab0d6"; 1896 ctx.fillText(`Hosted by ${room.owner_name}`, contentCenterX, cursorY); 1897 cursorY += 70; 1898 } else { 1899 cursorY += 20; 1900 } 1901 1902 // --- Poster (if this room has a video thumbnail) --- 1903 const posterSize = 430; 1904 const posterBottom = cursorY + posterSize; 1905 if (room.thumbnail_url && posterBottom < safeBottom - 140) { 1906 try { 1907 const poster = await _loadImage(room.thumbnail_url, true); 1908 const px = contentCenterX - posterSize / 2; 1909 ctx.save(); 1910 _roundRect(ctx, px, cursorY, posterSize, posterSize, 44); 1911 ctx.clip(); 1912 // Cover-fit crop 1913 const scale = Math.max(posterSize / poster.width, posterSize / poster.height); 1914 const sw = posterSize / scale, sh = posterSize / scale; 1915 const sx = (poster.width - sw) / 2, sy = (poster.height - sh) / 2; 1916 ctx.drawImage(poster, sx, sy, sw, sh, px, cursorY, posterSize, posterSize); 1917 ctx.restore(); 1918 ctx.lineWidth = 2; 1919 ctx.strokeStyle = "rgba(255,255,255,.14)"; 1920 _roundRect(ctx, px, cursorY, posterSize, posterSize, 44); 1921 ctx.stroke(); 1922 cursorY += posterSize + 56; 1923 } catch (e) { 1924 // Thumbnail failed to load (dead link, CORS) -- just skip it 1925 // rather than blocking the rest of the card. 1926 cursorY += 30; 1927 } 1928 } 1929 1930 // --- Join URL, printed directly on the card (see note above on 1931 // why this can't rely on a tappable sticker) --- 1932 const joinUrl = instantPartyUrl.replace(/^https?:\/\//, ""); 1933 ctx.font = "700 30px Manrope, sans-serif"; 1934 const urlPillW = ctx.measureText(joinUrl).width + 70; 1935 const urlPillH = 64; 1936 const urlPillY = Math.min(cursorY + 20, safeBottom - urlPillH - 10); 1937 _roundRect(ctx, contentCenterX - urlPillW / 2, urlPillY, urlPillW, urlPillH, urlPillH / 2); 1938 ctx.fillStyle = "rgba(255,255,255,.08)"; 1939 ctx.fill(); 1940 ctx.lineWidth = 2; 1941 ctx.strokeStyle = "rgba(255,255,255,.18)"; 1942 ctx.stroke(); 1943 ctx.fillStyle = "#ffffff"; 1944 ctx.textBaseline = "middle"; 1945 ctx.fillText(joinUrl, contentCenterX, urlPillY + urlPillH / 2 + 2); 1946 1947 canvas.toBlob((blob) => { 1948 if (blob) resolve(blob); else reject(new Error("toBlob returned null")); 1949 }, "image/png"); 1950 } catch (err) { 1951 reject(err); 1952 } 1953 }); 1954} 1955 1956function _loadImage(src, crossOrigin) { 1957 return new Promise((resolve, reject) => { 1958 const img = new Image(); 1959 if (crossOrigin) img.crossOrigin = "anonymous"; 1960 img.onload = () => resolve(img); 1961 img.onerror = reject; 1962 img.src = src; 1963 }); 1964} 1965 1966function _roundRect(ctx, x, y, w, h, r) { 1967 ctx.beginPath(); 1968 ctx.moveTo(x + r, y); 1969 ctx.arcTo(x + w, y, x + w, y + h, r); 1970 ctx.arcTo(x + w, y + h, x, y + h, r); 1971 ctx.arcTo(x, y + h, x, y, r); 1972 ctx.arcTo(x, y, x + w, y, r); 1973 ctx.closePath(); 1974} 1975 1976// Shrinks the font size (down to a floor) and wraps onto up to 1977// maxLines lines until the given text fits maxWidth -- handles both 1978// short party names (rendered large) and long ones (shrunk and/or 1979// wrapped) without ever overflowing the card. 1980function _fitTextLines(ctx, text, maxWidth, startSize, minSize, maxLines) { 1981 let size = startSize; 1982 let lines; 1983 while (size >= minSize) { 1984 ctx.font = `700 ${size}px Fraunces, Georgia, serif`; 1985 lines = _wrapText(ctx, text, maxWidth, maxLines); 1986 if (lines.every(l => ctx.measureText(l).width <= maxWidth)) break; 1987 size -= 4; 1988 } 1989 lines.fontSize = size; 1990 return lines; 1991} 1992 1993function _wrapText(ctx, text, maxWidth, maxLines) { 1994 const words = text.split(/\s+/); 1995 const lines = []; 1996 let current = ""; 1997 for (const word of words) { 1998 const attempt = current ? `${current} ${word}` : word; 1999 if (ctx.measureText(attempt).width > maxWidth && current) { 2000 lines.push(current); 2001 current = word; 2002 if (lines.length === maxLines - 1) break; 2003 } else { 2004 current = attempt; 2005 } 2006 } 2007 if (current) lines.push(current); 2008 // If there's leftover text beyond maxLines, ellipsize the last line 2009 // rather than silently dropping it. 2010 const consumed = lines.join(" ").split(/\s+/).length; 2011 if (consumed < words.length) { 2012 let last = lines[lines.length - 1]; 2013 while (ctx.measureText(last + "â¦").width > maxWidth && last.length > 1) { 2014 last = last.slice(0, -1); 2015 } 2016 lines[lines.length - 1] = last.replace(/\s+$/, "") + "â¦"; 2017 } 2018 return lines; 2019} 2020 2021 2022</script>
2022 2023 2024<!-- ââ Standard bottom nav âââââââââââââââââââââââââââ --> 2025<nav class="vs-bottom-nav" role="tablist" aria-label="Main navigation"> 2026 <div class="vs-bnav-glow" aria-hidden="true"></div> 2027 <button class="vs-bnav-tab active" id="vsTabInstant" role="tab" onclick="window.location.href='instant.html'"> 2028 <span class="vs-bnav-icon"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></span> 2029 <span class="vs-bnav-label">Instant</span> 2030 </button> 2031 <button class="vs-bnav-tab" id="vsTabPublic" role="tab" onclick="window.location.href='public.html'"> 2032 <span class="vs-bnav-icon"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span> 2033 <span class="vs-bnav-label">Public</span> 2034 </button> 2035 <button class="vs-bnav-tab" role="tab" onclick="window.location.href='watch.html'"> 2036 <span class="vs-bnav-icon"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg></span> 2037 <span class="vs-bnav-label">Signature</span> 2038 </button> 2039 <button class="vs-bnav-tab" role="tab" onclick="window.location.href='social.html'" style="position:relative;"> 2040 <span class="vs-bnav-icon"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3"/><path d="M2.5 20.5c0-3.6 2.9-6.5 6.5-6.5s6.5 2.9 6.5 6.5"/><circle cx="17" cy="9" r="2.6"/><path d="M14.5 14.8c2.6.3 4.6 2.5 4.6 5.2"/></svg> 2041 <span id="tab-social-dot" style="display:none;position:absolute;top:-2px;right:-2px;width:8px;height:8px;border-radius:50%;background:#ec4899;"></span> 2042 </span> 2043 <span class="vs-bnav-label">Social</span> 2044 </button> 2045 <button class="vs-bnav-tab" role="tab" onclick="window.location.href='index.html'"> 2046 <span class="vs-bnav-icon"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="10" x="3" y="11" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" x2="8" y1="16" y2="16"/><line x1="16" x2="16" y1="16" y2="16"/></svg></span> 2047 <span class="vs-bnav-label">Bots & Extensions</span> 2048 </button> 2049</nav> 2050
2051<script src="/static/vs-header.js"></script>
vendor: 1 bytes, line 2051
2051
2052<script src="/static/vs-profile.js"></script>
vendor: 1 bytes, line 2052
2052
2053<script src="/static/join-party.js?v=1"></script>
2053 2054 2055 2056<div id="room-edit-overlay" class="vs-overlay vs-party-overlay" data-overlay-kind="edit" style="display:none;" onclick="closeRoomEdit()"> 2057 <div class="vs-sheet" onclick="event.stopPropagation()"> 2058 <div class="vs-sheet-handle"></div> 2059 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:18px;"> 2060 <div class="vs-overhaul-title-wrap"><span class="vs-overhaul-icon">â¦</span><div><div class="vs-overhaul-kicker">PARTY SETTINGS</div><h3 style="margin-bottom:0;">Edit Watch Party</h3><p class="vs-overhaul-subtitle">Update your party without losing the room.</p></div></div> 2061 <button onclick="event.stopPropagation();closeRoomEdit()" 2062 style="background:none;border:none;color:rgba(255,255,255,.5);font-size:22px;cursor:pointer;padding:2px 8px;line-height:1;">â</button> 2063 </div> 2064 2065 <!-- Party Name --> 2066 <div class="vs-field vs-field-featured"> 2067 <label><span>Party Name</span><em>Required</em></label> 2068 <input id="edit-room-name" placeholder="Give your party a name"/> 2069 </div> 2070 2071 <!-- Video Source --> 2072 <div class="vs-field" id="edit-source-field"> 2073 <label>Video Source</label> 2074 <div class="vs-source-grid"> 2075 <label class="vs-radio-opt"> 2076 <input type="radio" name="edit-room-source" value="url" checked onchange="_onEditSourceChange()"> 2077 ð Video URL 2078 </label> 2079 <label class="vs-radio-opt" id="edit-source-streaming-opt"> 2080 <input type="radio" name="edit-room-source" value="streaming" onchange="_onEditSourceChange()"> 2081 ð¬ Browse Videos 2082 </label> 2083 <label class="vs-radio-opt"> 2084 <input type="radio" name="edit-room-source" value="local" onchange="_onEditSourceChange()"> 2085 ð± Local File 2086 </label> 2087 <label class="vs-radio-opt"> 2088 <input type="radio" name="edit-room-source" value="camera" onchange="_onEditSourceChange()"> 2089 ð´ Camera 2090 </label> 2091 <label class="vs-radio-opt"> 2092 <input type="radio" name="edit-room-source" value="screen" onchange="_onEditSourceChange()"> 2093 ð¥ï¸ Screen Share 2094 </label> 2095 </div> 2096 </div> 2097 2098 <!-- Streaming Platform picker -- same mechanism as the create-room 2099 sheet's own version (see its comment for the full explanation), 2100 just returning to THIS sheet/room via mode=signature-edit 2101 instead of the create sheet. --> 2102 <div class="vs-field" id="edit-streaming-field" style="display:none;"> 2103 <label>Select a platform and browse videos. Pick a video and tap "Create Party" button. Share the party link to watch the video together.</label> 2104 <div style="display:flex;gap:10px;flex-wrap:wrap;"> 2105 <button type="button" onclick="startStreamingPlatformForEditRoom('youtube')" style="flex:0 1 auto;min-width:44px;padding:10px 18px;white-space:nowrap;">YouTube</button> 2106 <button type="button" onclick="startStreamingPlatformForEditRoom('youtubemusic')" style="flex:0 1 auto;min-width:44px;padding:10px 18px;white-space:nowrap;">YT Music</button> 2107 <button type="button" onclick="startStreamingPlatformForEditRoom('dailymotion')" style="flex:0 1 auto;min-width:44px;padding:10px 18px;white-space:nowrap;">Dailymotion</button> 2108 <button type="button" onclick="startStreamingPlatformForEditRoom('twitch')" style="flex:0 1 auto;min-width:44px;padding:10px 18px;white-space:nowrap;">Twitch</button> 2109 <button type="button" onclick="startStreamingPlatformForEditRoom('ted')" style="flex:0 1 auto;min-width:44px;padding:10px 18px;white-space:nowrap;">TED</button> 2110 <button type="button" onclick="startStreamingPlatformForEditRoom('browseweb')" style="flex:0 1 auto;min-width:44px;padding:10px 18px;white-space:nowrap;">Browse Web</button> 2111 </div> 2112 </div> 2113 2114 <!-- Video URL --> 2115 <div class="vs-field vs-field-featured" id="edit-video-url-field"> 2116 <label><span>Video URL</span><em>Required</em></label> 2117 <div class="vs-input-shell" style="position:relative;display:flex;align-items:center;"> 2118 <input id="edit-room-video" placeholder="YouTube, Vimeo, or any public video URL" 2119 style="padding-right:32px;width:100%;box-sizing:border-box;"/> 2120 <button type="button" onclick="document.getElementById('edit-room-video').value='';document.getElementById('edit-room-video').focus();" 2121 title="Clear" style="position:absolute;right:8px;top:50%;transform:translateY(-50%); 2122 background:none;border:none;cursor:pointer;padding:2px 4px; 2123 color:var(--vs-dim,#888);font-size:16px;line-height:1;opacity:0.7;" 2124 onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.7'">â</button> 2125 </div> 2126 </div> 2127 <div class="vs-choice-note" id="edit-source-note" style="display:none;"></div> 2128 2129 <!-- Visibility --> 2130 <div class="vs-field"> 2131 <label>Visibility</label> 2132 <div class="vs-radio-row"> 2133 <label class="vs-radio-opt"> 2134 <input type="radio" name="edit-room-type" value="open" checked onchange="_toggleEditCodeField(); _onEditVisibilityChange();"> 2135 ð Public 2136 </label> 2137 <label class="vs-radio-opt"> 2138 <input type="radio" name="edit-room-type" value="private" onchange="_toggleEditCodeField(); _onEditVisibilityChange();">
2139 ð Private 2140 </label> 2141 <label class="vs-radio-opt"> 2142 <input type="radio" name="edit-room-type" value="friends" onchange="_toggleEditCodeField(); _onEditVisibilityChange();"> 2143 ð¥ Friends 2144 </label> 2145 </div> 2146 </div> 2147 2148 <!-- Share with (Friends visibility only) --> 2149 <div class="vs-field" id="edit-share-field" style="display:none;"> 2150 <label>Share with</label> 2151 <div id="edit-share-chips" style="display:flex;gap:8px;flex-wrap:wrap;margin-top:4px;"> 2152 <button type="button" class="vs-share-add-chip" onclick="openShareWithPicker('edit')">+ Add friend or circle</button> 2153 </div> 2154 </div> 2155 2156 <!-- Party Code (private rooms only) --> 2157 <div class="vs-field" id="edit-room-code-field" style="display:none;"> 2158 <label>Party Code</label> 2159 <div style="display:flex;align-items:center;gap:8px;"> 2160 <input id="edit-room-code" readonly 2161 style="flex:1;letter-spacing:2px;font-weight:700;text-transform:uppercase;"/> 2162 <button type="button" id="edit-regen-code-btn" onclick="regenerateRoomCode()" 2163 style="white-space:nowrap;padding:8px 14px;border-radius:6px;border:1px solid rgba(233,201,140,0.3); 2164 background:transparent;color:#e8c887;font-weight:600;font-size:13px;cursor:pointer;"> 2165 ð New Code 2166 </button> 2167 </div> 2168 <div style="font-size:11.5px;color:var(--vs-dim,#888);margin-top:6px;"> 2169 Generating a new code removes everyone currently in the party except you. 2170 </div> 2171 </div> 2172 2173 2174 <!-- Divider: Premiere Settings --> 2175 <div class="vs-sheet-divider"></div> 2176 <div class="vs-sheet-section-label">ð¬ Premiere Settings</div> 2177 2178 <!-- Host Control toggle --> 2179 <div class="vs-toggle-row"> 2180 <div class="vs-toggle-info"> 2181 <span class="vs-toggle-label">Host Control</span> 2182 <span class="vs-toggle-desc">Only you can start, pause, and end the party. Viewers wait for you.</span> 2183 </div> 2184 <label class="vs-toggle"> 2185 <input type="checkbox" id="edit-host-control" checked> 2186 <span class="vs-toggle-track"></span> 2187 </label> 2188 </div> 2189 2190 <!-- Scheduled Start --> 2191 <div class="vs-field"> 2192 <label>Scheduled Start <span style="color:var(--vs-dim);font-weight:400;text-transform:none;letter-spacing:0;">(optional)</span></label> 2193 <div class="vs-schedule-wrap"> 2194 <div style="position:relative;display:flex;align-items:center;"> 2195 <input type="datetime-local" id="edit-scheduled-at" 2196 style="padding-right:32px;width:100%;box-sizing:border-box;"/> 2197 <button type="button" onclick="document.getElementById('edit-scheduled-at').value='';document.getElementById('edit-scheduled-at').focus();" 2198 title="Clear date & time" style="position:absolute;right:8px;top:50%;transform:translateY(-50%); 2199 background:none;border:none;cursor:pointer;padding:2px 4px; 2200 color:var(--vs-dim,#888);font-size:16px;line-height:1;opacity:0.7;" 2201 onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.7'">â</button> 2202 </div> 2203 <div class="vs-schedule-note">Leave blank to start the party manually whenever you're ready. Times are in your local timezone.</div> 2204 </div> 2205 </div> 2206 2207 <!-- Allow Replay toggle --> 2208 <div class="vs-toggle-row"> 2209 <div class="vs-toggle-info"> 2210 <span class="vs-toggle-label">Allow Replay</span> 2211 <span class="vs-toggle-desc">After the party ends, viewers can watch the video again.</span> 2212 </div> 2213 <label class="vs-toggle"> 2214 <input type="checkbox" id="edit-allow-replay"> 2215 <span class="vs-toggle-track"></span> 2216 </label> 2217 </div> 2218 2219 <!-- Convert to Signature Party -- instant parties only, signed-in 2220 owner only. One-way: room_mode changes from "instant" to 2221 "manual" (matching regular signature parties exactly), same 2222 room/ID/chat history/participants preserved throughout. Shown 2223 and wired up by JS in editRoomName()/the click handler below, 2224 since it only makes sense for instant parties specifically. --> 2225 <div class="vs-field" id="edit-convert-signature-field" style="display:none;"> 2226 <div class="vs-sheet-divider"></div> 2227 <div class="vs-notice">
2228 <span class="vs-notice-icon">â¨</span> 2229 <div> 2230 <div style="color:#fff;font-weight:600;margin-bottom:3px;">Make this a Signature Party</div> 2231 <div>Keeps this exact party, its link, and everyone already in it -- just gives you the full settings above going forward (visibility, scheduling, host control) the same as any other signature party. This can't be undone.</div> 2232 </div> 2233 </div> 2234 <button type="button" id="edit-convert-signature-btn" onclick="convertInstantToSignature()" 2235 style="width:100%;height:40px;border-radius:10px;border:1px solid rgba(124,58,237,.4); 2236 background:rgba(124,58,237,.12);color:var(--vs-purple-l);font-size:13px;font-weight:700; 2237 cursor:pointer;font-family:inherit;"> 2238 ⨠Convert to Signature Party 2239 </button> 2240 </div> 2241 2242 <!-- Actions --> 2243 <div class="vs-sheet-actions"> 2244 <button class="vs-btn-cancel" onclick="closeRoomEdit()">Cancel</button> 2245 <button class="vs-btn-submit" onclick="saveRoomEdit()">Save Changes</button> 2246 </div> 2247 </div> 2248</div> 2249 2250<!-- Share-with picker (Friends visibility) -- this page has no Create 2251 Party sheet of its own (instant parties are created a different 2252 way), so unlike watch.html this markup only ever serves the Edit 2253 sheet above; openShareWithPicker()/etc. in croom.js are shared and 2254 already context-aware regardless. --> 2255<div class="vs-share-picker-overlay" id="share-picker-overlay" onclick="if(event.target===this) closeShareWithPicker()"> 2256 <div class="vs-share-picker-sheet" onclick="event.stopPropagation()"> 2257 <div class="vs-share-picker-head"> 2258 <div class="vs-share-picker-title">Share with</div> 2259 <button onclick="closeShareWithPicker()" style="background:none;border:none;color:rgba(255,255,255,.5);font-size:20px;cursor:pointer;padding:2px 8px;">✕</button> 2260 </div> 2261 <div class="vs-share-picker-tabs"> 2262 <button type="button" class="vs-share-picker-tab active" id="share-tab-friends" onclick="_switchSharePickerTab('friends')">Friends</button> 2263 <button type="button" class="vs-share-picker-tab" id="share-tab-circles" onclick="_switchSharePickerTab('circles')">Circles</button> 2264 </div> 2265 <div id="share-picker-list"></div> 2266 <button type="button" class="vs-btn-submit" style="width:100%;margin-top:8px;" onclick="closeShareWithPicker()">Done</button> 2267 </div> 2268</div> 2269 2270 2271<!-- ============================================================ 2272 Instant Party Playlist Overlay 2273 ============================================================ --> 2274<div id="instant-playlist-overlay" class="instant-playlist-overlay" aria-hidden="true"> 2275 <div class="instant-playlist-backdrop" onclick="closeInstantPlaylist()"></div> 2276 <section class="instant-playlist-sheet" role="dialog" aria-modal="true" aria-labelledby="instant-playlist-title"> 2277 <div class="instant-playlist-head"> 2278 <div> 2279 <div id="instant-playlist-title" class="instant-playlist-title">Playlist</div> 2280 <div id="instant-playlist-subtitle" class="instant-playlist-subtitle">Manage videos for this party</div> 2281 </div> 2282 <button type="button" class="instant-playlist-close" onclick="closeInstantPlaylist()" aria-label="Close">Ã</button> 2283 </div> 2284 2285 <div id="instant-playlist-status" class="instant-playlist-status"></div> 2286 <div id="instant-playlist-items" class="instant-playlist-items"> 2287 <div class="instant-playlist-loading">Loading playlistâ¦</div> 2288 </div> 2289 2290 <div class="instant-playlist-add"> 2291 <input id="instant-playlist-url" type="url" placeholder="Paste video URL" autocomplete="off"> 2292 <button type="button" id="instant-playlist-add-btn" onclick="addInstantPlaylistItem()">+ Add Video</button> 2293 </div> 2294 2295 <div class="instant-playlist-footer"> 2296 <span>Videos play sequentially when the party is live.</span> 2297 <button type="button" onclick="closeInstantPlaylist()">Done</button> 2298 </div> 2299 </section> 2300</div> 2301<style> 2302.instant-playlist-overlay{display:none;position:fixed;inset:0;z-index:100000;background:rgba(0,0,0,.72);
2302backdrop-filter:blur(7px);align-items:flex-end;justify-content:center} 2303.instant-playlist-overlay.open{display:flex} 2304.instant-playlist-backdrop{position:absolute;inset:0} 2305.instant-playlist-sheet{position:relative;width:min(620px,100%);max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:#0e0b14;border:1px solid rgba(255,255,255,.10);border-radius:20px 20px 0 0;box-shadow:0 -18px 60px rgba(0,0,0,.45);padding:18px 16px 14px;color:#ede9f4} 2306.instant-playlist-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-bottom:12px;border-bottom:1px solid rgba(255,255,255,.07)} 2307.instant-playlist-title{font-size:18px;font-weight:800} 2308.instant-playlist-subtitle{font-size:11px;color:#8e8799;margin-top:3px} 2309.instant-playlist-close{width:34px;height:34px;border-radius:10px;border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.05);color:#fff;font-size:22px;cursor:pointer} 2310.instant-playlist-status{font-size:12px;color:#e7b76c;min-height:18px;padding:8px 2px 0} 2311.instant-playlist-items{overflow-y:auto;padding:8px 0;display:flex;flex-direction:column;gap:7px;min-height:80px} 2312.instant-playlist-item{display:grid;grid-template-columns:32px 1fr auto;gap:10px;align-items:center;padding:10px;border:1px solid rgba(255,255,255,.07);border-radius:12px;background:rgba(255,255,255,.035)} 2313.instant-playlist-item.current{border-color:rgba(124,58,237,.55);background:rgba(124,58,237,.10)} 2314.instant-playlist-num{font-size:12px;font-weight:800;color:#9b91ab;text-align:center} 2315.instant-playlist-item.current .instant-playlist-num{color:#c4b5fd} 2316.instant-playlist-info{min-width:0} 2317.instant-playlist-item-title{font-size:12.5px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} 2318.instant-playlist-item-url{font-size:10px;color:#777080;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:3px} 2319.instant-playlist-badge{font-size:9px;text-transform:uppercase;letter-spacing:.08em;color:#c4b5fd;margin-left:5px} 2320.instant-playlist-actions{display:flex;gap:4px} 2321.instant-playlist-actions button{width:28px;height:28px;border:1px solid rgba(255,255,255,.08);border-radius:8px;background:rgba(255,255,255,.045);color:#cfc8da;cursor:pointer} 2322.instant-playlist-actions button:hover{background:rgba(255,255,255,.09)} 2323.instant-playlist-actions .remove{color:#ef7777} 2324.instant-playlist-add{display:flex;gap:8px;padding:10px 0;border-top:1px solid rgba(255,255,255,.07)} 2325.instant-playlist-add input{flex:1;min-width:0;height:40px;border:1px solid rgba(255,255,255,.10);border-radius:10px;background:rgba(255,255,255,.045);color:#fff;padding:0 12px;outline:none} 2326.instant-playlist-add button,.instant-playlist-footer button{height:40px;border:0;border-radius:10px;padding:0 15px;background:#7c3aed;color:#fff;font-weight:700;cursor:pointer} 2327.instant-playlist-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:10.5px;color:#777080;padding-top:4px} 2328.instant-playlist-footer button{height:34px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.10)} 2329.instant-playlist-loading{padding:25px;text-align:center;
2329color:#777080;font-size:12px} 2330@media(max-width:600px){.instant-playlist-sheet{max-height:92vh;border-radius:18px 18px 0 0}.instant-playlist-add{flex-direction:column}.instant-playlist-add button{width:100%}} 2331</style> 2332 2333
2334<script> 2335// "Social" nav-item notification dot -- one lightweight check per page 2336// load, shared across every page that has the bottom nav, rather than 2337// each page maintaining its own copy of this logic. 2338(async function checkSocialDot() { 2339 try { 2340 const statusRes = await fetch("/api/auth/status", { credentials: "include" }); 2341 const status = await statusRes.json(); 2342 if (!status || !status.ok || !status.user || !status.user.sub) return; 2343 const res = await fetch("/api/social/unseen-summary", { credentials: "include" }); 2344 const data = await res.json(); 2345 const dot = document.getElementById("tab-social-dot"); 2346 if (dot && data && data.ok && data.has_any) dot.style.display = "block"; 2347 } catch (e) { /* silent -- a failed check just leaves the dot off */ } 2348})(); 2349</script>
2349 2350</body> 2351</html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.