PageSourceSearch

https://vizsurge.com/

html vizsurge.com collected 2026-09-24 14:19:47 UTC 118,588 bytes, 2,351 lines download raw bytes

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">&middot;</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})">&#8942;</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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
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 &amp; 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 &amp; 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;">&#10005;</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.