Commit 85c673077e74e2f82f5df601433f790fd9de9ac8
1 parent
b16b5418
Add T28 (传衣服 tab) + T29 (credit balance + topup UI) to dance.html
T28 replaces the "开发中敬请期待" placeholder on dance.html's 传衣服 tab with a real upload panel: file input, the live spend_garment price pulled from the new public GET /api/pricing (not hardcoded, per dev-plan-4.md's acceptance criterion), and a submit flow that POSTs to T27's /api/dance/upload-garment. Not logged in opens the existing T22 login modal instead of failing silently; not email-verified shows an inline message; a 402 (insufficient balance) points at the new topup flow; success refreshes the balance and immediately polls the queue, same as the free wardrobe pick's own pollState() call. T29 adds a 充值 pill (visible once logged in) opening a tier picker built from GET /api/pricing's topup_tier entries, POSTing to T24's /api/credits/checkout and navigating to whatever URL comes back (the mock completion page today, Stripe's hosted Checkout once a real key lands tomorrow — both redirect back to dance.html?topup=success/ cancelled). A one-shot banner reads that query param on load, then scrubs it via history.replaceState. The account button's own label now also shows creditBalance, riding on the field GET /api/auth/me already started returning in T24. Manually verified end to end against the live server (localhost:8934): register -> verify -> mock checkout -> balance updates in the header -> topup banner -> upload panel shows the live price and opens the login modal when logged out. The upload itself surfaced that production's one seed character had never been marked isDefault (see the preceding commit) — not a bug in this code, but blocked a live walk of the full generate path until fixed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DUiYs6UjxDMkfvnjtsKFo
Showing
4 changed files
with
251 additions
and
4 deletions
dance.css
| @@ -52,4 +52,43 @@ | @@ -52,4 +52,43 @@ | ||
| 52 | 52 | ||
| 53 | .coming-soon { color: var(--muted); text-align: center; margin-top: 40px; font-size: 13px; font-weight: bold; padding: 0 12px; } | 53 | .coming-soon { color: var(--muted); text-align: center; margin-top: 40px; font-size: 13px; font-weight: bold; padding: 0 12px; } |
| 54 | 54 | ||
| 55 | +/* T28: 传衣服 (mode 2) upload panel — same comic-panel language as | ||
| 56 | + .wardrobe-card, laid out as a simple vertical form instead of a grid. */ | ||
| 57 | +.upload-panel { padding: 8px 4px; } | ||
| 58 | +.upload-panel .hint { font-size: 12px; color: var(--muted); margin-bottom: 10px; line-height: 1.5; } | ||
| 59 | +.upload-panel input[type="file"] { width: 100%; font-size: 12px; margin-bottom: 10px; } | ||
| 60 | +.upload-panel .price-line { font: bold 13px Tahoma, sans-serif; margin-bottom: 10px; } | ||
| 61 | +.upload-panel button { | ||
| 62 | + width: 100%; | ||
| 63 | + font: bold 13px Tahoma, sans-serif; | ||
| 64 | + background: var(--accent); | ||
| 65 | + border: 3px solid #000; | ||
| 66 | + color: var(--ink); | ||
| 67 | + border-radius: 12px; | ||
| 68 | + padding: 8px 4px; | ||
| 69 | + box-shadow: var(--shadow-sm); | ||
| 70 | + cursor: pointer; | ||
| 71 | +} | ||
| 72 | +.upload-panel button:disabled { opacity: .5; cursor: default; } | ||
| 73 | +.upload-panel .upload-error { color: #c0392b; font: bold 12px Tahoma, sans-serif; margin-top: 8px; } | ||
| 74 | +.upload-panel .upload-status { font: bold 12px Tahoma, sans-serif; margin-top: 8px; } | ||
| 75 | + | ||
| 76 | +/* T29: balance pill + topup tier picker (reuses .nickname-modal/.nickname-box) */ | ||
| 77 | +.credit-pill { font: bold 12px Tahoma, sans-serif; } | ||
| 78 | +.topup-tier { | ||
| 79 | + display: flex; | ||
| 80 | + justify-content: space-between; | ||
| 81 | + align-items: center; | ||
| 82 | + border: 3px solid #000; | ||
| 83 | + border-radius: 12px; | ||
| 84 | + padding: 8px 10px; | ||
| 85 | + margin-bottom: 8px; | ||
| 86 | + box-shadow: var(--shadow-sm); | ||
| 87 | +} | ||
| 88 | +.topup-tier .credits { font: bold 14px Tahoma, sans-serif; } | ||
| 89 | +.topup-tier button { font: bold 12px Tahoma, sans-serif; background: var(--accent); border: 3px solid #000; border-radius: 10px; padding: 5px 10px; cursor: pointer; } | ||
| 90 | +.topup-banner { text-align: center; font: bold 13px Tahoma, sans-serif; padding: 8px; margin: 0 0 10px; border-radius: 10px; } | ||
| 91 | +.topup-banner.success { background: #d4f7d4; } | ||
| 92 | +.topup-banner.cancelled { background: #f7e4d4; } | ||
| 93 | + | ||
| 55 | .age-box .age-disclaimer { font-size: 12px; line-height: 1.6; color: #444; font-weight: normal; } | 94 | .age-box .age-disclaimer { font-size: 12px; line-height: 1.6; color: #444; font-weight: normal; } |
dance.html
| @@ -20,10 +20,13 @@ | @@ -20,10 +20,13 @@ | ||
| 20 | <div class="spacer"></div> | 20 | <div class="spacer"></div> |
| 21 | <div class="watching" id="watchingCount" hidden></div> | 21 | <div class="watching" id="watchingCount" hidden></div> |
| 22 | <button class="topbar-pill" id="muteBtn">🔇 静音</button> | 22 | <button class="topbar-pill" id="muteBtn">🔇 静音</button> |
| 23 | + <button class="topbar-pill" id="topupBtn" hidden>💰 充值</button> | ||
| 23 | <button class="topbar-pill" id="authBtn">登录</button> | 24 | <button class="topbar-pill" id="authBtn">登录</button> |
| 24 | <a class="topbar-pill" href="live.html">AI 短剧频道 →</a> | 25 | <a class="topbar-pill" href="live.html">AI 短剧频道 →</a> |
| 25 | </header> | 26 | </header> |
| 26 | 27 | ||
| 28 | + <div id="topupBanner" hidden></div> | ||
| 29 | + | ||
| 27 | <div class="body" id="body"> | 30 | <div class="body" id="body"> |
| 28 | <aside class="left-col" id="leftCol"> | 31 | <aside class="left-col" id="leftCol"> |
| 29 | <div class="wardrobe-tabs" id="wardrobeTabs"> | 32 | <div class="wardrobe-tabs" id="wardrobeTabs"> |
| @@ -93,6 +96,15 @@ | @@ -93,6 +96,15 @@ | ||
| 93 | </div> | 96 | </div> |
| 94 | </div> | 97 | </div> |
| 95 | 98 | ||
| 99 | +<!-- T29: topup tier picker — reuses .nickname-modal/.nickname-box's shell --> | ||
| 100 | +<div class="nickname-modal" id="topupModal" hidden> | ||
| 101 | + <div class="nickname-box"> | ||
| 102 | + <div class="nickname-title">充值</div> | ||
| 103 | + <div id="topupTierList"></div> | ||
| 104 | + <button class="auth-link-btn" id="topupCloseBtn">取消</button> | ||
| 105 | + </div> | ||
| 106 | +</div> | ||
| 107 | + | ||
| 96 | <script> | 108 | <script> |
| 97 | let wardrobe = []; | 109 | let wardrobe = []; |
| 98 | let leftTab = "wardrobe"; | 110 | let leftTab = "wardrobe"; |
| @@ -190,6 +202,8 @@ function ensureIdentity() { | @@ -190,6 +202,8 @@ function ensureIdentity() { | ||
| 190 | // viewer_nickname onto this browser so switching devices or clearing | 202 | // viewer_nickname onto this browser so switching devices or clearing |
| 191 | // cookies doesn't silently start a fresh anonymous identity. | 203 | // cookies doesn't silently start a fresh anonymous identity. |
| 192 | const authBtn = document.getElementById("authBtn"); | 204 | const authBtn = document.getElementById("authBtn"); |
| 205 | +const topupBtn = document.getElementById("topupBtn"); | ||
| 206 | +const topupBanner = document.getElementById("topupBanner"); | ||
| 193 | const authModal = document.getElementById("authModal"); | 207 | const authModal = document.getElementById("authModal"); |
| 194 | const authTitle = document.getElementById("authTitle"); | 208 | const authTitle = document.getElementById("authTitle"); |
| 195 | const authEmail = document.getElementById("authEmail"); | 209 | const authEmail = document.getElementById("authEmail"); |
| @@ -199,10 +213,14 @@ const authSubmitBtn = document.getElementById("authSubmitBtn"); | @@ -199,10 +213,14 @@ const authSubmitBtn = document.getElementById("authSubmitBtn"); | ||
| 199 | const authToggleBtn = document.getElementById("authToggleBtn"); | 213 | const authToggleBtn = document.getElementById("authToggleBtn"); |
| 200 | const authCloseBtn = document.getElementById("authCloseBtn"); | 214 | const authCloseBtn = document.getElementById("authCloseBtn"); |
| 201 | let authMode = "login"; | 215 | let authMode = "login"; |
| 216 | +let currentUser = null; // T28/T29 need to know login/emailVerified/creditBalance beyond just "is authBtn's dot lit" | ||
| 202 | 217 | ||
| 203 | function renderAuthButton(user) { | 218 | function renderAuthButton(user) { |
| 204 | - authBtn.textContent = user ? `${user.nickname || user.email.split("@")[0]} · 退出` : "登录"; | 219 | + currentUser = user; |
| 220 | + authBtn.textContent = user ? `${user.nickname || user.email.split("@")[0]} · 💰${user.creditBalance} · 退出` : "登录"; | ||
| 205 | authBtn.dataset.loggedIn = user ? "1" : ""; | 221 | authBtn.dataset.loggedIn = user ? "1" : ""; |
| 222 | + topupBtn.hidden = !user; | ||
| 223 | + if (leftTab === "garment") renderWardrobe(); // price/balance-gated panel depends on login state | ||
| 206 | } | 224 | } |
| 207 | 225 | ||
| 208 | async function refreshAuthState() { | 226 | async function refreshAuthState() { |
| @@ -265,6 +283,82 @@ authPassword.addEventListener("keydown", (e) => { | @@ -265,6 +283,82 @@ authPassword.addEventListener("keydown", (e) => { | ||
| 265 | 283 | ||
| 266 | refreshAuthState(); | 284 | refreshAuthState(); |
| 267 | 285 | ||
| 286 | +// ---- T29: 积分余额 + 充值 (dev-plan-4.md) ---- | ||
| 287 | +let pricing = []; | ||
| 288 | +async function loadPricing() { | ||
| 289 | + try { | ||
| 290 | + pricing = await fetch("/api/pricing").then((r) => r.json()); | ||
| 291 | + } catch { | ||
| 292 | + pricing = []; | ||
| 293 | + } | ||
| 294 | +} | ||
| 295 | + | ||
| 296 | +const topupModal = document.getElementById("topupModal"); | ||
| 297 | +const topupTierList = document.getElementById("topupTierList"); | ||
| 298 | +const topupCloseBtn = document.getElementById("topupCloseBtn"); | ||
| 299 | + | ||
| 300 | +function renderTopupTiers() { | ||
| 301 | + const tiers = pricing.filter((p) => p.kind === "topup_tier"); | ||
| 302 | + topupTierList.innerHTML = tiers.length | ||
| 303 | + ? tiers | ||
| 304 | + .map( | ||
| 305 | + (t) => ` | ||
| 306 | + <div class="topup-tier"> | ||
| 307 | + <div><div class="credits">${t.credits} 积分</div>${t.priceCents != null ? `<div class="hint">$${(t.priceCents / 100).toFixed(2)}</div>` : ""}</div> | ||
| 308 | + <button data-tier="${t.key}">购买</button> | ||
| 309 | + </div>` | ||
| 310 | + ) | ||
| 311 | + .join("") | ||
| 312 | + : `<div class="empty-hint">暂无可购买档位</div>`; | ||
| 313 | + topupTierList.querySelectorAll("[data-tier]").forEach((btn) => | ||
| 314 | + btn.addEventListener("click", async () => { | ||
| 315 | + btn.disabled = true; | ||
| 316 | + try { | ||
| 317 | + const res = await fetch("/api/credits/checkout", { | ||
| 318 | + method: "POST", | ||
| 319 | + headers: { "Content-Type": "application/json" }, | ||
| 320 | + body: JSON.stringify({ tierKey: btn.dataset.tier }), | ||
| 321 | + }); | ||
| 322 | + const data = await res.json(); | ||
| 323 | + if (!res.ok) { | ||
| 324 | + alert(data.error || "创建支付会话失败"); | ||
| 325 | + return; | ||
| 326 | + } | ||
| 327 | + location.href = data.url; // Stripe's hosted page (real) or the mock completion URL — both redirect back here | ||
| 328 | + } finally { | ||
| 329 | + btn.disabled = false; | ||
| 330 | + } | ||
| 331 | + }) | ||
| 332 | + ); | ||
| 333 | +} | ||
| 334 | + | ||
| 335 | +topupBtn.addEventListener("click", async () => { | ||
| 336 | + if (!currentUser) { | ||
| 337 | + openAuthModal("login"); | ||
| 338 | + return; | ||
| 339 | + } | ||
| 340 | + if (!pricing.length) await loadPricing(); | ||
| 341 | + renderTopupTiers(); | ||
| 342 | + topupModal.hidden = false; | ||
| 343 | +}); | ||
| 344 | +topupCloseBtn.addEventListener("click", () => { | ||
| 345 | + topupModal.hidden = true; | ||
| 346 | +}); | ||
| 347 | + | ||
| 348 | +// A redirect back from Stripe (or the mock checkout) lands here with | ||
| 349 | +// ?topup=success/cancelled — show a one-off banner, then scrub the query | ||
| 350 | +// string so a page refresh doesn't keep re-showing it. | ||
| 351 | +(function showTopupBanner() { | ||
| 352 | + const topup = new URLSearchParams(location.search).get("topup"); | ||
| 353 | + if (!topup) return; | ||
| 354 | + topupBanner.hidden = false; | ||
| 355 | + topupBanner.className = topup === "success" ? "topup-banner success" : "topup-banner cancelled"; | ||
| 356 | + topupBanner.textContent = topup === "success" ? "充值成功!" : "已取消充值"; | ||
| 357 | + history.replaceState(null, "", location.pathname); | ||
| 358 | +})(); | ||
| 359 | + | ||
| 360 | +loadPricing(); | ||
| 361 | + | ||
| 268 | muteBtn.addEventListener("click", () => { | 362 | muteBtn.addEventListener("click", () => { |
| 269 | player.muted = !player.muted; | 363 | player.muted = !player.muted; |
| 270 | muteBtn.textContent = player.muted ? "🔇 静音" : "🔈 有声"; | 364 | muteBtn.textContent = player.muted ? "🔇 静音" : "🔈 有声"; |
| @@ -279,8 +373,12 @@ function garmentName(id) { | @@ -279,8 +373,12 @@ function garmentName(id) { | ||
| 279 | 373 | ||
| 280 | function renderWardrobe() { | 374 | function renderWardrobe() { |
| 281 | document.querySelectorAll("#wardrobeTabs button").forEach((b) => b.classList.toggle("active", b.dataset.tab === leftTab)); | 375 | document.querySelectorAll("#wardrobeTabs button").forEach((b) => b.classList.toggle("active", b.dataset.tab === leftTab)); |
| 376 | + if (leftTab === "garment") { | ||
| 377 | + renderGarmentUploadPanel(); | ||
| 378 | + return; | ||
| 379 | + } | ||
| 282 | if (leftTab !== "wardrobe") { | 380 | if (leftTab !== "wardrobe") { |
| 283 | - wardrobePanel.innerHTML = `<div class="coming-soon">${leftTab === "garment" ? "传衣服" : "传人"}功能开发中,敬请期待</div>`; | 381 | + wardrobePanel.innerHTML = `<div class="coming-soon">传人功能开发中,敬请期待</div>`; |
| 284 | return; | 382 | return; |
| 285 | } | 383 | } |
| 286 | if (wardrobe.length === 0) { | 384 | if (wardrobe.length === 0) { |
| @@ -301,6 +399,77 @@ function renderWardrobe() { | @@ -301,6 +399,77 @@ function renderWardrobe() { | ||
| 301 | ); | 399 | ); |
| 302 | } | 400 | } |
| 303 | 401 | ||
| 402 | +// ---- T28: 模式二"传衣服"(付费,需登录+邮箱验证) ---- | ||
| 403 | +function renderGarmentUploadPanel() { | ||
| 404 | + const spendGarment = pricing.find((p) => p.key === "spend_garment"); | ||
| 405 | + const priceLine = spendGarment ? `上传一张衣服照片,AI 换装生成跳舞视频,扣 ${spendGarment.credits} 积分` : "加载价格中…"; | ||
| 406 | + wardrobePanel.innerHTML = ` | ||
| 407 | + <div class="upload-panel"> | ||
| 408 | + <div class="hint">${priceLine}</div> | ||
| 409 | + <input type="file" id="garmentFileInput" accept="image/jpeg,image/png,image/webp" /> | ||
| 410 | + <button id="garmentUploadBtn">上传并生成</button> | ||
| 411 | + <div class="upload-error" id="garmentUploadError" hidden></div> | ||
| 412 | + <div class="upload-status" id="garmentUploadStatus" hidden></div> | ||
| 413 | + </div>`; | ||
| 414 | + document.getElementById("garmentUploadBtn").addEventListener("click", submitGarmentUpload); | ||
| 415 | +} | ||
| 416 | + | ||
| 417 | +async function submitGarmentUpload() { | ||
| 418 | + const fileInput = document.getElementById("garmentFileInput"); | ||
| 419 | + const errorBox = document.getElementById("garmentUploadError"); | ||
| 420 | + const statusBox = document.getElementById("garmentUploadStatus"); | ||
| 421 | + const btn = document.getElementById("garmentUploadBtn"); | ||
| 422 | + errorBox.hidden = true; | ||
| 423 | + statusBox.hidden = true; | ||
| 424 | + | ||
| 425 | + // 未登录/未验证先引导,而不是提交后静默失败(T28 验收标准) | ||
| 426 | + if (!currentUser) { | ||
| 427 | + openAuthModal("login"); | ||
| 428 | + return; | ||
| 429 | + } | ||
| 430 | + if (!currentUser.emailVerified) { | ||
| 431 | + errorBox.hidden = false; | ||
| 432 | + errorBox.textContent = "请先完成邮箱验证后再上传(查看邮箱中的验证链接)"; | ||
| 433 | + return; | ||
| 434 | + } | ||
| 435 | + const file = fileInput.files[0]; | ||
| 436 | + if (!file) { | ||
| 437 | + errorBox.hidden = false; | ||
| 438 | + errorBox.textContent = "请先选择一张图片"; | ||
| 439 | + return; | ||
| 440 | + } | ||
| 441 | + | ||
| 442 | + btn.disabled = true; | ||
| 443 | + statusBox.hidden = false; | ||
| 444 | + statusBox.textContent = "上传中…"; | ||
| 445 | + try { | ||
| 446 | + const fd = new FormData(); | ||
| 447 | + fd.append("file", file); | ||
| 448 | + const res = await fetch("/api/dance/upload-garment", { method: "POST", body: fd }); | ||
| 449 | + const data = await res.json().catch(() => ({})); | ||
| 450 | + if (!res.ok) { | ||
| 451 | + statusBox.hidden = true; | ||
| 452 | + errorBox.hidden = false; | ||
| 453 | + if (res.status === 402) { | ||
| 454 | + errorBox.textContent = "积分不足,请先充值"; | ||
| 455 | + } else { | ||
| 456 | + errorBox.textContent = data.error || "上传失败"; | ||
| 457 | + } | ||
| 458 | + return; | ||
| 459 | + } | ||
| 460 | + statusBox.textContent = data.status === "ready" ? "生成完成,已加入播放队列!" : "生成中,完成后会自动加入播放队列"; | ||
| 461 | + fileInput.value = ""; | ||
| 462 | + await refreshAuthState(); // balance just changed | ||
| 463 | + pollState(); // surface the new queue entry immediately, same as pickGarment | ||
| 464 | + } catch { | ||
| 465 | + statusBox.hidden = true; | ||
| 466 | + errorBox.hidden = false; | ||
| 467 | + errorBox.textContent = "网络错误,请重试"; | ||
| 468 | + } finally { | ||
| 469 | + btn.disabled = false; | ||
| 470 | + } | ||
| 471 | +} | ||
| 472 | + | ||
| 304 | document.querySelectorAll("#wardrobeTabs button").forEach((b) => | 473 | document.querySelectorAll("#wardrobeTabs button").forEach((b) => |
| 305 | b.addEventListener("click", () => { | 474 | b.addEventListener("click", () => { |
| 306 | leftTab = b.dataset.tab; | 475 | leftTab = b.dataset.tab; |
pricing.json
0 → 100644
| 1 | +[ | ||
| 2 | + { | ||
| 3 | + "key": "spend_garment", | ||
| 4 | + "kind": "spend", | ||
| 5 | + "credits": 10, | ||
| 6 | + "priceCents": null, | ||
| 7 | + "segmentsPerUnit": null, | ||
| 8 | + "isActive": true, | ||
| 9 | + "updatedAt": 1788356862386 | ||
| 10 | + }, | ||
| 11 | + { | ||
| 12 | + "key": "topup_tier_1", | ||
| 13 | + "kind": "topup_tier", | ||
| 14 | + "credits": 100, | ||
| 15 | + "priceCents": 999, | ||
| 16 | + "segmentsPerUnit": null, | ||
| 17 | + "isActive": true, | ||
| 18 | + "updatedAt": 1788356862386 | ||
| 19 | + }, | ||
| 20 | + { | ||
| 21 | + "key": "topup_tier_2", | ||
| 22 | + "kind": "topup_tier", | ||
| 23 | + "credits": 550, | ||
| 24 | + "priceCents": 4999, | ||
| 25 | + "segmentsPerUnit": null, | ||
| 26 | + "isActive": true, | ||
| 27 | + "updatedAt": 1788356862386 | ||
| 28 | + } | ||
| 29 | +] | ||
| 0 | \ No newline at end of file | 30 | \ No newline at end of file |
server.js
| @@ -1586,8 +1586,9 @@ http | @@ -1586,8 +1586,9 @@ http | ||
| 1586 | 1586 | ||
| 1587 | // ---- dance.html: public viewer-facing endpoints (T11/T12/T15/T17) ---- | 1587 | // ---- dance.html: public viewer-facing endpoints (T11/T12/T15/T17) ---- |
| 1588 | // No admin auth (T6 only gates /api/admin/*) — these are the free, | 1588 | // No admin auth (T6 only gates /api/admin/*) — these are the free, |
| 1589 | - // anonymous, 模式一 (衣柜选衣服) path. Modes 二/三 (paid upload) aren't | ||
| 1590 | - // built yet (require accounts/credits, dev-plan-2.md 第三/四期). | 1589 | + // anonymous, 模式一 (衣柜选衣服) path. 模式二 (付费传衣服) is T27, |
| 1590 | + // below, and does require login+emailVerified on its own. 模式三 isn't | ||
| 1591 | + // built yet (第五期). | ||
| 1591 | 1592 | ||
| 1592 | if (pathname === "/api/dance/wardrobe" && req.method === "GET") { | 1593 | if (pathname === "/api/dance/wardrobe" && req.method === "GET") { |
| 1593 | res.writeHead(200, { "Content-Type": "application/json" }); | 1594 | res.writeHead(200, { "Content-Type": "application/json" }); |
| @@ -1595,6 +1596,15 @@ http | @@ -1595,6 +1596,15 @@ http | ||
| 1595 | return; | 1596 | return; |
| 1596 | } | 1597 | } |
| 1597 | 1598 | ||
| 1599 | + // T28/T29 need pricing (spend_garment's cost, topup_tier options) on | ||
| 1600 | + // the client before any purchase decision — public/unauthenticated on | ||
| 1601 | + // purpose, same reasoning as GET /api/dance/wardrobe above. | ||
| 1602 | + if (pathname === "/api/pricing" && req.method === "GET") { | ||
| 1603 | + res.writeHead(200, { "Content-Type": "application/json" }); | ||
| 1604 | + res.end(JSON.stringify(pricing.list().filter((p) => p.isActive))); | ||
| 1605 | + return; | ||
| 1606 | + } | ||
| 1607 | + | ||
| 1598 | if (pathname === "/api/dance/live/state" && req.method === "GET") { | 1608 | if (pathname === "/api/dance/live/state" && req.method === "GET") { |
| 1599 | ensureDanceSchedule(); | 1609 | ensureDanceSchedule(); |
| 1600 | const now = Date.now(); | 1610 | const now = Date.now(); |