1 2/** 3 * Serialized <video>/<audio>.play() â overlapping play() after srcObject churn 4 * throws AbortError and resets NetEQ playout (webcam mic crackle). Skips when 5 * already playing the same stream. 6 */ 7window._ccSafeMediaPlay = function (el) { 8 if (!el || typeof el.play !== 'function') return Promise.resolve(); 9 try { 10 // Skip only when already playing real video (or audio-only). A black 11 // tile can sit at readyState>=2 with videoWidth===0 â still need play(). 12 const hasVid = !!(el.srcObject && el.srcObject.getVideoTracks 13 && el.srcObject.getVideoTracks().some((t) => t && t.readyState === 'live')); 14 const painted = !hasVid || ((el.videoWidth | 0) > 0); 15 if (!el.paused && !el.ended && el.readyState >= 2 && painted) return Promise.resolve(); 16 const canUnmute = typeof window._ccHasUserMediaGesture === 'function' 17 ? window._ccHasUserMediaGesture() 18 : !!window._ccAudioUnlocked; 19 if (hasVid && !painted && !canUnmute && !el.muted) { 20 el.muted = true; 21 try { el.dataset.ccPendingUnmute = '1'; } catch (_) {} 22 } 23 } catch (_) {} 24 if (el._ccPlayInFlight) return el._ccPlayInFlight; 25 const playMutedFallback = function () { 26 try { 27 if ((el.videoWidth | 0) > 0 && !el.paused) return Promise.resolve(); 28 if (!el.muted) el.muted = true; 29 try { el.dataset.ccPendingUnmute = '1'; } catch (_) {} 30 const p2 = el.play(); 31 if (p2 && typeof p2.catch === 'function') { 32 return p2.catch(function () { return undefined; }); 33 } 34 return Promise.resolve(p2); 35 } catch (_) { 36 return Promise.resolve(); 37 } 38 }; 39 let p; 40 try { p = el.play(); } catch (e) { 41 if (e && e.name === 'NotAllowedError') { 42 try { el._ccAutoplayBlocked = true; } catch (_) {} 43 return playMutedFallback(); 44 } 45 return Promise.reject(e); 46 } 47 if (!p || typeof p.then !== 'function') return Promise.resolve(p); 48 el._ccPlayInFlight = p.then( 49 (v) => { 50 el._ccPlayInFlight = null; 51 try { el._ccAutoplayBlocked = false; } catch (_) {} 52 return v; 53 }, 54 (err) => { 55 el._ccPlayInFlight = null; 56 if (err && err.name === 'AbortError') return; 57 if (err && err.name === 'NotAllowedError') { 58 try { el._ccAutoplayBlocked = true; } catch (_) {} 59 return playMutedFallback(); 60 } 61 throw err; 62 } 63 ); 64 return el._ccPlayInFlight; 65}; 66 67/** Hard-stop a Socket.IO client so reconnection:Infinity cannot leak joins. */ 68window._ccKillSocketIo = function (sock) { 69 if (!sock) return; 70 try { 71 if (sock.io) { 72 try { sock.io.opts.reconnection = false; } catch (_) {} 73 try { if (typeof sock.io.reconnection === 'function') sock.io.reconnection(false); } catch (_) {} 74 } 75 } catch (_) {} 76 try { sock.removeAllListeners(); } catch (_) {} 77 try { sock.off(); } catch (_) {} 78 try { sock.disconnect(); } catch (_) {} 79}; 80 81/** Keep a docked cam on a reconnect overlay after producerclose / connection_lost. Never undock here â webcam:stopped still closes the tile (tab/browser quit). */ 82window._ccWebcamDisconnectGrace = window._ccWebcamDisconnectGrace || new Map(); 83window._ccMediaHoldActive = function () { 84 return !!(window._ccHoldLiveMedia && window._ccExpectMediaRestart 85 && (Date.now() - window._ccExpectMediaRestart) < 120000); 86}; 87window._ccHoldLiveMediaAcrossRestart = function () { 88 window._ccExpectMediaRestart = Date.now(); 89 window._ccHoldLiveMedia = true; 90 try { 91 document.querySelectorAll('.webcam-dock video, .docked-webcam video, #webcamPreview, .local-webcam video').forEach(function (v) { 92 try { v._ccHoldLastFrame = true; } catch (_) {} 93 }); 94 } catch (_) {} 95}; 96window._ccMediaRolloverOverlapActive = function () { 97 return !!(window._ccMediaRolloverOverlap 98 && window._ccMediaRolloverUntil 99 && (Date.now() - 0) < window._ccMediaRolloverUntil); 100}; 101window._ccBeginMediaRolloverOverlap = function (payload) { 102 if (payload && payload.overlap === false && payload.keepLive === false) return; 103 window._ccMediaRolloverOverlap = true; 104 window._ccMediaRolloverUntil = Date.now() + 20000;
105 window._ccMediaRolloverGen = (window._ccMediaRolloverGen || 0) + 1; 106 window._ccMediaRolloverRepublished = false; 107 window._ccMediaRolloverOmRepublished = false; 108 window._ccHoldLiveMediaAcrossRestart(); 109 try { 110 const viewers = window.remoteWebcamViewers || {}; 111 Object.keys(viewers).forEach(function (k) { 112 try { viewers[k]._ccRolloverRecv = false; } catch (_) {} 113 }); 114 } catch (_) {} 115 ;[1500, 4000, 9000, 12000].forEach(function (ms) { 116 setTimeout(function () { 117 try { 118 const cam = window.chatCrackWebcam; 119 if (!cam || !cam.dockedWebcams) return; 120 const names = Array.from(cam.dockedWebcams.keys ? cam.dockedWebcams.keys() : cam.dockedWebcams); 121 const me = window.currentUsername || (window.me && window.me.username) || ''; 122 names.forEach(function (uname) { 123 if (me && String(uname).toLowerCase() === String(me).toLowerCase()) return; 124 if (typeof window._ccRolloverRepairDockAudio === 'function') { 125 window._ccRolloverRepairDockAudio(uname); 126 } 127 }); 128 } catch (_) {} 129 }, ms); 130 }); 131}; 132/** Keep showing the current dock picture until the replacement stream has decoded a frame. */ 133window._ccSwapRemoteVideoWhenPainted = function (videoElement, newStream, onSwapped) { 134 if (!videoElement || !newStream) { 135 if (typeof onSwapped === 'function') onSwapped(); 136 return; 137 } 138 const alreadyPainted = (videoElement.videoWidth | 0) > 0 139 && videoElement.srcObject 140 && videoElement.srcObject !== newStream; 141 if (!alreadyPainted) { 142 try { videoElement.srcObject = newStream; } catch (_) {} 143 try { 144 const canUnmute = typeof window._ccHasUserMediaGesture === 'function' 145 ? window._ccHasUserMediaGesture() 146 : !!window._ccAudioUnlocked; 147 if (canUnmute) videoElement.muted = false; 148 } catch (_) {} 149 try { if (typeof window._ccSafeMediaPlay === 'function') window._ccSafeMediaPlay(videoElement); } catch (_) {} 150 try { 151 const uname = videoElement.dataset && videoElement.dataset.username; 152 if (uname && typeof window._ccEnsureDockWebcamAudioPlaying === 'function') { 153 window._ccEnsureDockWebcamAudioPlaying(uname, { skipIfNoTrack: false }); 154 } 155 if (uname && typeof window._ccRefreshDockAudioLevelForUser === 'function') { 156 window._ccRefreshDockAudioLevelForUser(uname); 157 } 158 } catch (_) {} 159 if (typeof onSwapped === 'function') onSwapped(); 160 return; 161 } 162 let done = false; 163 const probe = document.createElement('video'); 164 probe.muted = true; 165 probe.autoplay = true; 166 probe.playsInline = true; 167 probe.setAttribute('playsinline', ''); 168 probe.setAttribute('webkit-playsinline', 'true'); 169 probe.srcObject = newStream; 170 probe.style.cssText = 'position:absolute;left:0;top:0;width:1px;height:1px;opacity:0;pointer-events:none;z-index:-1'; 171 try { 172 if (videoElement.parentNode) videoElement.parentNode.appendChild(probe); 173 else document.body.appendChild(probe); 174 } catch (_) {} 175 videoElement._ccRolloverNextStream = newStream; 176 const finish = function () { 177 if (done) return; 178 done = true; 179 const prev = videoElement.srcObject; 180 try { 181 if (prev && prev !== newStream && prev.getAudioTracks) { 182 prev.getAudioTracks().forEach(function (t) { 183 if (!t || t.readyState !== 'live') return; 184 try { 185 if (!newStream.getAudioTracks().some(function (x) { return x.id === t.id; })) { 186 newStream.addTrack(t); 187 } 188 } catch (_) {} 189 }); 190 } 191 } catch (_) {} 192 try { videoElement.srcObject = newStream; } catch (_) {} 193 try { videoElement._ccRolloverNextStream = null; } catch (_) {} 194 try { 195 const canUnmute = typeof window._ccHasUserMediaGesture === 'function' 196 ? window._ccHasUserMediaGesture() 197 : !!window._ccAudioUnlocked; 198 if (canUnmute) videoElement.muted = false; 199 } catch (_) {} 200 try { if (typeof window._ccSafeMediaPlay === 'function') window._ccSafeMediaPlay(videoElement); } catch (_) {} 201 try { probe.pause(); } catch (_) {} 202 try { probe.srcObject = null; } catch (_) {} 203 try { probe.remove(); } catch (_) {} 204 try { 205 const uname = videoElement.dataset && videoElement.dataset.username; 206 if (uname && typeof window._ccEnsureDockWebcamAudioPlaying === 'function') { 207 window._ccEnsureDockWebcamAudioPlaying(uname, { skipIfNoTrack: false }); 208 } 209 if (uname && typeof window._ccRefreshDockAudioLevelForUser === 'function') { 210 window._ccRefreshDockAudioLevelForUser(uname); 211 } 212 } catch (_) {} 213 if (typeof onSwapped === 'function') onSwapped(); 214 }; 215 const onFrame = function () { 216 if ((probe.videoWidth | 0) > 0) finish(); 217 }; 218 try { probe.addEventListener('loadeddata', onFrame); } catch (_) {} 219 try { probe.addEventListener('playing', onFrame); } catch (_) {} 220 try { 221 if (typeof probe.requestVideoFrameCallback === 'function') { 222 probe.requestVideoFrameCallback(function () { finish(); }); 223 } 224 } catch (_) {} 225 try { if (typeof window._ccSafeMediaPlay === 'function') window._ccSafeMediaPlay(probe); } catch (_) {} 226 setTimeout(finish, 2500); 227}; 228 229window._ccAttachTrackToRolloverStreams = function (videoElement, track) { 230 if (!videoElement || !track) return; 231 const streams = []; 232 try { if (videoElement.srcObject) streams.push(videoElement.srcObject); } catch (_) {}
233 try { if (videoElement._ccRolloverNextStream) streams.push(videoElement._ccRolloverNextStream); } catch (_) {} 234 streams.forEach(function (s) { 235 if (!s || !s.getTracks) return; 236 try { 237 if (!s.getTracks().some(function (t) { return t && t.id === track.id; })) s.addTrack(track); 238 } catch (_) {} 239 }); 240}; 241 242window._ccRolloverRepairDockAudio = async function (username) { 243 if (!username) return; 244 const nl = String(username).toLowerCase(); 245 if (!window._ccAudioRepairAt) window._ccAudioRepairAt = {}; 246 const now = Date.now(); 247 if (window._ccAudioRepairAt[nl] && (now - window._ccAudioRepairAt[nl]) < 2000) return; 248 window._ccAudioRepairAt[nl] = now; 249 if (typeof window._ccDockRecvIceBad === 'function' && window._ccDockRecvIceBad(username)) { 250 if (typeof window._ccRecoverDockAfterIceFail === 'function') { 251 window._ccRecoverDockAfterIceFail(username, 'failed'); 252 } 253 return; 254 } 255 const rtc = window.chatCrackWebRTC; 256 if (!rtc || typeof rtc.consumeUserWebcam !== 'function') return; 257 const tile = (typeof window._ccFindWebcamDockTile === 'function') 258 ? window._ccFindWebcamDockTile(username) 259 : document.querySelector('.webcam-dock[data-username="' + CSS.escape(String(username)) + '"]'); 260 const video = tile && tile.querySelector('video'); 261 if (!video) return; 262 const viewer = (typeof window._ccGetRemoteWebcamViewer === 'function') 263 ? window._ccGetRemoteWebcamViewer(username) 264 : (window.remoteWebcamViewers && (window.remoteWebcamViewers[username] 265 || window.remoteWebcamViewers[String(username).toLowerCase()])); 266 if (!viewer || !viewer.socket || !viewer.device) return; 267 const roomId = (typeof window._ccWebcamRoomId === 'function') 268 ? window._ccWebcamRoomId(username) 269 : ('chatcrack_webcam_' + String(username).toLowerCase()); 270 let listed = null; 271 try { 272 listed = await new Promise(function (resolve) { 273 let done = false; 274 const to = setTimeout(function () { if (!done) { done = true; resolve(null); } }, 4000); 275 try { 276 viewer.socket.emit('chatcrack:list-producers', { roomId: roomId }, function (ack) { 277 if (done) return; 278 done = true; 279 clearTimeout(to); 280 resolve(ack); 281 }); 282 } catch (_) { 283 if (!done) { done = true; clearTimeout(to); resolve(null); } 284 } 285 }); 286 } catch (_) { listed = null; } 287 const list = listed && Array.isArray(listed.list) ? listed.list : []; 288 const uLc = String(username).toLowerCase(); 289 let newestAudio = null; 290 list.forEach(function (p) { 291 if (!p || p.kind !== 'audio') return; 292 if (String(p.userId || '').toLowerCase() !== uLc) return; 293 newestAudio = p; 294 }); 295 if (!newestAudio || !newestAudio.id) { 296 if (typeof window._ccEnsureDockWebcamAudioPlaying === 'function') { 297 try { window._ccEnsureDockWebcamAudioPlaying(username, { skipIfNoTrack: false }); } catch (_) {} 298 } 299 return; 300 } 301 const liveAudio = !!(video.srcObject && video.srcObject.getAudioTracks 302 && video.srcObject.getAudioTracks().some(function (t) { return t && t.readyState === 'live'; })); 303 if (liveAudio && video._ccAudioProducerId && String(video._ccAudioProducerId) === String(newestAudio.id)) { 304 if (typeof window._ccEnsureDockWebcamAudioPlaying === 'function') { 305 try { window._ccEnsureDockWebcamAudioPlaying(username, { skipIfNoTrack: false }); } catch (_) {} 306 } 307 if (typeof window._ccRefreshDockAudioLevelForUser === 'function') { 308 try { window._ccRefreshDockAudioLevelForUser(username); } catch (_) {} 309 } 310 return; 311 } 312 const transport = viewer.recvTransport; 313 if (!transport || transport.closed) return; 314 try { 315 await rtc.consumeUserWebcam(viewer.socket, transport, newestAudio.id, username, video, viewer.device); 316 } catch (e) { 317 console.warn('[Media] Rollover audio repair failed for', username, e); 318 } 319 if (typeof window._ccEnsureDockWebcamAudioPlaying === 'function') { 320 try { window._ccEnsureDockWebcamAudioPlaying(username, { skipIfNoTrack: false }); } catch (_) {} 321 } 322 if (typeof window._ccRefreshDockAudioLevelForUser === 'function') { 323 try { window._ccRefreshDockAudioLevelForUser(username); } catch (_) {} 324 } 325}; 326 327window._ccCloseProducerKeepTracks = window._ccCloseProducerKeepTracks || function (prod) { 328 if (!prod || prod.closed) return; 329 try { prod._stopTracks = false; } catch (_) {} 330 try { prod.close(); } catch (_) {} 331}; 332 333window._ccScheduleWebcamProducerCloseGrace = function (username, opts) { 334 const name = String(username || '').trim(); 335 if (!name) return; 336 if (typeof window._ccMediaHoldActive === 'function' && window._ccMediaHoldActive()) { 337 return; 338 } 339 if (typeof window._ccUserExplicitlyLeft === 'function' && window._ccUserExplicitlyLeft(name)) { 340 if (typeof window._ccUndockUserImmediately === 'function') {
341 try { window._ccUndockUserImmediately(name, 'user_left'); } catch (_) {} 342 } 343 return; 344 } 345 const key = name.toLowerCase(); 346 const reconnectMs = Math.max(5000, Number(opts && opts.reconnectMs) || 75000); 347 const prev = window._ccWebcamDisconnectGrace.get(key); 348 if (prev && prev.timer) { 349 try { clearTimeout(prev.timer); } catch (_) {} 350 } 351 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.showStreamDisconnectedState === 'function') { 352 window.chatCrackWebcam.showStreamDisconnectedState(name, { reconnectMs: reconnectMs }); 353 } 354 const timer = setTimeout(() => { 355 window._ccWebcamDisconnectGrace.delete(key); 356 }, reconnectMs + 4000); 357 window._ccWebcamDisconnectGrace.set(key, { timer: timer, username: name, until: Date.now() + reconnectMs }); 358}; 359window._ccCancelWebcamProducerCloseGrace = function (username) { 360 const key = String(username || '').toLowerCase(); 361 if (!key) return; 362 const prev = window._ccWebcamDisconnectGrace.get(key); 363 if (prev && prev.timer) { 364 try { clearTimeout(prev.timer); } catch (_) {} 365 } 366 window._ccWebcamDisconnectGrace.delete(key); 367}; 368window._ccWebcamHasDisconnectOverlay = function (username) { 369 const key = String(username || '').toLowerCase(); 370 if (window._ccWebcamDisconnectGrace && window._ccWebcamDisconnectGrace.has(key)) return true; 371 try { 372 const tile = (typeof window._ccFindWebcamDockTile === 'function') 373 ? window._ccFindWebcamDockTile(username) 374 : document.querySelector('.webcam-dock[data-username="' + CSS.escape(String(username || '')) + '"]'); 375 if (tile && tile.querySelector('.stream-disconnected-overlay')) return true; 376 const floating = document.querySelector('.floating-webcam[data-username="' + CSS.escape(String(username || '')) + '"]'); 377 if (floating && floating.querySelector('.stream-disconnected-overlay')) return true; 378 } catch (_) {} 379 return false; 380}; 381 382/** After media server (PM2) restart: restart local producer and refresh open webcam docks. */ 383/** 384 * Ask the media server whether a user still owns a live producer in a room. 385 * Returns true / false, or null when it cannot be determined (socket down, 386 * ack timeout) â callers must treat null as "unknown", NOT as "dead". 387 */ 388window.ccCheckMediaProducerAlive = async function (roomId, kind, username) { 389 try { 390 const rtc = window.chatCrackWebRTC; 391 if (!rtc || !rtc.socket || !rtc.socket.connected || !roomId || !username) return null; 392 const resp = await new Promise((resolve) => { 393 let done = false; 394 const to = setTimeout(() => { if (!done) { done = true; resolve(null); } }, 5000); 395 try { 396 rtc.socket.emit('chatcrack:list-producers', { roomId }, (ack) => { 397 if (done) return; 398 done = true; 399 clearTimeout(to); 400 resolve(ack); 401 }); 402 } catch (_) { 403 if (!done) { done = true; clearTimeout(to); resolve(null); } 404 } 405 }); 406 if (!resp || !Array.isArray(resp.list)) return null; 407 // The server answered. ok:false (room gone after a reboot) is "no producer", 408 // not "unknown". Unknown is only a timeout or a disconnected socket. 409 if (resp.ok === false) return false; 410 const uLc = String(username).toLowerCase(); 411 return resp.list.some(p => 412 p && (!kind || p.kind === kind) && String(p.userId || '').toLowerCase() === uLc 413 ); 414 } catch (_) { return null; } 415}; 416 417window.ccRecoverWebcamsAfterMediaRestart = async function(opts) { 418 opts = opts || {}; 419 // Coalesce concurrent triggers (media reconnect, chat reconnect, admin 420 // restart notice) â parallel runs would race producer/dock rebuilds. 421 if (window._ccMediaRecoveryInFlight) return window._ccMediaRecoveryInFlight; 422 window._ccMediaRecoveryInFlight = (async function() { 423 _chatLog('[Media] Recovering webcams after media server restartâ¦'); 424 try { 425 const rtc = window.chatCrackWebRTC; 426 const cam = window.chatCrackWebcam; 427 const om = window.chatCrackOpenMic; 428 const me = window.currentUsername || (window.me && window.me.username) || ''; 429 const broadcasting = cam?.isWebcamOn; 430 const expectRestart = !!(window._ccExpectMediaRestart 431 && (Date.now() - window._ccExpectMediaRestart) < 120000); 432 // After a media reboot, isolated viewer sockets used to stay dead 433 // (reconnection was off). They now reconnect; we still hard-refresh docks 434 // only when a restart was signaled or the outage was long. 435 // Short media-socket blips (3G, Engine.IO upgrade, proxy idle) close 436 // transports server-side, but the docks are usually still painting the last 437 // frames. Hard-refreshing every tile made cams appear to jump on/off. 438 // Only tear docks after a real media restart signal or a long outage. 439 const forceDocks = opts.force === true && !expectRestart && !opts._followUp; 440 let recoveredAnything = false;
441 let hasLiveProducer = !!(rtc?.cameraProducer && !rtc.cameraProducer.closed); 442 const overlap = !!(opts.overlap 443 || (typeof window._ccMediaRolloverOverlapActive === 'function' && window._ccMediaRolloverOverlapActive())); 444 // Overlap recycle: old producer is still live on the old router. We must 445 // dual-produce onto the new router or watchers freeze when the old one closes. 446 if (overlap) { 447 if (window._ccMediaRolloverRepublished) hasLiveProducer = true; 448 else hasLiveProducer = false; 449 } 450 // After a server reboot the client-side producer object still looks open â 451 // verify against the server before trusting it. 452 if (broadcasting && hasLiveProducer && (opts.verify || forceDocks) && me) { 453 const alive = await window.ccCheckMediaProducerAlive( 454 'chatcrack_webcam_' + String(me).toLowerCase(), 'video', me 455 ); 456 // false = server confirmed missing; null after expected restart = treat 457 // as missing (main socket may not have re-joined the room yet). 458 if (alive === false || (alive === null && expectRestart)) { 459 _chatLog('[Media] Own webcam producer missing/unknown on server â forcing restart'); 460 hasLiveProducer = false;
461 } 462 } 463 if (broadcasting && !hasLiveProducer && cam) { 464 if (overlap) window._ccMediaRolloverRepublished = true; 465 let reused = false; 466 if (typeof cam.republishWebcamKeepingTracks === 'function') { 467 try { reused = await cam.republishWebcamKeepingTracks(); } catch (_) { reused = false; } 468 } 469 // Never stopWebcam/startWebcam here â that turns the camera off then on. 470 if (!reused) { 471 _chatLog('[Media] Republish keeping tracks failed â will retry, camera stays on'); 472 } 473 recoveredAnything = true; 474 _chatLog('[Media] Broadcaster producer restarted (tracks kept=', !!reused, ')'); 475 } 476 if (om && om.isPublishing && typeof om.recoverPublisherAfterMediaRestart === 'function') { 477 await om.recoverPublisherAfterMediaRestart(); 478 _chatLog('[Media] Open mic publisher checked/restarted'); 479 } 480 if (om && typeof om._watchConsumerHealth === 'function') { 481 om._watchConsumerHealth(); 482 } 483 if (cam && cam.dockedWebcams && cam.dockedWebcams.size > 0) { 484 try { 485 if (typeof window._ccReemitViewerAddedForAllDocked === 'function') { 486 window._ccReemitViewerAddedForAllDocked(); 487 } 488 } catch (_) {}
489 const usernames = Array.from(cam.dockedWebcams.keys ? cam.dockedWebcams.keys() : cam.dockedWebcams); 490 for (const uname of usernames) { 491 try { 492 if (me && String(uname).toLowerCase() === String(me).toLowerCase()) continue; 493 if (typeof cam.hideStreamDisconnectedOverlay === 'function') { 494 cam.hideStreamDisconnectedOverlay(uname); 495 } 496 if (typeof cam._reattachWatchingDockAfterMediaRestart === 'function') { 497 await cam._reattachWatchingDockAfterMediaRestart(uname); 498 } else if (typeof cam._refreshDockedWebcamStream === 'function') { 499 await cam._refreshDockedWebcamStream(uname, { 500 locked: false, 501 forceReconnect: true, 502 keepLastFrame: true, 503 keepWatching: true 504 }); 505 } 506 recoveredAnything = true; 507 _chatLog('[Media] Reattached watching dock for', uname); 508 if (typeof window._ccRolloverRepairDockAudio === 'function') { 509 try { await window._ccRolloverRepairDockAudio(uname); } catch (_) {} 510 } 511 } catch (e) { 512 console.warn('[Media] Dock reattach failed for', uname, e); 513 } 514 } 515 } 516 if (recoveredAnything && typeof showToast === 'function' && !opts.silent && !expectRestart) { 517 showToast('Webcam streams reconnected after server restart', 'success'); 518 } 519 // Follow-up: after a planned reboot, wait for publishers to re-produce 520 // then silently re-attach docks without an off/on. Never toast that. 521 if (!opts._followUp && (expectRestart || forceDocks || recoveredAnything)) { 522 // Media boot (workers + game namespaces) can outlast the first two tries. 523 const schedule = expectRestart ? [2500, 8000, 16000, 30000] : (forceDocks ? [8000, 20000] : [8000]); 524 schedule.forEach((ms) => { 525 setTimeout(() => { 526 if (typeof window.ccRecoverWebcamsAfterMediaRestart === 'function') { 527 window.ccRecoverWebcamsAfterMediaRestart({ 528 verify: true, 529 force: false, 530 silent: true, 531 _followUp: true 532 }); 533 } 534 }, ms); 535 }); 536 } 537 // Keep the restart flag for the full hold. Clearing it on the first 538 // follow-up made later retries trust a producer the new process never had. 539 if (!opts._followUp) { 540 setTimeout(function () { window._ccHoldLiveMedia = false; }, 25000); 541 } 542 } catch (e) { 543 console.warn('[Media] ccRecoverWebcamsAfterMediaRestart:', e); 544 } 545 })().finally(function() { window._ccMediaRecoveryInFlight = null; }); 546 return window._ccMediaRecoveryInFlight; 547}; 548 549/** Collapse/expand own webcam modal (works even if instance method unavailable). */ 550window.ccToggleWebcamModalCollapse = function() { 551 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.toggleWebcamModalCollapse === 'function') { 552 window.chatCrackWebcam.toggleWebcamModalCollapse(); 553 return; 554 } 555 const modal = document.getElementById('webcamModal'); 556 if (!modal) return; 557 const collapsed = !modal.classList.contains('webcam-modal-collapsed'); 558 modal.classList.toggle('webcam-modal-collapsed', collapsed); 559 const content = modal.querySelector('.webcam-modal-content'); 560 const btn = document.getElementById('webcamModalCollapse'); 561 if (collapsed) { 562 modal.style.setProperty('height', 'auto', 'important'); 563 modal.style.setProperty('min-height', '0', 'important'); 564 modal.style.setProperty('resize', 'none', 'important'); 565 if (content) content.style.setProperty('display', 'none', 'important'); 566 } else { 567 modal.style.removeProperty('height'); 568 modal.style.removeProperty('min-height'); 569 modal.style.removeProperty('resize'); 570 if (content) content.style.removeProperty('display'); 571 if (typeof applyWebcamModalSavedSize === 'function') applyWebcamModalSavedSize(modal); 572 } 573 if (btn) { 574 btn.textContent = collapsed ? '+' : 'â'; 575 btn.title = collapsed ? 'Expand webcam panel' : 'Collapse webcam panel'; 576 } 577 try { localStorage.setItem('webcamModalCollapsed', collapsed ? '1' : '0'); } catch (_) {} 578}; 579 580/* Keep undocked cams and the own-webcam modal in a dedicated top layer */ 581function ccFloatingMediaZIndex(extra) { 582 var base = 6000000; 583 try { 584 var raw = getComputedStyle(document.documentElement).getPropertyValue('--z-floating-media'); 585 var parsed = parseInt(String(raw).trim(), 10); 586 if (parsed > 0) base = parsed; 587 } catch (_) { /* ignore */ } 588 return String(base + (extra || 0)); 589} 590 591function ccGetFloatingMediaLayer() { 592 var layer = document.getElementById('ccFloatingMediaLayer'); 593 if (!layer) { 594 layer = document.createElement('div'); 595 layer.id = 'ccFloatingMediaLayer'; 596 layer.style.zIndex = ccFloatingMediaZIndex(); 597 document.body.appendChild(layer); 598 } 599 return layer; 600} 601 602function ccPinFloatingMediaToBody() { 603 var layer = ccGetFloatingMediaLayer(); 604 ['webcamModal', 'screenShareModal'].forEach(function (id) { 605 var el = document.getElementById(id); 606 if (el && el.parentElement !== layer) layer.appendChild(el); 607 }); 608 document.querySelectorAll('.floating-webcam, .floating-screen').forEach(function (el) { 609 if (el.parentElement !== layer) layer.appendChild(el); 610 }); 611} 612 613function ccShekelsChildZIndex() { 614 var shek = document.getElementById('shekelsModal'); 615 if (shek && shek.style.display !== 'none') { 616 var z = parseInt(shek.style.zIndex || getComputedStyle(shek).zIndex, 10); 617 if (Number.isFinite(z) && z > 0) return z + 100; 618 } 619 try { 620 var cssZ = getComputedStyle(document.documentElement).getPropertyValue('--z-shekels-child').trim(); 621 if (cssZ) return parseInt(cssZ, 10) || 5100000; 622 } catch (e) {} 623 return 5100000; 624} 625 626function ccIsCasinoGameModalEl(el) { 627 if (!el || !el.id) return false;
628 var list = (typeof CC_CASINO_GAMES !== 'undefined') ? CC_CASINO_GAMES : []; 629 for (var i = 0; i < list.length; i++) { 630 if (list[i] && list[i].modalId === el.id) return true; 631 } 632 return false; 633} 634 635function ccEnsureAboveShekelsModal(el) { 636 if (!el) return; 637 if (ccIsCasinoGameModalEl(el) && typeof window.ccRaiseCasinoGameModal === 'function') { 638 window.ccRaiseCasinoGameModal(el.id); 639 return; 640 } 641 if (el.parentElement !== document.body) document.body.appendChild(el); 642 var z = ccShekelsChildZIndex(); 643 var shek = document.getElementById('shekelsModal'); 644 if (shek && shek.style.display !== 'none') { 645 var shekZ = parseInt(shek.style.zIndex || '5300000', 10) || 5300000; 646 if (!(z > shekZ)) z = shekZ + 100; 647 } 648 el.style.setProperty('z-index', String(z), 'important'); 649} 650 651function ccIsShekelsModalOpen() { 652 var m = document.getElementById('shekelsModal'); 653 if (!m) return false; 654 if (m.style.display === 'none') return false; 655 return getComputedStyle(m).display !== 'none'; 656} 657 658function ccEnsureOwnWebcamOnTop(forceVideo) { 659 ccPinFloatingMediaToBody(); 660 var modal = document.getElementById('webcamModal'); 661 if (!modal) return; 662 if (document.documentElement.classList.contains('cc-ios-shell') 663 && modal.getAttribute('data-forced-closed') === 'true' 664 && window.chatCrackWebcam && window.chatCrackWebcam.isWebcamOn) { 665 return; 666 } 667 if (modal.classList.contains('dragging') || modal.classList.contains('resizing')) return; 668 var wc = window.chatCrackWebcam; 669 var rtc = window.chatCrackWebRTC; 670 var streaming = !!(wc && wc.isWebcamOn); 671 var userOpened = !!(wc && wc.modalOpenedByUser); 672 if (!streaming && !userOpened) return; 673 674 if (streaming && !userOpened) { 675 var u = window.currentUsername || (window.me && window.me.username); 676 var classicSelf = u && typeof window._ccIsClassicWebcamLayoutUsername === 'function' 677 && window._ccIsClassicWebcamLayoutUsername(u); 678 if (classicSelf) { 679 if (wc) wc.modalOpenedByUser = true; 680 } else if (wc && u) { 681 if (typeof wc._ensureOwnWebcamDocked === 'function') { 682 try { wc._ensureOwnWebcamDocked(); } catch (_) {} 683 } 684 if (typeof wc._hideWebcamModalKeepStreaming === 'function') { 685 try { wc._hideWebcamModalKeepStreaming(); } catch (_) {} 686 } 687 return; 688 } 689 } 690 691 if (streaming) modal.removeAttribute('data-forced-closed'); 692 if (modal.getAttribute('data-forced-closed') === 'true') return; 693 694 var cs = getComputedStyle(modal); 695 var hidden = cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity || '1') < 0.05; 696 var offscreen = modal.style.left === '-9999px' || modal.style.top === '-9999px'; 697 if (hidden || offscreen) { 698 modal.hidden = false; 699 modal.style.setProperty('position', 'fixed', 'important'); 700 modal.style.setProperty('display', 'flex', 'important'); 701 modal.style.setProperty('visibility', 'visible', 'important'); 702 modal.style.setProperty('opacity', '1', 'important'); 703 if (offscreen && !modal.dataset.ccDragInit) { 704 modal.style.setProperty('left', '50%', 'important'); 705 modal.style.setProperty('top', '50%', 'important'); 706 modal.style.setProperty('transform', 'translate(-50%, -50%)', 'important'); 707 } 708 } 709 modal.setAttribute('data-cc-live', streaming ? '1' : '0'); 710 711 if (!streaming && !forceVideo) return; 712 var stream = (wc && wc.localStream) || (rtc && rtc.localStream); 713 var preview = document.getElementById('webcamPreview'); 714 if (stream && preview) { 715 if (!preview.srcObject || preview.srcObject !== stream) preview.srcObject = stream; 716 if (forceVideo || preview.paused || !preview.videoWidth) { 717 var playPromise = preview.play && preview.play(); 718 if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(function () {}); 719 } 720 } 721} 722 723function ccRaiseFloatingMedia(el, extra) { 724 if (!el) return; 725 var layer = ccGetFloatingMediaLayer(); 726 if (el.parentElement !== layer) layer.appendChild(el); 727 el.style.setProperty('z-index', String(1 + (extra ? 1 : 0)), 'important'); 728 el.style.setProperty('visibility', 'visible', 'important'); 729 el.style.setProperty('opacity', '1', 'important'); 730} 731 732function ccInitWebcamStackGuard() { 733 if (window._ccWebcamStackGuardInit) return; 734 window._ccWebcamStackGuardInit = true; 735 ccPinFloatingMediaToBody(); 736 setInterval(function () { 737 if (!window.chatCrackWebcam || (!window.chatCrackWebcam.isWebcamOn && !window.chatCrackWebcam.modalOpenedByUser)) return; 738 ccEnsureOwnWebcamOnTop(false); 739 }, 5000); 740} 741 742window.ccGetFloatingMediaLayer = ccGetFloatingMediaLayer; 743window.ccPinFloatingMediaToBody = ccPinFloatingMediaToBody; 744window.ccEnsureAboveShekelsModal = ccEnsureAboveShekelsModal;
745window.ccShekelsChildZIndex = ccShekelsChildZIndex; 746window.ccIsShekelsModalOpen = ccIsShekelsModalOpen; 747window.ccEnsureOwnWebcamOnTop = ccEnsureOwnWebcamOnTop; 748window.ccRaiseFloatingMedia = ccRaiseFloatingMedia; 749window.ccFloatingMediaZIndex = ccFloatingMediaZIndex; 750window.ccInitWebcamStackGuard = ccInitWebcamStackGuard; 751if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ccInitWebcamStackGuard); 752else ccInitWebcamStackGuard(); 753 754var WEBCAM_MODAL_SIZE_KEY = 'webcamModalSize'; 755var WEBCAM_MODAL_SIZE_VER = 2; 756var WEBCAM_MODAL_DEFAULT_W = 280; 757var WEBCAM_MODAL_DEFAULT_H = 340; 758var WEBCAM_MODAL_MIN_W = 200; 759var WEBCAM_MODAL_MIN_H = 200; 760 761function getWebcamModalDefaultSize() { 762 var vw = window.innerWidth || 1024; 763 var vh = window.innerHeight || 768; 764 if (vw <= 480 || vh <= 600) { 765 return { 766 w: Math.max(WEBCAM_MODAL_MIN_W, Math.min(240, Math.floor(vw * 0.88))), 767 h: Math.max(WEBCAM_MODAL_MIN_H, Math.min(300, Math.floor(vh * 0.72))) 768 }; 769 } 770 if (vw <= 768 || vh <= 700) { 771 return { 772 w: Math.max(WEBCAM_MODAL_MIN_W, Math.min(260, Math.floor(vw * 0.85))), 773 h: Math.max(WEBCAM_MODAL_MIN_H, Math.min(320, Math.floor(vh * 0.75))) 774 }; 775 } 776 return { w: WEBCAM_MODAL_DEFAULT_W, h: WEBCAM_MODAL_DEFAULT_H }; 777} 778window.getWebcamModalDefaultSize = getWebcamModalDefaultSize; 779 780function syncWebcamModalUiScale(modal) { 781 if (!modal) return; 782 var w = modal.offsetWidth || WEBCAM_MODAL_DEFAULT_W; 783 var h = modal.offsetHeight || WEBCAM_MODAL_DEFAULT_H; 784 var scaleW = Math.max(0.58, Math.min(1, w / WEBCAM_MODAL_DEFAULT_W)); 785 var scaleH = Math.max(0.58, Math.min(1, h / WEBCAM_MODAL_DEFAULT_H)); 786 var scale = Math.min(scaleW, scaleH); 787 modal.style.setProperty('--wcm-scale', scale.toFixed(3)); 788} 789 790/** Keep the modal on-screen and ensure footer controls are never clipped. */ 791function fitWebcamModalToViewport(modal) { 792 if (!modal || modal.classList.contains('webcam-modal-collapsed')) return; 793 var maxH = Math.floor(window.innerHeight * 0.85); 794 var maxW = Math.floor(window.innerWidth * 0.92); 795 var h = modal.offsetHeight || WEBCAM_MODAL_DEFAULT_H; 796 var w = modal.offsetWidth || WEBCAM_MODAL_DEFAULT_W; 797 if (h > maxH) { 798 modal.style.setProperty('height', maxH + 'px', 'important'); 799 } 800 if (w > maxW) { 801 modal.style.setProperty('width', maxW + 'px', 'important'); 802 } 803 syncWebcamModalUiScale(modal); 804} 805window.fitWebcamModalToViewport = fitWebcamModalToViewport; 806window.syncWebcamModalUiScale = syncWebcamModalUiScale; 807 808function applyWebcamModalSavedSize(modal) { 809 if (!modal || modal.classList.contains('webcam-modal-collapsed')) return; 810 var defaults = getWebcamModalDefaultSize(); 811 var w = defaults.w; 812 var h = defaults.h; 813 try { 814 var raw = localStorage.getItem(WEBCAM_MODAL_SIZE_KEY); 815 if (raw) { 816 var parsed = JSON.parse(raw); 817 if (Number(parsed.v) === WEBCAM_MODAL_SIZE_VER) { 818 w = parseInt(parsed.w, 10) || w; 819 h = parseInt(parsed.h, 10) || h; 820 } 821 } 822 } catch (_) { /* ignore */ } 823 var maxW = Math.floor(window.innerWidth * 0.95); 824 var maxH = Math.floor(window.innerHeight * 0.9); 825 w = Math.max(WEBCAM_MODAL_MIN_W, Math.min(maxW, w)); 826 h = Math.max(WEBCAM_MODAL_MIN_H, Math.min(maxH, h)); 827 modal.style.setProperty('width', w + 'px', 'important'); 828 modal.style.setProperty('height', h + 'px', 'important'); 829 modal.style.setProperty('min-height', WEBCAM_MODAL_MIN_H + 'px', 'important'); 830 fitWebcamModalToViewport(modal); 831} 832window.applyWebcamModalSavedSize = applyWebcamModalSavedSize; 833 834function saveWebcamModalSize(modal) { 835 if (!modal) return; 836 try { 837 var w = modal.offsetWidth; 838 var h = modal.offsetHeight; 839 if (w >= WEBCAM_MODAL_MIN_W && h >= WEBCAM_MODAL_MIN_H) { 840 localStorage.setItem(WEBCAM_MODAL_SIZE_KEY, JSON.stringify({ w: w, h: h, v: WEBCAM_MODAL_SIZE_VER })); 841 } 842 } catch (_) { /* ignore */ } 843} 844 845function pinWebcamModalPosition(modal) { 846 if (!modal) return null; 847 var layer = (typeof ccGetFloatingMediaLayer === 'function') ? ccGetFloatingMediaLayer() : document.body; 848 if (modal.parentElement !== layer) layer.appendChild(modal); 849 var rect = modal.getBoundingClientRect(); 850 modal.style.setProperty('position', 'fixed', 'important'); 851 modal.style.setProperty('left', rect.left + 'px', 'important'); 852 modal.style.setProperty('top', rect.top + 'px', 'important'); 853 modal.style.setProperty('transform', 'none', 'important'); 854 modal.style.setProperty('margin', '0', 'important'); 855 modal.style.setProperty('display', 'flex', 'important'); 856 modal.style.setProperty('visibility', 'visible', 'important'); 857 modal.style.setProperty('opacity', '1', 'important'); 858 modal.dataset.ccDragInit = '1'; 859 return rect; 860} 861 862function makeWebcamModalResizable(modal) { 863 if (!modal || modal.dataset.ccResizeWired === '1') return; 864 var handle = document.getElementById('webcamModalResize'); 865 if (!handle) return; 866 modal.dataset.ccResizeWired = '1'; 867 868 var resizing = false;
869 var startX = 0; 870 var startY = 0; 871 var startW = 0; 872 var startH = 0; 873 874 function onResizeDown(e) { 875 if (modal.classList.contains('webcam-modal-collapsed')) return; 876 if (e.button !== undefined && e.button !== 0) return; 877 pinWebcamModalPosition(modal); 878 resizing = true; 879 startX = e.clientX; 880 startY = e.clientY; 881 startW = modal.offsetWidth; 882 startH = modal.offsetHeight; 883 modal.classList.add('resizing'); 884 modal.style.setProperty('z-index', '2', 'important'); 885 window.addEventListener('pointermove', onResizeMove, true); 886 window.addEventListener('pointerup', onResizeUp, true); 887 window.addEventListener('pointercancel', onResizeUp, true); 888 e.preventDefault(); 889 e.stopPropagation(); 890 } 891 892 function onResizeMove(e) { 893 if (!resizing) return; 894 var maxW = Math.floor(window.innerWidth * 0.95); 895 var maxH = Math.floor(window.innerHeight * 0.9); 896 var newW = Math.max(WEBCAM_MODAL_MIN_W, Math.min(maxW, startW + (e.clientX - startX))); 897 var newH = Math.max(WEBCAM_MODAL_MIN_H, Math.min(maxH, startH + (e.clientY - startY))); 898 modal.style.setProperty('width', newW + 'px', 'important'); 899 modal.style.setProperty('height', newH + 'px', 'important'); 900 syncWebcamModalUiScale(modal); 901 } 902 903 function onResizeUp() { 904 if (!resizing) return; 905 resizing = false; 906 modal.classList.remove('resizing'); 907 syncWebcamModalUiScale(modal); 908 fitWebcamModalToViewport(modal); 909 saveWebcamModalSize(modal); 910 if (typeof ccRaiseFloatingMedia === 'function') { 911 ccRaiseFloatingMedia(modal); 912 } 913 window.removeEventListener('pointermove', onResizeMove, true); 914 window.removeEventListener('pointerup', onResizeUp, true); 915 window.removeEventListener('pointercancel', onResizeUp, true); 916 } 917 918 handle.addEventListener('pointerdown', onResizeDown); 919} 920window.makeWebcamModalResizable = makeWebcamModalResizable; 921 922/* Make modal draggable (webcam + screen share modals) */ 923function makeModalDraggable(modal) { 924 if (!modal || modal.dataset.ccDragWired === '1') return; 925 const header = modal.querySelector('.webcam-modal-header, .screen-share-modal-header'); 926 if (!header) return; 927 modal.dataset.ccDragWired = '1'; 928 929 let isDragging = false; 930 let startX = 0; 931 let startY = 0; 932 let startLeft = 0; 933 let startTop = 0; 934 935 header.style.cursor = 'move'; 936 header.style.userSelect = 'none'; 937 938 function pinModalPosition() { 939 var layer = (typeof ccGetFloatingMediaLayer === 'function') ? ccGetFloatingMediaLayer() : document.body; 940 if (modal.parentElement !== layer) layer.appendChild(modal); 941 const rect = modal.getBoundingClientRect(); 942 modal.style.setProperty('position', 'fixed', 'important'); 943 modal.style.setProperty('left', rect.left + 'px', 'important'); 944 modal.style.setProperty('top', rect.top + 'px', 'important'); 945 modal.style.setProperty('transform', 'none', 'important'); 946 modal.style.setProperty('margin', '0', 'important'); 947 modal.style.setProperty('display', 'flex', 'important'); 948 modal.style.setProperty('visibility', 'visible', 'important'); 949 modal.style.setProperty('opacity', '1', 'important'); 950 modal.dataset.ccDragInit = '1'; 951 return rect; 952 } 953 954 header.addEventListener('mousedown', (e) => { 955 if (e.target.closest('.webcam-modal-close, .webcam-modal-collapse, .webcam-modal-settings, #screenModalClose, #screenModalMinimize, .screen-share-modal-minimize, button')) return; 956 957 const rect = pinModalPosition(); 958 isDragging = true; 959 startX = e.clientX; 960 startY = e.clientY; 961 startLeft = rect.left; 962 startTop = rect.top; 963 modal.classList.add('dragging'); 964 modal.style.setProperty('z-index', '2', 'important'); 965 modal.style.setProperty('display', 'flex', 'important'); 966 modal.style.setProperty('visibility', 'visible', 'important'); 967 modal.style.setProperty('opacity', '1', 'important'); 968 e.preventDefault(); 969 }); 970 971 document.addEventListener('mousemove', (e) => { 972 if (!isDragging) return; 973 e.preventDefault(); 974 const left = startLeft + (e.clientX - startX); 975 const top = startTop + (e.clientY - startY); 976 modal.style.setProperty('left', left + 'px', 'important'); 977 modal.style.setProperty('top', top + 'px', 'important'); 978 modal.style.setProperty('display', 'flex', 'important'); 979 modal.style.setProperty('visibility', 'visible', 'important'); 980 modal.style.setProperty('opacity', '1', 'important'); 981 }); 982 983 document.addEventListener('mouseup', () => { 984 if (!isDragging) return; 985 isDragging = false;
986 modal.classList.remove('dragging'); 987 if (typeof ccRaiseFloatingMedia === 'function') { 988 ccRaiseFloatingMedia(modal); 989 } else { 990 modal.style.setProperty('z-index', ccFloatingMediaZIndex(), 'important'); 991 } 992 }); 993} 994 995// YouTube Controller - WebSocket-based queue management 996window.YouTubeCtrl = (function(){ 997 let lastVersion = 0; 998 let actionInFlight = false; 999 1000 function connectWS(){ 1001 if (!window.socket || !window.socket.connected) return; 1002 const room = `room:youtube:${window.currentRoomId || 'default'}`; 1003 window.socket.emit('room:join', { room }); 1004 window.socket.on('youtube:state', onState); 1005 window.socket.on('youtube:play-start', onPlayStart); 1006 console.log('ð¥ YOUTUBE: WebSocket controller connected to room:', room); 1007 } 1008 1009 function onState(payload){ 1010 if (!payload || typeof payload.version !== 'number') return; 1011 if (payload.version <= lastVersion) return; // idempotent guard 1012 lastVersion = payload.version; 1013 1014 youtubeQueue = payload.queue || []; 1015 youtubeState = payload.current || null; 1016 1017 // Render queue (debounced) 1018 requestAnimationFrame(updateYouTubeQueue); 1019 1020 // Sync player position if dock is open 1021 if (window.youtubePlayer && youtubeState?.playing) { 1022 const serverSec = youtubeState.position ?? ((Date.now() - youtubeState.startedAt)/1000); 1023 syncPlayerPosition(serverSec); 1024 } 1025 } 1026 1027 function onPlayStart(payload){ 1028 const vid = payload?.videoId; 1029 if (!vid) return; 1030 if (payload?.play_for_all && typeof handleYouTubePlayForAll === 'function') { 1031 handleYouTubePlayForAll(vid, { title: payload.title || '', channel: payload.channel || '' }, { silent: true }); 1032 } 1033 } 1034 1035 function syncPlayerPosition(serverSeconds){ 1036 try { 1037 const p = getYtDockPlayer(); 1038 if (!p || typeof p.getCurrentTime !== 'function') return; 1039 const diff = Math.abs(p.getCurrentTime() - serverSeconds); 1040 if (diff > 0.75) p.seekTo(serverSeconds, true); 1041 } catch {} 1042 } 1043 1044 // Public command wrappers (ACK with timeouts) 1045 function add(videoId, title, channel){ 1046 if (actionInFlight) return; 1047 actionInFlight = true; 1048 window.socket.timeout(6000).emit('youtube:add', { videoId, title, channel }, (err, ack) => { 1049 actionInFlight = false; 1050 if (err) return showToast('Add failed', 'error'); 1051 // Do nothing locally; wait for youtube:state 1052 }); 1053 } 1054 1055 function remove(videoId){ 1056 if (actionInFlight) return; 1057 actionInFlight = true; 1058 window.socket.timeout(6000).emit('youtube:remove', { videoId }, (err) => { 1059 actionInFlight = false; 1060 if (err) showToast('Remove failed', 'error'); 1061 }); 1062 } 1063 1064 function skip(){ 1065 if (actionInFlight) return; 1066 actionInFlight = true; 1067 window.socket.timeout(6000).emit('youtube:skip', {}, (err) => { 1068 actionInFlight = false; 1069 if (err) showToast('Skip failed', 'error'); 1070 }); 1071 } 1072 1073 return { connectWS, add, remove, skip }; 1074})(); 1075 1076/* WebSocket status indicator â flashing banner under header tabs / above ticker */ 1077function ccIsWsLiveForIndicator() { 1078 if (typeof navigator !== 'undefined' && navigator.onLine === false) return false; 1079 if (window._ccManualLogoutRequested || window._wsUserLoggedOut) return false; 1080 var s = window.socket; 1081 return !!(s && s.connected); 1082} 1083window.ccIsWsLiveForIndicator = ccIsWsLiveForIndicator; 1084 1085function ccEnsureWsDisconnectBanner() { 1086 var el = document.getElementById('ccWsDisconnectBanner'); 1087 if (el) return el; 1088 var ticker = document.querySelector('.top .ticker') || document.querySelector('.ticker'); 1089 if (!ticker || !ticker.parentNode) return null; 1090 el = document.createElement('div'); 1091 el.id = 'ccWsDisconnectBanner'; 1092 el.className = 'cc-ws-disconnect-banner'; 1093 el.setAttribute('role', 'status'); 1094 el.setAttribute('aria-live', 'polite'); 1095 el.hidden = true; 1096 el.setAttribute('aria-hidden', 'true'); 1097 el.textContent = '*WS not connected*'; 1098 ticker.parentNode.insertBefore(el, ticker); 1099 return el; 1100} 1101 1102/** Show flashing disconnect banner when chat WS is down â not while a handshake is in flight. */ 1103function updateWsStatusIndicator(connected) { 1104 var live = false;
1105 if (connected === true) live = true; 1106 else if (connected === false) live = false; 1107 else live = !!(window.socket && window.socket.connected); 1108 1109 if (live) { 1110 try { window.__wsHadSuccessfulConnect = true; } catch (_) {} 1111 } 1112 1113 var el = ccEnsureWsDisconnectBanner(); 1114 if (!el) return; 1115 1116 var loggedOut = !!(window._ccManualLogoutRequested || window._wsUserLoggedOut); 1117 var offline = typeof navigator !== 'undefined' && navigator.onLine === false; 1118 var sock = window.socket; 1119 // Socket.IO "active" / early create = connecting â don't flash red during handshake. 1120 var connecting = !!(!live && ( 1121 (sock && sock.active) || 1122 window._cleanWsCreating || 1123 window._wsConnectPending 1124 )); 1125 // Brief boot grace so the banner does not appear while identity/WS are still starting. 1126 if (!window._ccPageStartMs) window._ccPageStartMs = Date.now(); 1127 var inBootGrace = !window.__wsHadSuccessfulConnect && (Date.now() - window._ccPageStartMs) < 6000; 1128 var show = !loggedOut && (offline || (!live && !connecting && !inBootGrace)); 1129 1130 el.classList.toggle('is-visible', show); 1131 el.hidden = !show; 1132 el.setAttribute('aria-hidden', show ? 'false' : 'true'); 1133 if (show) { 1134 el.textContent = '*WS not connected*'; 1135 } 1136} 1137function ccSyncWsStatusIndicator() { 1138 if (typeof updateWsStatusIndicator === 'function') updateWsStatusIndicator(); 1139} 1140window.ccSyncWsStatusIndicator = ccSyncWsStatusIndicator; 1141function initWsStatusHelp() { 1142 ccEnsureWsDisconnectBanner(); 1143 ccSyncWsStatusIndicator(); 1144 // Keep banner in sync during boot until the socket is actually up. 1145 if (!window._ccWsBannerBootPoll) { 1146 window._ccWsBannerBootPoll = setInterval(function () { 1147 ccSyncWsStatusIndicator(); 1148 if (window.socket && window.socket.connected) { 1149 clearInterval(window._ccWsBannerBootPoll); 1150 window._ccWsBannerBootPoll = null; 1151 } 1152 }, 1000); 1153 } 1154 if (!window._wsOnlineOfflineWired) { 1155 window._wsOnlineOfflineWired = true; 1156 window.addEventListener('offline', function () { ccSyncWsStatusIndicator(); }); 1157 window.addEventListener('online', function () { 1158 ccSyncWsStatusIndicator(); 1159 try { 1160 var s = window.socket; 1161 if (s && !s.connected) { 1162 if (s.io && s.io.backoff && typeof s.io.backoff.reset === 'function') s.io.backoff.reset(); 1163 if (typeof s.connect === 'function') s.connect(); 1164 } else if (!s && typeof window.ccScheduleWsConnect === 'function') { 1165 window.ccScheduleWsConnect(); 1166 } 1167 } catch (_) {} 1168 }); 1169 } 1170} 1171window.initWsStatusHelp = initWsStatusHelp; 1172window.updateWsStatusIndicator = updateWsStatusIndicator; 1173 1174/** 1175 * Resume the chat socket when the tab becomes visible again. 1176 * 1177 * IMPORTANT: Never disconnect a socket that still reports connected. Doing so 1178 * on "stale" pong timestamps caused random reconnects while internet was fine 1179 * (quiet rooms, timer throttle, game main-thread stalls). Socket.IO's own 1180 * Engine.IO ping already detects truly dead transports. 1181 * 1182 * Only call connect()/re-init when the socket is actually down. 1183 */ 1184function ccForceSocketResumeNow(opts) { 1185 opts = opts || {}; 1186 var s = window.socket; 1187 try { 1188 if (s && s.connected) { 1189 // Live â nudge app-level RTT only. Do NOT disconnect/recreate.
1190 try { s.emit('cc:ping', { t: Date.now(), resume: 1 }); } catch (_) {} 1191 try { s.emit('ping'); } catch (_) {} 1192 return; 1193 } 1194 if (s && s.io) { 1195 try { if (s.io.backoff && typeof s.io.backoff.reset === 'function') s.io.backoff.reset(); } catch (_) {} 1196 try { if (s.io._readyState === 'closed' && typeof s.io.open === 'function') s.io.open(); } catch (_) {} 1197 } 1198 try { if (s && typeof s.connect === 'function') s.connect(); } catch (_) {} 1199 1200 // Hard fallback only if still disconnected after a real wait (not 100ms). 1201 try { clearTimeout(window._ccResumeReinitTimer); } catch (_) {} 1202 window._ccResumeReinitTimer = setTimeout(function () { 1203 if (window.socket && window.socket.connected) return; 1204 if (!window.socket && typeof window.ccStartEarlyWsSocket === 'function') { 1205 try { window.ccStartEarlyWsSocket(); } catch (_) {} 1206 } 1207 if (typeof initCleanWebSocket === 'function') { 1208 try { initCleanWebSocket({ force: true }); } catch (_) {} 1209 } 1210 }, opts.forceReconnect ? 1500 : 4000); 1211 } catch (_) {} 1212} 1213window.ccForceSocketResumeNow = ccForceSocketResumeNow; 1214 1215/* ChatCrack Radio player lives in cc-radio.js so edits in this file cannot stop the sidebar player from booting. */ 1216 1217/* Simple, clean Socket.IO init */ 1218 1219/** Probe optional regional WS edges and connect everyone to their fastest node. */ 1220const CC_EDGE_CACHE_MS = 86400000; // 24 h â survives refresh 1221const CC_EDGE_MIGRATE_MIN_GAIN_MS = 80; 1222 1223function ccEdgeEndpointsKey(eps) { 1224 return 'cc_edge_pick_' + (eps || []).map(e => e.id || e.ws).join('|'); 1225} 1226 1227function ccReadEdgeCache(cacheKey) { 1228 const stores = [localStorage, sessionStorage]; 1229 for (const store of stores) { 1230 try { 1231 const raw = store.getItem(cacheKey); 1232 if (!raw) continue; 1233 const cached = JSON.parse(raw); 1234 if (cached && cached.id && (Date.now() - (cached.ts || 0)) < CC_EDGE_CACHE_MS) return cached; 1235 } catch (_) {} 1236 } 1237 return null; 1238} 1239 1240function ccWriteEdgeCache(cacheKey, pick) { 1241 const payload = JSON.stringify({ id: pick.id, ts: Date.now(), rtt: pick.rtt }); 1242 try { localStorage.setItem(cacheKey, payload); } catch (_) {} 1243 try { sessionStorage.setItem(cacheKey, payload); } catch (_) {} 1244} 1245 1246function ccApplyEdgePick(ep, fallback) { 1247 const prevWs = window.CC_WS_URL; 1248 window.CC_WS_URL = ep.ws || fallback.ws; 1249 window.CC_MEDIA_ORIGIN = ep.media || fallback.media; 1250 window.CC_SELECTED_EDGE = ep.id || 'default'; 1251 window._ccEdgeUrlChanged = prevWs && prevWs !== window.CC_WS_URL; 1252 return window.CC_SELECTED_EDGE; 1253} 1254 1255function ccEdgeFromHint(eps, hintId, fallback) { 1256 if (!hintId) return null; 1257 const hit = eps.find(e =>
1257 e && (e.id === hintId || e.region === hintId)); 1258 return hit || null; 1259} 1260 1261async function ccHttpProbeOneEdgeEndpoint(ep, timeoutMs) { 1262 if (!ep || !ep.ws) return { ...ep, rtt: Infinity }; 1263 // /health/rtt CORS fails on production edges (console red) â use WS probe instead. 1264 if (window._ccHealthRttCorsBroken) return { ...ep, rtt: Infinity }; 1265 const base = String(ep.ws).replace(/\/+$/, ''); 1266 const url = base + '/health/rtt?_=' + Date.now(); 1267 const t0 = Date.now(); 1268 try { 1269 const ctrl = typeof AbortController === 'function' ? new AbortController() : null; 1270 const timer = ctrl ? setTimeout(() => ctrl.abort(), timeoutMs || 3500) : null; 1271 const res = await fetch(url, { method: 'GET', cache: 'no-store', mode: 'cors', signal: ctrl ? ctrl.signal : undefined }); 1272 if (timer) clearTimeout(timer); 1273 if (!res.ok) return { ...ep, rtt: Infinity }; 1274 await res.json().catch(() => null); 1275 return { ...ep, rtt: Math.max(1, Date.now() - t0) }; 1276 } catch (_) { 1277 window._ccHealthRttCorsBroken = true; 1278 return { ...ep, rtt: Infinity }; 1279 } 1280} 1281 1282async function ccProbeOneEdgeEndpoint(ep) { 1283 const http = await ccHttpProbeOneEdgeEndpoint(ep, 3500); 1284 if (Number.isFinite(http.rtt) && http.rtt < Infinity) return http; 1285 return new Promise((resolve) => { 1286 if (!ep || !ep.ws || typeof io !== 'function') { 1287 resolve({ ...ep, rtt: Infinity }); 1288 return; 1289 } 1290 let done = false; 1291 const finish = (rtt) => { 1292 if (done) return; 1293 done = true; 1294 try { sock.disconnect(); } catch (_) {} 1295 resolve({ ...ep, rtt: Number.isFinite(rtt) ? Math.round(rtt) : Infinity }); 1296 }; 1297 const sock = io(ep.ws, { 1298 path: '/ws/', 1299 transports: ['websocket'], 1300 auth: { key: 'dev-http-key', username: '__edge_probe__', role: 'member' }, 1301 timeout: 4000, 1302 forceNew: true, 1303 autoConnect: true, 1304 reconnection: false, 1305 }); 1306 const timer = setTimeout(() => finish(Infinity), 5000); 1307 sock.on('connect', () => { 1308 const t0 = Date.now(); 1309 sock.emit('cc:ping', { t: t0 }); 1310 sock.once('cc:pong', (data) => { 1311 clearTimeout(timer); 1312 finish(data && data.t != null ? Date.now() - data.t : Date.now() - t0); 1313 }); 1314 }); 1315 sock.on('connect_error', () => { clearTimeout(timer); finish(Infinity); }); 1316 }); 1317} 1318 1319async function ccProbeEdgeEndpoints(endpoints) { 1320 const eps = (endpoints || []).filter(ep => ep && ep.ws); 1321 if (!eps.length) return null; 1322 if (eps.length === 1) return { ...eps[0], rtt: 0 }; 1323 const httpResults = await Promise.all(eps.map(ep => ccHttpProbeOneEdgeEndpoint(ep, 3500))); 1324 httpResults.sort((a, b) => (a.rtt || Infinity) - (b.rtt || Infinity)); 1325 const bestHttp = httpResults[0]; 1326 const secondHttp = httpResults[1]; 1327 if (bestHttp && Number.isFinite(bestHttp.rtt) && bestHttp.rtt < Infinity) { 1328 if (!secondHttp || !Number.isFinite(secondHttp.rtt) || (secondHttp.rtt - bestHttp.rtt) > 40) { 1329 return bestHttp; 1330 } 1331 const wsTop = await Promise.all([ccProbeOneEdgeEndpoint(bestHttp), ccProbeOneEdgeEndpoint(secondHttp)]); 1332 wsTop.sort((a, b) => (a.rtt || Infinity) - (b.rtt || Infinity)); 1333 return wsTop[0]; 1334 } 1335 const results = await Promise.all(eps.map(ccProbeOneEdgeEndpoint)); 1336 results.sort((a, b) => (a.rtt || Infinity) - (b.rtt || Infinity)); 1337 return results[0]; 1338} 1339 1340async function ccApplyEdgeEndpoint(endpoints, opts) { 1341 const options = opts || {}; 1342 const fallback = { 1343 id: 'default', 1344 ws: 'https://ws.chatcrack.us', 1345 media: 'https://media.chatcrack.us', 1346 }; 1347 const eps = Array.isArray(endpoints) && endpoints.length ? endpoints : [fallback]; 1348 if (eps.length === 1) { 1349 return ccApplyEdgePick(eps[0], fallback); 1350 } 1351 const cacheKey = ccEdgeEndpointsKey(eps); 1352 const cached = ccReadEdgeCache(cacheKey); 1353 if (cached && cached.id) { 1354 const hit = eps.find(e => e.id === cached.id); 1355 if (hit) return ccApplyEdgePick(hit, fallback); 1356 } 1357 const hintId = options.suggestedEdge 1358 || (window.me && window.me.country_code && typeof ccGuessEdgeFromCountry === 'function' ? ccGuessEdgeFromCountry(window.me.country_code, eps) : null) 1359 || window.__bootSuggestedEdge 1360 || null; 1361 const hinted = ccEdgeFromHint(eps, hintId, fallback); 1362 if (options.skipProbe) { 1363 if (hinted) return ccApplyEdgePick(hinted, fallback); 1364 return ccApplyEdgePick(eps[0], fallback); 1365 } 1366 const winner = await ccProbeEdgeEndpoints(eps); 1367 const pick = winner && winner.ws && Number.isFinite(winner.rtt) && winner.rtt < Infinity ? winner : (hinted || eps[0]); 1368 ccApplyEdgePick(pick, fallback); 1369 ccWriteEdgeCache(cacheKey, pick); 1370 try { 1371 console.log('[EDGE] Selected', pick.id, pick.ws, pick.rtt != null ? pick.rtt + 'ms' : ''); 1372 } catch (_) {} 1373 return pick.id; 1374} 1375 1376function ccGuessEdgeFromCountry(countryCode, eps) { 1377 const cc = String(countryCode || '').toUpperCase(); 1378 if (!cc) return null; 1379 const list = Array.isArray(eps) ? eps : []; 1380 const hasRegion = (want) => { 1381 const w = String(want || '').toLowerCase(); 1382 const hit = list.find(e => e && (String(e.id || '').toLowerCase() === w || String(e.region || '').toLowerCase() === w)); 1383 return hit ? hit.id : null; 1384 }; 1385 // Prefer more-specific regions when that edge is configured (sg > us > au). 1386 const sg = ['SG', 'MY'];
1387 const us = ['US','CA','MX','PR','VI','GU','AS','MP','BM','BS','BB','TT','JM','DO','HT','CR','PA','CO','VE','EC','PE','CL','AR','BR','UY','PY','BO']; 1388 const au = ['AU','NZ','FJ','PG','NC','PF','WS','TO','VU','SB','KI','NR','TV','CK','NU','TK','ID','MY','SG','PH','TH','VN','JP','KR','TW','HK','MO','IN','PK','BD','LK','NP']; 1389 if (sg.includes(cc)) { 1390 const id = hasRegion('sg'); 1391 if (id) return id; 1392 } 1393 if (us.includes(cc)) { 1394 const id = hasRegion('us'); 1395 if (id) return id; 1396 } 1397 if (au.includes(cc)) { 1398 const id = hasRegion('au'); 1399 if (id) return id; 1400 } 1401 return hasRegion('default'); 1402} 1403 1404function ccEnsureEdgeUrlForWs() { 1405 if (window._ccEdgeUrlReady) return window.CC_WS_URL; 1406 window._ccEdgeUrlReady = true; 1407 const fallback = { id: 'default', ws: 'https://ws.chatcrack.us', media: 'https://media.chatcrack.us' }; 1408 try { 1409 if (typeof ccApplyEdgeEndpoint === 'function') { 1410 const eps = window.__bootEdgeEndpoints; 1411 let suggested = window.__bootSuggestedEdge || null; 1412 if (!suggested && eps && eps.length > 1 && typeof ccReadEdgeCache === 'function' && typeof ccEdgeEndpointsKey === 'function') { 1413 const cached = ccReadEdgeCache(ccEdgeEndpointsKey(eps)); 1414 if (cached && cached.id) suggested = cached.id; 1415 } 1416 ccApplyEdgeEndpoint(eps || null, { skipProbe: true, suggestedEdge: suggested }); 1417 } 1418 } catch (_) {} 1419 if (!window.CC_WS_URL) { 1420 window.CC_WS_URL = fallback.ws; 1421 window.CC_MEDIA_ORIGIN = fallback.media; 1422 window.CC_SELECTED_EDGE = 'default'; 1423 } 1424 return window.CC_WS_URL; 1425} 1426window.ccEnsureEdgeUrlForWs = ccEnsureEdgeUrlForWs; 1427 1428function ccStartEarlyEdgePick(edgesPromise) { 1429 if (window.__edgePickPromise) return window.__edgePickPromise; 1430 window.__edgePickPromise = (async () => { 1431 try { 1432 let payload = null; 1433 if (edgesPromise && typeof edgesPromise.then === 'function') { 1434 payload = await edgesPromise; 1435 } 1436 if (payload && Array.isArray(payload.edge_endpoints)) { 1437 window.__bootEdgeEndpoints = payload.edge_endpoints; 1438 if (payload.suggested_edge) window.__bootSuggestedEdge = payload.suggested_edge; 1439 } 1440 if (typeof ccApplyEdgeEndpoint !== 'function') return null; 1441 const eps = window.__bootEdgeEndpoints; 1442 if (!eps || eps.length <= 1) return ccApplyEdgeEndpoint(eps, { skipProbe: true }); 1443 const cached = ccReadEdgeCache(ccEdgeEndpointsKey(eps)); 1444 if (cached && cached.id) { 1445 return ccApplyEdgeEndpoint(eps, { skipProbe: true, suggestedEdge: cached.id }); 1446 } 1447 // Connect immediately on hinted/default edge â probe runs without blocking first WS. 1448 ccApplyEdgeEndpoint(eps, { 1449 skipProbe: true, 1450 suggestedEdge: window.__bootSuggestedEdge || null, 1451 }); 1452 try { 1453 await Promise.race([ 1454 ccApplyEdgeEndpoint(eps, { 1455 skipProbe: false, 1456 suggestedEdge: window.__bootSuggestedEdge || null, 1457 }), 1458 new Promise((resolve) => setTimeout(resolve, optionsMaxMs())), 1459 ]); 1460 } catch (_) {} 1461 return window.CC_SELECTED_EDGE; 1462 } catch (_) { 1463 return null; 1464 } 1465 })(); 1466 return window.__edgePickPromise; 1467 function optionsMaxMs() { return 800; } 1468} 1469 1470if (window.__earlyFastPath && window.__earlyFastPath.edges) { 1471 window.__earlyFastPath.edges.then(function (r) { 1472 if (r && Array.isArray(r.ice_servers) && r.ice_servers.length) { 1473 window.CC_ICE_SERVERS = r.ice_servers; 1474 window.__bootIceServers = r.ice_servers; 1475 } 1476 }).catch(function () {}); 1477} 1478if (window.__earlyFastPath && window.__earlyFastPath.edges && !window.__edgePickPromise) { 1479 ccStartEarlyEdgePick(window.__earlyFastPath.edges); 1480} 1481 1482function ccScheduleEdgeProbe(endpoints) { 1483 const eps = Array.isArray(endpoints) ? endpoints.filter(ep => ep && ep.ws) : []; 1484 if (eps.length <= 1 || window._ccEdgeProbeScheduled) return; 1485 window._ccEdgeProbeScheduled = true; 1486 // Probe for a better edge. Soft-reconnect once if clearly better and the 1487 // current socket is still young (first 20s). Otherwise only update cache for 1488 // the next natural reconnect â avoids mid-session flaps. 1489 setTimeout(async function() { 1490 try { 1491 if (document.hidden) return; 1492 if (!window.socket || !window.socket.connected) return; 1493 const before = window.CC_SELECTED_EDGE; 1494 const beforeWs = window.CC_WS_URL; 1495 const beforeMedia = window.CC_MEDIA_ORIGIN; 1496 const connectedAt = Number(window._ccWsConnectedAt) || 0; 1497 const young = connectedAt > 0 && (Date.now() - connectedAt) < 20000; 1498 await ccApplyEdgeEndpoint(eps, {
1499 skipProbe: false, 1500 suggestedEdge: window.__bootSuggestedEdge || null, 1501 }); 1502 if (window.CC_SELECTED_EDGE && window.CC_SELECTED_EDGE !== before) { 1503 if (young && !window._ccEarlyEdgeSwitched && typeof window.ccReconnectToEdge === 'function') { 1504 window._ccEarlyEdgeSwitched = true; 1505 try { console.log('[EDGE] Soft-reconnect to', window.CC_SELECTED_EDGE); } catch (_) {} 1506 window.ccReconnectToEdge(); 1507 } else { 1508 // Keep the live connection; cache already updated for next reconnect. 1509 window.CC_WS_URL = beforeWs; 1510 window.CC_MEDIA_ORIGIN = beforeMedia; 1511 window.CC_SELECTED_EDGE = before; 1512 } 1513 } 1514 } catch (_) {} 1515 }, 8000); 1516} 1517 1518function ccReconnectToEdge() { 1519 if (!window.me || !window.me.username) return; 1520 ccSyncWsStatusIndicator(); 1521 const old = window.socket; 1522 if (old) { 1523 try { old._ccHandlersWired = false; old._ccHandlersWiring = false; } catch (_) {} 1524 try { old.removeAllListeners && old.removeAllListeners(); } catch (_) {} 1525 try { old.disconnect(); } catch (_) {} 1526 } 1527 window.socket = null; 1528 window.chatcrackSocket = null; 1529 window._cleanWsCreating = false; 1530 window._eyeballRegistered = false; 1531 initCleanWebSocket({ force: true }); 1532} 1533 1534/** HTTP seed for live cams â defined early near ccPrewarmMediaSocket; see window.ccSeedWebcamStatusesFromHttp */ 1535 1536/** Lobby/global system rows store room_id NULL. Treat those as visible in the 1537 * current room (same as history SQL for Lobby). Room-scoped rows must match. 1538 * Game create/join/win lines are pushed to every socket (broadcast_all_rooms) 1539 * so Lobby Join Game rows don't wait on a refresh. */ 1540function _ccWsBroadcastAllRooms(data) { 1541 if (!data) return false; 1542 return data.broadcast_all_rooms === true 1543 || data.broadcast_all_rooms === 1 1544 || data.broadcast_all_rooms === '1'; 1545} 1546function _ccIsGameLobbyAnnouncement(data) { 1547 const text = data && data.text; 1548 if (!text || typeof text !== 'string') return false; 1549 if (text.indexOf('[[DIRECTIVE]]') !== -1) return false; 1550 if (typeof ccLooksLikeGameLobbyAnnouncement === 'function' && ccLooksLikeGameLobbyAnnouncement(text)) return true; 1551 if (typeof ccIsActionableGameAnnouncement === 'function' && ccIsActionableGameAnnouncement(text)) return true; 1552 if (typeof ccIsGameCreationAnnouncement === 'function' && ccIsGameCreationAnnouncement(text)) return true; 1553 return false; 1554} 1555window._ccIsGameLobbyAnnouncement = _ccIsGameLobbyAnnouncement; 1556function _ccIsMessageForCurrentRoom(data) { 1557 if (!data) return false; 1558 const text = String(data.text || data.message || ''); 1559 const isWpOnly = data.type === 'system' && text 1560 && typeof window.ccIsWatchPartyOnlyAnnouncementText === 'function' 1561 && window.ccIsWatchPartyOnlyAnnouncementText(text); 1562 const isJoinLeave = data.type === 'system' && text && !isWpOnly && ( 1563 (typeof ccIsJoinLeaveAnnouncementText === 'function' && ccIsJoinLeaveAnnouncementText(text)) 1564 || /has joined the party/i.test(text) 1565 ); 1566 const msgRoom = (data.room_id == null || data.room_id === '') ? 1 : (Number(data.room_id) || 1); 1567 const currentRoom = (window.currentRoomId != null ? window.currentRoomId : 1) | 0; 1568 if (isWpOnly) { 1569 const wr = (data.room_id == null || data.room_id === '') ? 0 : (Number(data.room_id) || 0); 1570 const wpRid = (wr >= 9001 && wr <= 9005) 1571 ? wr 1572 : ((currentRoom >= 9001 && currentRoom <= 9005) ? currentRoom : 9001); 1573 return wpRid === currentRoom 1574 || (typeof window.ccIsOpenChatRoom === 'function' && window.ccIsOpenChatRoom(wpRid)); 1575 } 1576 if (isJoinLeave) return msgRoom === currentRoom; 1577 if (data.type === 'system') { 1578 if (_ccWsBroadcastAllRooms(data)) return true; 1579 if (data.announcement_sitewide === true || data.announcement_sitewide === 1 || data.announcement_sitewide === '1') return true; 1580 if (data.room_id == null || data.room_id === '') return true; 1581 if (_ccIsGameLobbyAnnouncement(data)) return true; 1582 if (typeof ccIsSitewideModerationAnnouncementText === 'function' 1583 && ccIsSitewideModerationAnnouncementText(data.text)) return true; 1584 if (typeof ccIsSitewidePublicAnnouncementText === 'function' 1585 && ccIsSitewidePublicAnnouncementText(data.text)) return true; 1586 } 1587 return msgRoom === currentRoom; 1588} 1589window._ccIsMessageForCurrentRoom = _ccIsMessageForCurrentRoom; 1590 1591function _ccIsMessageForOpenChatRoom(data) { 1592 if (_ccIsMessageForCurrentRoom(data)) return true; 1593 if (!data) return false;
1594 const msgRoom = (data.room_id == null || data.room_id === '') ? 1 : ((Number(data.room_id) || 1) | 0); 1595 if (msgRoom === 1) return true; 1596 if (typeof window.ccIsOpenChatRoom === 'function' && window.ccIsOpenChatRoom(msgRoom)) return true; 1597 const currentRoom = (window.currentRoomId != null ? window.currentRoomId : 1) | 0; 1598 if (currentRoom >= 9001 && currentRoom <= 9005 && msgRoom === 1) return true; 1599 return false; 1600} 1601window._ccIsMessageForOpenChatRoom = _ccIsMessageForOpenChatRoom; 1602 1603/** Roster add/remove follows the room the person actually joined/left. */ 1604function _ccIsRosterEventForCurrentRoom(data) { 1605 if (!data) return false; 1606 const msgRoom = (data.room_id == null || data.room_id === '') ? 1 : ((Number(data.room_id) || 1) | 0); 1607 const currentRoom = (window.currentRoomId != null ? window.currentRoomId : 1) | 0; 1608 return msgRoom === currentRoom; 1609} 1610window._ccIsRosterEventForCurrentRoom = _ccIsRosterEventForCurrentRoom; 1611 1612/** Last-resort yellow-bell row when rowHTML returns '' (poll is off while WS is up). */ 1613function _ccFallbackSystemAnnouncementHtml(data) { 1614 if (!data || data.type !== 'system') return ''; 1615 const text = String(data.text || '').trim(); 1616 if (!text) return ''; 1617 if (text.indexOf('[[DIRECTIVE]]') !== -1) return ''; 1618 if (/^\s*\[\[YT:/i.test(text)) return ''; 1619 if (/queued a video for everyone/i.test(text)) return ''; 1620 if (typeof window.ccTextLooksLikeInternalWatchAnnouncement === 'function' 1621 && window.ccTextLooksLikeInternalWatchAnnouncement(text)) { 1622 return ''; 1623 } 1624 const mid = (typeof ccNormalizeChatMessageId === 'function') 1625 ? ccNormalizeChatMessageId(data.id) 1626 : (Number(data.id) || 0); 1627 const ts = data.created_ts ? Number(data.created_ts) * 1000 : Date.now(); 1628 const timeString = (typeof timeAgo === 'function') ? timeAgo(ts) : ''; 1629 const idAttr = mid ? (' data-message-id="' + mid + '"') : ''; 1630 const trusted = (typeof _ccShouldRenderSystemHtmlRaw === 'function' && _ccShouldRenderSystemHtmlRaw(data)) 1631 || (typeof _ccSystemMessageHasTrustedHtml === 'function' && _ccSystemMessageHasTrustedHtml(text)); 1632 let body; 1633 if (trusted) { 1634 body = (typeof _ccUnwrapServerSystemMsgHtml === 'function') ? _ccUnwrapServerSystemMsgHtml(text) : text; 1635 } else { 1636 body = (typeof esc === 'function') 1637 ? esc(text) 1638 : text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); 1639 } 1640 return '<div class="row system-message"' + idAttr + '><div class="col-msg"><div class="system-msg">ð ' 1641 + body + '</div></div><div class="col-time" data-ts="' + ts + '">' + timeString + '</div></div>'; 1642} 1643window._ccFallbackSystemAnnouncementHtml = _ccFallbackSystemAnnouncementHtml; 1644 1645function initCleanWebSocket(opts) { 1646 const options = opts || {}; 1647 const existing = window.socket; 1648 1649 if (existing?.connected && existing._ccHandlersWired && !options.force) { 1650 _chatLog("ð Main chat WebSocket already connected, skipping."); 1651 ccSyncWsStatusIndicator(); 1652 return; 1653 } 1654 1655 // Socket.IO still connecting or auto-reconnecting â never replace it (duplicate 1656 // inits were disconnecting mid-handshake â "interrupted while loading" + slow red dot). 1657 if (!options.force && existing && existing.active && existing._ccHandlersWired) { 1658 _chatLog('ð WebSocket already active (connecting/reconnecting), skipping duplicate init.'); 1659 ccSyncWsStatusIndicator(); 1660 return; 1661 } 1662 1663 // Handlers already wired on a half-open socket â nudge connect, do NOT build a 2nd socket 1664 // (boot used to call schedule after ~11s and replace the first handshake). 1665 if (!options.force && existing && existing._ccHandlersWired) { 1666 _chatLog('ð WebSocket handlers already wired â resuming instead of recreating.'); 1667 try { 1668 if (existing.io && existing.io.backoff && typeof existing.io.backoff.reset === 'function') { 1669 existing.io.backoff.reset(); 1670 } 1671 if (!existing.connected && typeof existing.connect === 'function') existing.connect(); 1672 } catch (_) {} 1673 ccSyncWsStatusIndicator(); 1674 return; 1675 } 1676 1677 if (window._cleanWsCreating && !options.force && !(existing && (existing.active || existing.connected) && !existing._ccHandlersWired)) { 1678 _chatLog('ð WebSocket init already in progress, skipping duplicate.'); 1679 return; 1680 } 1681 1682 // Firefox: avoid opening WS during document "loading" (handshake can abort). 1683 // Connect once interactive â waiting for full DOMContentLoaded on this huge page 1684 // left the disconnect banner up for tens of seconds. 1685 if (!options.force && document.readyState === 'loading' 1686 && typeof window.ccWhenWsConnectSafe === 'function' 1687 && /firefox/i.test((typeof navigator !== 'undefined' && navigator.userAgent) || '')) {
1688 window.ccWhenWsConnectSafe(function () { initCleanWebSocket(opts); }); 1689 return; 1690 } 1691 1692 const WS_URL = window.CC_WS_URL || "https://ws.chatcrack.us"; 1693 const WS_PATH = "/ws/"; 1694 const WS_KEY = "dev-http-key"; // must match server WS_HTTP_KEY 1695 1696 // WebSocket debug helpers â always defined (early-connect reuses socket and skips io()). 1697 const WS_DEBUG = window.WS_DEBUG || (typeof URLSearchParams !== 'undefined' && new URLSearchParams(location.search).get('ws_debug') === '1'); 1698 if (WS_DEBUG && !window._wsDebug) { 1699 window._wsDebug = { connected: false, lastConnect: 0, lastDisconnect: 0, lastUsersChanged: 0, lastJoinLeave: 0, joinLeaveCount: 0, usersChangedCount: 0, lastLoadUsersStart: 0, lastLoadUsersEnd: 0 }; 1700 console.log('[WS debug] Enabled. User-list path: users:changed or chat:new_message (join/leave) â timer 700ms â loadUsers(). Check window._wsDebug and look for [WS user-list path] in console.'); 1701 } 1702 if (typeof window.wsDebugOn !== 'function') { 1703 window.wsDebugOn = function() { return !!(window.WS_DEBUG || window._wsDebug); }; 1704 } 1705 if (typeof window.wsDebugInit !== 'function') { 1706 window.wsDebugInit = function() { 1707 if (window.WS_DEBUG && !window._wsDebug) { 1708 window._wsDebug = { connected: !!window.socket?.connected, lastConnect: 0, lastDisconnect: 0, lastUsersChanged: 0, lastJoinLeave: 0, joinLeaveCount: 0, usersChangedCount: 0, lastLoadUsersStart: 0, lastLoadUsersEnd: 0 }; 1709 console.log('[WS debug] Enabled from console. _wsDebug created. Run enableWsEventLogging() to log every socket event.'); 1710 } 1711 return window._wsDebug || null; 1712 }; 1713 } 1714 if (typeof window.enableWsEventLogging !== 'function') { 1715 window.enableWsEventLogging = function() { 1716 if (!window.socket) { console.warn('[WS] No socket yet.'); return; } 1717 if (window.socket._wsAllEventsAttached) { console.log('[WS] All-event logging already on.'); return; } 1718 window.socket._wsAllEventsAttached = true; 1719 window.socket.onAny(function(event) { console.log('[WS event]', event, arguments.length > 1 ? Array.prototype.slice.call(arguments, 1) : ''); }); 1720 console.log('[WS] All WebSocket events will now be logged as [WS event] <name> [payload]. If you never see users:changed or chat:new_message, the server is not emitting to this client.'); 1721 }; 1722 } 1723 1724 // Wait for BOTH the identity (boot must set window.me with username+role) AND 1725 // Socket.IO (`io`), which is loaded by a <script> later in the document. 1726 const _ioReady = (typeof io === 'function'); 1727 const _meReady = !!(window.me && window.me.username && window.me.role); 1728 if (!_ioReady || !_meReady) { 1729 if (typeof ccScheduleWsConnect === 'function') ccScheduleWsConnect(); 1730 return; 1731 } 1732 1733 const reuseEarlySocket = !!(existing && !options.force && !existing._ccHandlersWired && (existing.connected || existing.active)); 1734 let socket; 1735 1736 if (reuseEarlySocket) { 1737 socket = existing; 1738 window._cleanWsCreating = false; 1739 _chatLog('ð Reusing existing WebSocket â wiring handlers...'); 1740 } else if (existing && !options.force && (existing.connected || existing.active)) { 1741 socket = existing; 1742 window._cleanWsCreating = false; 1743 _chatLog('ð Reusing live WebSocket â wiring handlers...'); 1744 } else { 1745 window._cleanWsCreating = true; 1746 _chatLog("ð Initializing clean WebSocket connection..."); 1747 ccSyncWsStatusIndicator(); 1748 1749 if (existing && typeof existing.disconnect === 'function' && (options.force || (!existing.active && !existing.connected))) { 1750 try { 1751 existing.removeAllListeners && existing.removeAllListeners(); 1752 } catch (_) {} 1753 try { 1754 existing._ccVisFriendWired = false; 1755 existing.disconnect(); 1756 } catch (_) {} 1757 } 1758 1759 const username = window.me.username; 1760 const role = window.me.role; 1761 1762 _chatLog(`ð Connecting WebSocket: ${username} (${role})`); 1763 _chatLog("ð WebSocket:", WS_URL, WS_PATH, username, role); 1764 socket = io(WS_URL, { 1765 path: WS_PATH, 1766 transports: (typeof window.ccChatSocketTransportOrder === 'function') 1767 ? window.ccChatSocketTransportOrder() 1768 : ['websocket', 'polling'], 1769 upgrade: true, 1770 rememberUpgrade: true,
1771 tryAllTransports: true, 1772 auth: { key: WS_KEY, username, role }, 1773 query: { key: WS_KEY, username, role }, 1774 timeout: 8000, 1775 reconnection: true, 1776 reconnectionAttempts: Infinity, 1777 reconnectionDelay: 500, 1778 reconnectionDelayMax: 5000, 1779 randomizationFactor: 0.3, 1780 forceNew: !!options.force, 1781 autoConnect: true 1782 }); 1783 window.chatcrackSocket = socket; 1784 window.socket = socket; 1785 if (typeof window.ccEarlyWireMessageBuffer === 'function') window.ccEarlyWireMessageBuffer(socket); 1786 } 1787 1788 window.socket = socket; 1789 window.chatcrackSocket = socket; 1790 if (typeof window.ccEarlyWireMessageBuffer === 'function') window.ccEarlyWireMessageBuffer(socket); 1791 if (typeof window._ccLoadDebugWireSocket === 'function') window._ccLoadDebugWireSocket(socket); 1792 if (!socket._ccHandlersWiring) socket._ccHandlersWiring = true; 1793 1794 if (typeof ccEnsureEdgeUrlForWs === 'function') ccEnsureEdgeUrlForWs(); 1795 if (!reuseEarlySocket && !(existing && socket === existing && (existing.active || existing.connected))) { 1796 if (typeof window.ccSeedWebcamStatusesFromHttp === 'function') window.ccSeedWebcamStatusesFromHttp(); 1797 } 1798 1799 if (socket.io && !socket.io._ccStatusWired) { 1800 socket.io._ccStatusWired = true; 1801 socket.io.on('reconnect', function () { 1802 if (window.socket === socket) { 1803 updateWsStatusIndicator(true); 1804 const chatRoomId = window.currentRoomId != null ? window.currentRoomId : 1; 1805 if (typeof window.ccWsJoinOpenChatRooms === 'function') { 1806 try { window.ccWsJoinOpenChatRooms(socket); } catch (_) { 1807 socket.emit('chat:join_room', { roomId: chatRoomId, keepOthers: chatRoomId !== 1 }); 1808 } 1809 } else { 1810 socket.emit('chat:join_room', { roomId: chatRoomId, keepOthers: chatRoomId !== 1 }); 1811 } 1812 } 1813 }); 1814 socket.io.on('reconnect_attempt', function () { 1815 if (window.socket === socket) ccSyncWsStatusIndicator(); 1816 }); 1817 } 1818 1819 // Lifecycle listeners once per socket instance (early socket already sets this). 1820 // When late creates the socket (no early), attach status + media seed here. 1821 if (!socket._ccLifecycleWired) { 1822 socket._ccLifecycleWired = true; 1823 socket.on('connect', () => { 1824 try { window._cleanWsCreating = false; } catch (_) {} 1825 try { window._ccWsConnectedAt = Date.now(); } catch (_) {} 1826 _chatLog('ð Main chat socket connected:', socket.id); 1827 if (typeof window._ccLoadDebugWireSocket === 'function') window._ccLoadDebugWireSocket(socket); 1828 if (typeof window.wsDebugOn === 'function' && window.wsDebugOn()) { var d = window.wsDebugInit(); if (d) { d.connected = true; d.lastConnect = Date.now(); } console.log('[WS] connected', socket.id, new Date().toISOString()); } 1829 updateWsStatusIndicator(true); 1830 window._messagePollIntervalMs = 120000; 1831 if (typeof window.restartMessagePollInterval === 'function') window.restartMessagePollInterval(); 1832 if (typeof window._scheduleClientHeartbeat === 'function') window._scheduleClientHeartbeat(); 1833 if (typeof window._ccRefreshPollIntervals === 'function') window._ccRefreshPollIntervals(); 1834 try { 1835 if (typeof window.ccWarmCamStack === 'function') window.ccWarmCamStack(); 1836 else if (typeof window.ccPrewarmMediaSocket === 'function') window.ccPrewarmMediaSocket(); 1837 if (typeof window.ccSeedWebcamStatusesFromHttp === 'function') window.ccSeedWebcamStatusesFromHttp(); 1838 if (typeof window._ccRefreshActiveWebcamSet === 'function') window._ccRefreshActiveWebcamSet(); 1839 if (typeof window._ccTryWireWebcamVisibilitySocket === 'function') { 1840 socket._ccVisFriendWired = false; 1841 window._ccTryWireWebcamVisibilitySocket(); 1842 } 1843 if (typeof window._ccBootFavouriteAutoOpen === 'function') { 1844 window._ccBootFavouriteAutoOpen('ws_connect'); 1845 } else if (typeof window._ccTryAutoOpenAllFavouriteCams === 'function') { 1846 window._ccTryAutoOpenAllFavouriteCams({ bootPass: true }); 1847 } 1848 } catch (_) {} 1849 var afterIdle = typeof window._ccWhenCatchUpIdle === 'function' ? window._ccWhenCatchUpIdle : function (fn) { setTimeout(fn, 200); }; 1850 afterIdle(function () { 1851 if (typeof window.ccGameOnChatConnected === 'function') window.ccGameOnChatConnected(); 1852 if (typeof window.ccLoadPendingDmGroupInvites === 'function') window.ccLoadPendingDmGroupInvites(); 1853 }, 2000); 1854 }); 1855 1856 socket.on('connect_error', (error) => { 1857 _chatLog('â Main chat socket connection error:', error?.message || error); 1858 if (typeof window.wsDebugOn === 'function' && window.wsDebugOn()) { window.wsDebugInit(); console.log('[WS] connect_error', error?.message || error, new Date().toISOString()); } 1859 if (window.socket === socket) ccSyncWsStatusIndicator(); 1860 window._messagePollIntervalMs = 12000; 1861 if (typeof window._scheduleClientHeartbeat === 'function') window._scheduleClientHeartbeat(); 1862 if (typeof window.restartMessagePollInterval === 'function') window.restartMessagePollInterval(); 1863 if (typeof window._ccRefreshPollIntervals === 'function') window._ccRefreshPollIntervals();
1864 try { if (typeof poll === 'function') poll(); } catch (_) {} 1865 try { window._cleanWsCreating = false; } catch (_) {} 1866 }); 1867 1868 socket.on('disconnect', (reason) => { 1869 if (typeof window.wsDebugOn === 'function' && window.wsDebugOn()) { var d = window.wsDebugInit(); if (d) { d.connected = false; d.lastDisconnect = Date.now(); } console.log('[WS] disconnect', reason, new Date().toISOString()); } 1870 if (window.socket !== socket) return; 1871 if (reason === 'io client disconnect') window._wsUserLoggedOut = true; 1872 ccSyncWsStatusIndicator(); 1873 }); 1874 } // end _ccLifecycleWired 1875 1876 // Connect bootstrap (join room, poll catch-up) â separate from handler wiring so 1877 // early head connect that beats initCleanWebSocket still gets chat:new_message. 1878 function _ccOnSocketConnect() { 1879 // One bootstrap per connect cycle (initCleanWebSocket may call while already connected). 1880 const sid = socket.id || 'pending'; 1881 if (socket._ccConnectBootstrapSid === sid && socket._ccConnectBootstrapAt 1882 && (Date.now() - socket._ccConnectBootstrapAt) < 5000) { 1883 return; 1884 } 1885 socket._ccConnectBootstrapSid = sid; 1886 socket._ccConnectBootstrapAt = Date.now(); 1887 console.log("â WS connected (clean)", socket.id); 1888 updateWsStatusIndicator(true); 1889 try { 1890 if (typeof window.ccUnlockChatUiForInteraction === 'function') { 1891 window.ccUnlockChatUiForInteraction('ws_clean_connect'); 1892 } 1893 } catch (_) {} 1894 // Seed active polls as soon as the socket is up (login / refresh). 1895 try { 1896 setTimeout(function () { 1897 if (typeof window.refreshPolls === 'function') { 1898 window.refreshPolls({ forceHttp: true, forceModal: !!window.pollsModalOpen }); 1899 } 1900 }, 400); 1901 } catch (_) {} 1902 // No force ping on WS connect â room_join.shekel_pot already painted the jar. 1903 // Force-sync during join quiet was Live Load ping max ~5s red. 1904 1905 window.socket = socket; 1906 if (typeof window._ccLoadDebugWireSocket === 'function') window._ccLoadDebugWireSocket(socket); 1907 const chatRoomId = window.currentRoomId != null ? window.currentRoomId : 1; 1908 if (typeof window.ccWsJoinOpenChatRooms === 'function') { 1909 try { window.ccWsJoinOpenChatRooms(socket); } catch (_) { 1910 socket.emit('chat:join_room', { roomId: chatRoomId, keepOthers: chatRoomId !== 1 }); 1911 } 1912 } else { 1913 socket.emit('chat:join_room', { roomId: chatRoomId, keepOthers: chatRoomId !== 1 }); 1914 } 1915 if (typeof window.chatCrackOpenMic?.onRoomReady === 'function') { 1916 window.chatCrackOpenMic.onRoomReady(); 1917 } 1918 1919 // Everything pushed while the socket was away is gone: nothing replays it, 1920 // and poll() ignores a connected socket unless it is forced, so those rows 1921 // stayed invisible until the user reloaded. A deploy is the usual cause â 1922 // the media server restarts, then announces seconds later into a room the 1923 // clients have not rejoined yet. Claim one catch-up fetch per reconnect. 1924 const hadConnectedBefore = !!socket._ccHadConnected; 1925 socket._ccHadConnected = true; 1926 if (hadConnectedBefore) { 1927 setTimeout(function () { 1928 try { 1929 if (window.initialLoadInProgress) return; 1930 if (typeof ccSyncLastIdFromDom === 'function') ccSyncLastIdFromDom(); 1931 var hasChat = typeof ccChatHasHistoryRows === 'function' && ccChatHasHistoryRows(); 1932 if (hasChat) return; 1933 window._ccForceMessagePoll = true; 1934 if (typeof poll === 'function') poll(); 1935 } catch (_) {} 1936 }, 300); 1937 } 1938 1939 // Lean connect bootstrap: join room first; defer poll/reconcile/game until idle. 1940 // Presence icons + eyeballs must NOT wait on catch-up idle. 1941 try { 1942 if (typeof window.ccSeedWebcamStatusesFromHttp === 'function') window.ccSeedWebcamStatusesFromHttp(); 1943 if (typeof window._ccRefreshActiveWebcamSet === 'function') window._ccRefreshActiveWebcamSet(); 1944 if (typeof window._ccBootFavouriteAutoOpen === 'function') { 1945 window._ccBootFavouriteAutoOpen('ws_connect_boot'); 1946 } 1947 } catch (_) {} 1948 var afterIdleConnect = typeof window._ccWhenCatchUpIdle === 'function' 1949 ? window._ccWhenCatchUpIdle 1950 : function (fn, ms) { setTimeout(fn, ms || 200); }; 1951 afterIdleConnect(function () {
1952 try { 1953 if (typeof window.ccGameOnChatConnected === 'function') window.ccGameOnChatConnected(); 1954 // Skip early poll:messages / pending_check during join quiet â WS covers chat. 1955 var quiet = typeof window._ccInJoinQuiet === 'function' && window._ccInJoinQuiet(); 1956 if (!quiet && typeof poll === 'function' && !(window._ccCatchUpBusy)) { 1957 setTimeout(() => { try { poll(); } catch (e) {} }, 120); 1958 } 1959 if (typeof window._ccReconnectReconcile === 'function') { 1960 setTimeout(() => { try { window._ccReconnectReconcile(); } catch (e) {} }, quiet ? 20000 : 250); 1961 } 1962 // shekel_shop_pending_check: 55s safety-net loop only (not on every connect). 1963 } catch (e) {} 1964 }, 1500); 1965 1966 // Identify ourselves to the server for eyeball viewer tracking 1967 const regUser = window.currentUsername || (window.me && window.me.username); 1968 if (regUser) { 1969 socket.emit('webcam:register', typeof window._ccWebcamRegisterPayload === 'function' 1970 ? window._ccWebcamRegisterPayload(regUser) 1971 : { username: regUser }); 1972 try { 1973 const wcLive = window.chatCrackWebcam; 1974 const rtcLive = window.chatCrackWebRTC; 1975 if (wcLive && wcLive.isWebcamOn) { 1976 if (typeof window.ccAnnounceWebcamLive === 'function') { 1977 window.ccAnnounceWebcamLive({ suppressSystemMessage: true }); 1978 } 1979 const prodDead = !rtcLive || !rtcLive.cameraProducer || rtcLive.cameraProducer.closed; 1980 const stream = (wcLive.localStream && wcLive.localStream.getVideoTracks && wcLive.localStream.getVideoTracks()[0]) 1981 || (rtcLive && rtcLive.localStream && rtcLive.localStream.getVideoTracks && rtcLive.localStream.getVideoTracks()[0]); 1982 if (prodDead && rtcLive && stream && stream.readyState === 'live' && typeof rtcLive.connectAndStart === 'function') { 1983 Promise.resolve(rtcLive.connectAndStart()).catch(function (e) { 1984 console.warn('[WEBCAM] republish after reconnect failed', e); 1985 }); 1986 } 1987 } 1988 } catch (_) {} 1989 // Our live presence set is stale after any gap â pull the room's real roster 1990 // instead of leaving the list to drift until the next heartbeat. 1991 setTimeout(function () { 1992 try { 1993 if (typeof window._ccRequestLivePresenceSnapshot === 'function') { 1994 window._ccRequestLivePresenceSnapshot('socket_connect'); 1995 } 1996 } catch (_) {} 1997 }, 400); 1998 window._eyeballRegistered = true; 1999 if (typeof fetchMyWebcamViewBlocks === 'function') fetchMyWebcamViewBlocks(); 2000 } 2001 else window._eyeballRegistered = false; 2002 2003 // Re-sync eyeballs immediately (then short retries) â publishers need viewers on login. 2004 const _ccEyeballConnectSync = function () { 2005 try { if (typeof window._ccReemitViewerAddedForAllDocked === 'function') window._ccReemitViewerAddedForAllDocked(); } catch (_) {} 2006 try { 2007 const live = !!(window.chatCrackWebcam && window.chatCrackWebcam.isWebcamOn) 2008 || (typeof window._ccIsSelfWebcamLive === 'function' && window._ccIsSelfWebcamLive()); 2009 if (live && typeof window._ccStartPublisherViewerFastPoll === 'function') { 2010 window._ccStartPublisherViewerFastPoll(); 2011 } else if (typeof window._ccSyncMyWebcamViewers === 'function') { 2012 window._ccSyncMyWebcamViewers(); 2013 } 2014 } catch (_) {} 2015 }; 2016 try { _ccEyeballConnectSync(); } catch (_) {} 2017 setTimeout(_ccEyeballConnectSync, 250); 2018 setTimeout(_ccEyeballConnectSync, 1000); 2019 2020 // Seed authoritative "active webcams" set from MediaServer. 2021 // The server's `activeWebcams` Set tracks who has a live mediasoup 2022 // producer right now â use it as the single source of truth for icon 2023 // visibility so stale localStorage entries can never re-light a ghost. 2024 try { 2025 // Collapse duplicate-cased localStorage keys NOW that we know the 2026 // canonical roster casing (we just registered, user list arrives soon). 2027 if (typeof window._ccDedupeWebcamStatusKeys === 'function') { 2028 requestAnimationFrame(function() { try { window._ccDedupeWebcamStatusKeys(); } catch (_) {} }); 2029 } 2030 if (typeof window._ccRefreshActiveWebcamSet === 'function') { 2031 try { window._ccRefreshActiveWebcamSet(); } catch (_) {} 2032 requestAnimationFrame(function() { 2033 try { window._ccRefreshActiveWebcamSet(); } catch (_) {} 2034 try { if (typeof _ccCheckFeaturedUser === 'function') _ccCheckFeaturedUser(); } catch (_) {} 2035 }); 2036 // Refresh periodically in case we miss a started/stopped event. 2037 if (typeof window._ccRescheduleWebcamActiveSetPoll === 'function') { 2038 window._ccRescheduleWebcamActiveSetPoll(); 2039 } else if (window._ccActiveSetTimer) { 2040 clearInterval(window._ccActiveSetTimer); 2041 window._ccActiveSetTimer = setInterval(() => { 2042 try { 2043 if (typeof window._ccDedupeWebcamStatusKeys === 'function') window._ccDedupeWebcamStatusKeys(); 2044 window._ccRefreshActiveWebcamSet(); 2045 } catch (_) {} 2046 }, 60 * 1000); 2047 } 2048 // Startup burst: late-appearing live cams (other shard / delayed producer).
2049 if (Array.isArray(window._ccActiveSetBurstTimers)) { 2050 window._ccActiveSetBurstTimers.forEach((t) => clearTimeout(t)); 2051 } 2052 window._ccActiveSetBurstTimers = [300, 1000, 2500].map((delay) => 2053 setTimeout(() => { 2054 try { 2055 if (!window.socket || !window.socket.connected) return; 2056 window._ccRefreshActiveWebcamSet(); 2057 } catch (_) {} 2058 }, delay) 2059 ); 2060 // Local-only: re-paint cam icons from storage if the user list wiped them (no API calls). 2061 if (typeof window._ccRescheduleWebcamActiveSetPoll === 'function') { 2062 /* icon keeper scheduled inside */ 2063 } else if (window._ccWebcamIconKeeperTimer) { 2064 clearInterval(window._ccWebcamIconKeeperTimer); 2065 window._ccWebcamIconKeeperTimer = setInterval(() => { 2066 try { 2067 if (!window.socket?.connected || window.pageLoadProtection) return; 2068 if (typeof window._ccSyncUserListTrailingIcons === 'function') { 2069 window._ccSyncUserListTrailingIcons(window.currentUsers || []); 2070 } 2071 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.refreshWebcamIconsFromStorage === 'function') { 2072 window.chatCrackWebcam.refreshWebcamIconsFromStorage(); 2073 } 2074 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.ensureEyeballIconsOnly === 'function') { 2075 window.chatCrackWebcam.ensureEyeballIconsOnly(); 2076 } 2077 } catch (_) {} 2078 }, 20000); 2079 } 2080 } 2081 if (window._ccLivePresenceTimer) clearInterval(window._ccLivePresenceTimer); 2082 window._ccLivePresenceTimer = setInterval(() => { 2083 try { 2084 const me = window.currentUsername || (window.me && window.me.username); 2085 if (!me || !window._activeWebcamSetSeeded) return; 2086 const live = !!(window.chatCrackWebcam && window.chatCrackWebcam.isWebcamOn); 2087 const hasProducer = !!(window.chatCrackWebRTC && window.chatCrackWebRTC.cameraProducer 2088 && !window.chatCrackWebRTC.cameraProducer.closed); 2089 if (!live || !hasProducer) return; 2090 if (typeof window._ccActiveWebcamHas === 'function' && window._ccActiveWebcamHas(me)) return; 2091 console.warn('[WEBCAM] Live producer but missing from activeWebcams â re-announcing'); 2092 if (typeof window.ccAnnounceWebcamLive === 'function') { 2093 window.ccAnnounceWebcamLive({ suppressSystemMessage: true }); 2094 } 2095 } catch (_) {} 2096 }, 20000); 2097 } catch (_) {} 2098 2099 // Start 45-second polling backup for viewer list (in case WS events are missed). 2100 // Both sides are lowercased so a casing drift between the server's record 2101 // and our local Set can't cause spurious add/remove churn (which used to 2102 // make the green eyeball flicker on/off every 45s). 2103 if (window._eyeballPollTimer) clearInterval(window._eyeballPollTimer); 2104 window._eyeballPollTimer = setInterval(() => { 2105 const u = window.currentUsername || (window.me && window.me.username); 2106 if (u && window.socket && window.socket.connected) { 2107 const wc = window.chatCrackWebcam; 2108 const live = !!(wc && wc.isWebcamOn) 2109 || (typeof window._ccIsSelfWebcamLive === 'function' && window._ccIsSelfWebcamLive()); 2110 if (!live) { 2111 if (!window._eyeballNotLiveSince) window._eyeballNotLiveSince = Date.now(); 2112 // Cam off: clear green eyes quickly (800ms helper hold, or immediate if sustained). 2113 if (wc && typeof wc._clearWebcamViewerStateIfOffline === 'function') { 2114 const sustained = (Date.now() - window._eyeballNotLiveSince) >= 500; 2115 wc._clearWebcamViewerStateIfOffline(sustained ? { immediate: true } : {}); 2116 } 2117 return; 2118 } 2119 window._eyeballNotLiveSince = 0; 2120 window.socket.emit('webcam:get_my_viewers', { username: u }, (resp) => { 2121 if (!resp || !Array.isArray(resp.viewers) || !window.chatCrackWebcam) return; 2122 const wc2 = window.chatCrackWebcam; 2123 if (!wc2.isWebcamOn && typeof window._ccIsSelfWebcamLive === 'function' && !window._ccIsSelfWebcamLive()) { 2124 if (typeof wc2._clearWebcamViewerStateIfOffline === 'function') { 2125 wc2._clearWebcamViewerStateIfOffline(); 2126 } 2127 return; 2128 } 2129 window._eyeballNotLiveSince = 0; 2130 if (typeof wc2.mergeWebcamViewersFromServer === 'function') { 2131 // Add-only merge so a single empty/late reply can never wipe everyone. 2132 wc2.mergeWebcamViewersFromServer(resp.viewers, { exact: false }); 2133 }
2133 else if (wc2.webcamViewers) { 2134 resp.viewers.forEach((v) => { 2135 if (v == null) return; 2136 const vlc = String(v).toLowerCase(); 2137 if (!wc2.webcamViewers.has(vlc)) { 2138 wc2.webcamViewers.add(vlc); 2139 if (typeof wc2.updateEyeballIconForUser === 'function') wc2.updateEyeballIconForUser(v); 2140 } 2141 }); 2142 } 2143 // Self-clear STALE eyeballs: if a viewer we still show is absent from 2144 // the authoritative server list on two consecutive polls, drop them. 2145 // (Catches a missed webcam:viewer_removed without risking a flicker 2146 // from one hiccup reply â instant removal still comes from the WS event.) 2147 try { 2148 if (!window._ccEyeballMissCounts) window._ccEyeballMissCounts = new Map(); 2149 const miss = window._ccEyeballMissCounts; 2150 const serverSet = new Set((resp.viewers || []).map((v) => String(v).toLowerCase())); 2151 if (wc2.webcamViewers) { 2152 Array.from(wc2.webcamViewers).forEach((vlc) => { 2153 if (serverSet.has(vlc)) { miss.delete(vlc); return; } 2154 const n = (miss.get(vlc) || 0) + 1; 2155 if (n >= 2) { 2156 miss.delete(vlc); 2157 if (typeof wc2.removeWebcamViewer === 'function') wc2.removeWebcamViewer(vlc); 2158 } else { 2159 miss.set(vlc, n); 2160 } 2161 }); 2162 // Forget miss counts for anyone no longer tracked. 2163 Array.from(miss.keys()).forEach((k) => { if (!wc2.webcamViewers.has(k)) miss.delete(k); }); 2164 } 2165 } catch (_) {} 2166 }); 2167 if (typeof fetchMyWebcamViewBlocks === 'function') fetchMyWebcamViewBlocks(); 2168 } 2169 }, 30000); 2170 } 2171 if (!socket._ccConnectBootstrapWired) { 2172 socket._ccConnectBootstrapWired = true; 2173 socket.on('connect', _ccOnSocketConnect); 2174 } 2175 if (socket.connected) { 2176 try { _ccOnSocketConnect(); } catch (e) { console.warn('[WS] connect bootstrap (already connected):', e); } 2177 } else if (!socket.active && typeof socket.connect === 'function') { 2178 // Wired but idle (common after Firefox aborted an early handshake) â start transport now. 2179 try { socket.connect(); } catch (_) {} 2180 } 2181 2182 if (!socket._ccHandlersWired) { 2183 // Drop thin early live handlers before full core message handlers attach. 2184 try { 2185 if (typeof window.ccRemoveEarlyLiveHandlers === 'function') window.ccRemoveEarlyLiveHandlers(socket); 2186 } catch (_) {} 2187 const _ws = socket; 2188 // Optional: log when emit-driven events are received (api.php -> 3012 -> broadcast) 2189 // Enable with ?emit_debug=1 in URL or in console: window.CHAT_DEBUG_EMIT = true then refresh 2190 const emitDebug = window.CHAT_DEBUG_EMIT || (typeof URLSearchParams !== 'undefined' && new URLSearchParams(location.search).get('emit_debug')); 2191 if (emitDebug && _ws.onAny) { 2192 const emitEvents = ['chat:new_message','game:announce','polls:updated','suggestions:updated','users:changed','mutes:updated','global_system_message']; 2193 _ws.onAny((event, ...args) => { 2194 if (emitEvents.includes(event)) console.log('[emit received]', event, args.length ? args : ''); 2195 }); 2196 console.log('[emit debug] Logging received events:', emitEvents.join(', ')); 2197 } 2198 2199 // Kick off an initial fetch so UI is correct even without WS 2200 try { 2201 if (typeof fetchYouTubeQueue === 'function') fetchYouTubeQueue(); 2202 } catch (_) {} 2203 2204 // Listen for server-driven updates and sync everyone 2205 _ws.on('youtube:queue_updated', (payload) => { 2206 const prevHead = getYouTubeQueueHeadVideoId(); 2207 if (payload && Array.isArray(payload.queue)) { 2208 youtubeQueue = payload.queue; 2209 if (youtubeQueue.length) clearYouTubeQueueEndedFlag(); 2210 if (typeof updateYouTubeQueue === 'function') updateYouTubeQueue(true); 2211 } else if (typeof fetchYouTubeQueue === 'function') { 2212 fetchYouTubeQueue(); 2213 return; 2214 } 2215 if (!youtubeQueue || !youtubeQueue.length) { 2216 window._ytLastSyncedHeadId = null; 2217 return; 2218 } 2219 const newVid = (payload && payload.current_video_id) || getYouTubeQueueHeadVideoId(); 2220 if (newVid && payload && payload.play_for_all) { 2221 handleYouTubePlayForAll(newVid, {}, { silent: true }); 2222 } else if (newVid && prevHead && newVid !== prevHead) { 2223 syncYouTubeQueuePlaybackFromServer({ currentVideoId: newVid, reason: 'ws-queue-advance', force: true }); 2224 } else if (newVid && window._ytLoadedVideoId !== newVid) { 2225 syncYouTubeQueuePlaybackFromServer({ currentVideoId: newVid, reason: 'ws-queue-head', force: true }); 2226 } else if (newVid && !window._ytLastSyncedHeadId) { 2227 window._ytLastSyncedHeadId = newVid; 2228 } 2229 }); 2230 2231 _ws.on('youtube:state', (state) => { 2232 window.youtubeState = state; 2233 noteQueueEventFromWS();
2234 try { 2235 if (typeof fetchYouTubeQueue === 'function') fetchYouTubeQueue(); 2236 } catch (_) {} 2237 }); 2238 2239 _ws.on('radio:update', (payload) => { 2240 if (typeof ccRadioOnWsUpdate === 'function') ccRadioOnWsUpdate(payload); 2241 }); 2242 2243 _ws.on('raffle:update', (payload) => { 2244 try { 2245 if (typeof window.ccRaffleApplyUpdate === 'function') window.ccRaffleApplyUpdate(payload); 2246 } catch (e) {} 2247 }); 2248 2249 _ws.on('raffle:draw', (payload) => { 2250 try { 2251 if (payload && payload.news_ticker != null && typeof applyTickerSettings === 'function') { 2252 window._ccTickerSettingsSnap = ''; 2253 window._ccTickerNewsText = null; 2254 window._ccTickerComposeFp = null; 2255 applyTickerSettings(payload); 2256 if (typeof ccPersistTickerLocally === 'function') ccPersistTickerLocally(payload); 2257 } 2258 if (typeof window.ccRafflePlayBowl === 'function') window.ccRafflePlayBowl(payload, false); 2259 if (typeof window.ccRaffleRefresh === 'function') window.ccRaffleRefresh(); 2260 } catch (e) {} 2261 }); 2262 2263 // CRITICAL: Set up force stop command handler FIRST - this is the authoritative server command 2264 _chatLog("ð¹ SERVER COMMAND: Setting up webcam:force_stop_command handler..."); 2265 _ws.on("webcam:force_stop_command", (data) => { 2266 console.log("ð¹ SERVER COMMAND: Force stop webcam command received:", data); 2267 2268 if (!data || !data.target_username) { 2269 console.error("â Invalid force stop command data received:", data); 2270 return; 2271 } 2272 2273 // If this is the target user, EXECUTE THE STOP COMMAND IMMEDIATELY 2274 const currentUser = window.currentUsername || window.me?.username; 2275 if (data.target_username === currentUser) {
2276 console.log(`ð¹ SERVER COMMAND: Executing force stop webcam command from ${data.closed_by || 'an administrator'}`); 2277 console.log(`ð¹ SERVER COMMAND: Target user matches current user: ${currentUser}`); 2278 2279 // Set flag to indicate this is a force close 2280 window._lastForceCloseUsername = currentUser; 2281 2282 // CRITICAL: Literally call stopWebcam() as if the stop button was clicked 2283 // This will do EVERYTHING: stop mediasoup, stop tracks, update UI, etc. 2284 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.stopWebcam === 'function') { 2285 console.log(`ð¹ SERVER COMMAND: Calling stopWebcam() to execute stop command...`); 2286 console.log(`ð¹ SERVER COMMAND: Current webcam state - isWebcamOn: ${window.chatCrackWebcam.isWebcamOn}`); 2287 2288 // This simulates clicking the stop button - does everything 2289 window.chatCrackWebcam.stopWebcam(); 2290 2291 // Verify it stopped 2292 setTimeout(() => { 2293 console.log(`ð¹ SERVER COMMAND: Verification - isWebcamOn after stop: ${window.chatCrackWebcam.isWebcamOn}`); 2294 if (window.chatCrackWebcam.isWebcamOn) { 2295 console.error('â SERVER COMMAND: Webcam did not stop! Retrying...'); 2296 window.chatCrackWebcam.stopWebcam(); 2297 } 2298 }, 500); 2299 2300 // Show notification 2301 if (typeof showToast === 'function') { 2302 showToast(`Your webcam was force closed by ${data.closed_by || 'an administrator'}`, 'warning'); 2303 } 2304 } else { 2305 console.error('â SERVER COMMAND: stopWebcam function not available!'); 2306 console.error('â SERVER COMMAND: chatCrackWebcam available:', !!window.chatCrackWebcam); 2307 // Fallback: try to stop mediasoup directly 2308 if (window.chatCrackWebRTC && typeof window.chatCrackWebRTC.stopWebcamStreaming === 'function') { 2309 window.chatCrackWebRTC.stopWebcamStreaming().catch(err => { 2310 console.error('[WebRTC] Error stopping stream from server command:', err); 2311 }); 2312 } 2313 } 2314 } else { 2315 console.log(`ð¹ SERVER COMMAND: Ignoring command - target (${data.target_username}) != current user (${currentUser})`); 2316 } 2317 }); 2318 2319 // CRITICAL FIX: Handle webcam closure events immediately when WebSocket connects 2320 // This must be set up BEFORE any webcam:closure events are sent 2321 _chatLog("ð CRITICAL: Setting up main webcam:closure event listener in immediate connection..."); 2322 _chatLog("ð CRITICAL: Socket ID:", socket.id); 2323 _chatLog("ð CRITICAL: Socket connected:", socket.connected); 2324 2325 // Live shekel balance (wins/losses, transfers, live game awards, etc.) â no refresh needed. 2326 // Also patches open avatar hover ð° for any username (broadcast). 2327 _ws.on('user:shekels', (data) => { 2328 try { 2329 if (typeof applyLiveShekelsBalance === 'function') applyLiveShekelsBalance(data); 2330 else if (typeof window.ccApplyAvatarShekelsUpdate === 'function') window.ccApplyAvatarShekelsUpdate(data); 2331 } catch (err) { 2332 console.warn('user:shekels handler', err); 2333 } 2334 }); 2335 2336 _ws.on('bank:loans_changed', (data) => { 2337 try { 2338 if (typeof window.loadBankOpenLoans === 'function') { 2339 window.loadBankOpenLoans(); 2340 } 2341 const meId = window.me && window.me.id != null ? Number(window.me.id) : 0; 2342 const payerId = data && data.payer_id != null ? Number(data.payer_id) : 0; 2343 const borrowerId = data && data.borrower_id != null ? Number(data.borrower_id) : 0; 2344 if (meId > 0 && (meId === payerId || meId === borrowerId) && typeof window.loadBankStatus === 'function') { 2345 window.loadBankStatus(); 2346 } 2347 if (typeof window.scheduleUserListRefresh === 'function') { 2348 window.scheduleUserListRefresh({ immediate: true, force: true }); 2349 } 2350 } catch (err) { 2351 console.warn('bank:loans_changed handler', err); 2352 } 2353 }); 2354 2355 // Pot rate/fill push (cam on/off, 2Ã, Platinum, staff cap edits) â update jar immediately 2356 _ws.on('shekel_pot:update', (data) => {
2357 try { 2358 if (!data) return; 2359 const me = window.me; 2360 const meName = (window.currentUsername || (me && me.username) || '').trim().toLowerCase(); 2361 const target = (data.username || '').trim().toLowerCase(); 2362 const meId = me && me.id != null ? Number(me.id) : 0; 2363 const targetId = data.user_id != null ? Number(data.user_id) : 0; 2364 const forMe = (meId > 0 && targetId > 0 && meId === targetId) 2365 || (!!meName && !!target && meName === target); 2366 if (!forMe) { 2367 try { 2368 if (typeof window.ccPatchShekelLeaderboardRow === 'function') { 2369 window.ccPatchShekelLeaderboardRow(data); 2370 } else if (typeof window.ccNudgeShekelLeaderboard === 'function') { 2371 window.ccNudgeShekelLeaderboard(false); 2372 } 2373 } catch (_) {} 2374 return; 2375 } 2376 // cc_shekel_pot_refresh_now emits the pot fields at the TOP level 2377 // (net_per_min, webcam_on, â¦). Nested data.shekel_pot is only used by 2378 // some staff cap edits. The old fallback kept daily_* only and left the 2379 // jar stuck at the previous rate (15/min after cam-on â 27). 2380 let patch = null; 2381 if (data.shekel_pot && typeof data.shekel_pot === 'object') { 2382 patch = data.shekel_pot; 2383 } else if (data.net_per_min != null || data.potential_per_min != null || data.mode != null) { 2384 patch = data; 2385 } else { 2386 patch = { 2387 daily_used: data.daily_used, 2388 daily_cap: data.daily_cap, 2389 daily_remaining: data.daily_remaining, 2390 daily_capped: data.daily_capped, 2391 sync_ts: data.ts || Math.floor(Date.now() / 1000), 2392 }; 2393 } 2394 const prevPot = (me && me.shekel_pot) || {}; 2395 const pot = Object.assign({}, prevPot, patch); 2396 // Incomplete patches must not leave mode=daily_capped after a day reset. 2397 if (pot.daily_capped === 0 || pot.daily_capped === false || pot.daily_capped === '0') { 2398 if (String(pot.mode || '') === 'daily_capped') { 2399 const n = parseInt(pot.net_per_min, 10) || 0; 2400 const p = parseInt(pot.potential_per_min, 10) || 0; 2401 pot.mode = (n > 0 || p > 0) ? 'earn' : 'idle'; 2402 } 2403 } 2404 // Don't let an older WS snapshot rewind daily_used (same UTC day). 2405 try { 2406 const prevUsed = Math.max(0, parseInt(prevPot.daily_used, 10) || 0); 2407 const nextUsed = Math.max(0, parseInt(pot.daily_used, 10) || 0); 2408 const prevSync = Math.max(0, parseInt(prevPot.sync_ts, 10) || 0); 2409 const nextSync = Math.max(0, parseInt(pot.sync_ts, 10) || parseInt(data.ts, 10) || 0); 2410 const prevDay = String(prevPot.daily_day || ''); 2411 const nextDay = String(pot.daily_day || prevDay || ''); 2412 if (prevDay && nextDay && prevDay === nextDay && nextUsed < prevUsed && !(nextSync > prevSync)) { 2413 pot.daily_used = prevUsed; 2414 pot.daily_remaining = Math.max(0, (parseInt(pot.daily_cap, 10) || 0) - prevUsed); 2415 if (prevSync > 0) pot.sync_ts = Math.max(nextSync, prevSync); 2416 } 2417 } catch (_) {} 2418 if (me) { 2419 try { me.shekel_pot = pot; } catch (_) {} 2420 } 2421 window.__ccShekelPotPending = pot; 2422 if (typeof window.ccShekelPotApply === 'function') { 2423 window.ccShekelPotApply(pot, 0); 2424 } 2425 try { 2426 if (typeof window.ccPatchShekelLeaderboardRow === 'function') { 2427 window.ccPatchShekelLeaderboardRow(Object.assign({}, data, pot, { net_per_min: pot.net_per_min })); 2428 } else if (typeof window.ccNudgeShekelLeaderboard === 'function') { 2429 window.ccNudgeShekelLeaderboard(true); 2430 } 2431 } catch (_) {} 2432 } catch (e) { console.warn('shekel_pot:update handler', e); } 2433 }); 2434 2435 _ws.on('user:silent_muted', (data) => { 2436 try { 2437 if (typeof ccHandleSilentMutedPush === 'function') ccHandleSilentMutedPush(data); 2438 } catch (e) { console.warn('user:silent_muted handler', e); } 2439 }); 2440 2441 _ws.on('user:silent_unmuted', (data) => {
2442 try { 2443 if (typeof ccHandleSilentUnmutedPush === 'function') ccHandleSilentUnmutedPush(data); 2444 } catch (e) { console.warn('user:silent_unmuted handler', e); } 2445 }); 2446 2447 // Shekel shop counter prompt (target has UNO Reverse / Get Out cards) 2448 _ws.on('shekel:counter_prompt', (data) => { 2449 try { 2450 const meName = (window.currentUsername || (window.me && window.me.username) || '').trim().toLowerCase(); 2451 const target = (data && data.username || '').trim().toLowerCase(); 2452 const meId = window.me && window.me.id != null ? Number(window.me.id) : 0; 2453 const targetId = data && data.user_id != null ? Number(data.user_id) : 0; 2454 const forMe = (meId > 0 && targetId > 0 && meId === targetId) 2455 || (!!meName && !!target && meName === target); 2456 if (!forMe) return; 2457 if (typeof ccDismissForceLogoutModal === 'function') ccDismissForceLogoutModal(); 2458 if (typeof showShekelCounterPrompt === 'function') showShekelCounterPrompt(data); 2459 } catch (e) { console.warn('shekel:counter_prompt handler', e); } 2460 }); 2461 2462 // Buyer nudges target after a pending shop attack â force a pending check. 2463 _ws.on('shekel:check_pending', () => { 2464 try { 2465 if (typeof checkShekelShopPendingCounter === 'function') checkShekelShopPendingCounter(true); 2466 } catch (e) { /* ignore */ } 2467 }); 2468 2469 _ws.on('shekel:uno_reverse', (data) => { 2470 try { 2471 const meName = (window.currentUsername || (window.me && window.me.username) || '').trim().toLowerCase(); 2472 const target = (data && data.username || '').trim().toLowerCase(); 2473 if (meName && target && meName === target) { 2474 if (typeof flashUnoReverseCelebration === 'function') flashUnoReverseCelebration(); 2475 if (typeof refreshShopCardsInventory === 'function') refreshShopCardsInventory(); 2476 } 2477 } catch (e) { /* ignore */ } 2478 }); 2479 2480 // Live ticker updates (content + colour/size/weight/bg/speed/style) â no refresh needed 2481 _ws.on('ticker:update', (data) => { 2482 try { 2483 window._ccTickerSettingsSnap = ''; 2484 window._ccTickerNewsText = null; 2485 window._ccTickerComposeFp = null; 2486 if (typeof applyTickerSettings === 'function') applyTickerSettings(data); 2487 if (typeof ccPersistTickerLocally === 'function') ccPersistTickerLocally(data); 2488 } catch (e) {} 2489 }); 2490 2491 _ws.on('shop_prices:update', (data) => { 2492 try { 2493 if (typeof window._ccApplyPlatinumPricesFromPayload === 'function') { 2494 // Live admin panel save â always win over a stale shop_extras cache. 2495 window._ccApplyPlatinumPricesFromPayload(data, { force: true }); 2496 } 2497 } catch (e) {} 2498 }); 2499 2500 // Chat Config â Passive Earn â live rate changes for jars / local cam bonus. 2501 _ws.on('settings:passive_earn_rates', (data) => { 2502 try { 2503 const rates = (data && data.rates && typeof data.rates === 'object') ? data.rates : data; 2504 if (!rates || typeof rates !== 'object') return; 2505 window.__ccPassiveEarnRates = rates; 2506 if (typeof rates.rate_webcam_bonus === 'number') { 2507 window.__ccShekelRateWebcamBonus = rates.rate_webcam_bonus; 2508 } 2509 if (typeof rates.rate_active === 'number') { 2510 window.__ccShekelRateActive = rates.rate_active; 2511 } 2512 if (typeof rates.rate_idle === 'number') { 2513 window.__ccShekelRateIdle = rates.rate_idle; 2514 } 2515 if (typeof window.ccShekelPotForceSync === 'function') { 2516 window.ccShekelPotForceSync('passive_earn_rates'); 2517 } 2518 if (typeof window.ccNudgeShekelLeaderboard === 'function') window.ccNudgeShekelLeaderboard(true); 2519 } catch (e) {} 2520 }); 2521 2522 // Chat Config master tax switch â live for send UI / PPV hints / help copy. 2523 _ws.on('taxes:update', (data) => { 2524 try { 2525 const on = !(data && (data.taxes_enabled === 0 || data.taxes_enabled === false || data.taxes_enabled === '0')); 2526 window._ccTaxesEnabled = on ? 1 : 0; 2527 if (window.__siteSettings && typeof window.__siteSettings === 'object') { 2528 window.__siteSettings.taxes_enabled = on ? 1 : 0; 2529 } 2530 if (typeof window.ccApplyTaxesEnabledUi === 'function') { 2531 window.ccApplyTaxesEnabledUi(on); 2532 } 2533 } catch (e) {} 2534 }); 2535 2536 // Lobby guest + registration gates â keep landing / Chat Config cache live. 2537 _ws.on('auth_gates:update', (data) => {
2538 try { 2539 if (!data || typeof data !== 'object') return; 2540 const br = data.block_registration === 1 || data.block_registration === true || data.block_registration === '1' ? 1 : 0; 2541 const gl = data.guest_login_lobby === 1 || data.guest_login_lobby === true || data.guest_login_lobby === '1' ? 1 : 0; 2542 window._ccAuthGates = { block_registration: br, guest_login_lobby: gl, ts: data.ts || Date.now() }; 2543 if (window.__siteSettings && typeof window.__siteSettings === 'object') { 2544 window.__siteSettings.block_registration = br; 2545 window.__siteSettings.guest_login_lobby = gl; 2546 } 2547 if (window._ccChatConfigCache) { 2548 window._ccChatConfigCache.lobbyGuestEnabled = !!gl; 2549 if (window._ccChatConfigCache.cfg) { 2550 window._ccChatConfigCache.cfg.block_registration = br; 2551 } 2552 } 2553 if (typeof window.ccApplyAuthGatesUi === 'function') { 2554 window.ccApplyAuthGatesUi(window._ccAuthGates); 2555 } 2556 } catch (e) {} 2557 }); 2558 2559 // Chat Config bank loans switch / max / duration â refresh Bank tab if open. 2560 _ws.on('bank_loans:update', (data) => { 2561 try { 2562 const on = !(data && (data.bank_loans_enabled === 0 || data.bank_loans_enabled === false || data.bank_loans_enabled === '0')); 2563 if (window.__siteSettings && typeof window.__siteSettings === 'object') { 2564 window.__siteSettings.bank_loans_enabled = on ? 1 : 0; 2565 if (data && data.bank_loan_max_borrow != null) { 2566 window.__siteSettings.bank_loan_max_borrow = parseInt(data.bank_loan_max_borrow, 10) || 10000; 2567 } 2568 if (data && data.bank_loan_duration_days != null) { 2569 window.__siteSettings.bank_loan_duration_days = parseInt(data.bank_loan_duration_days, 10) || 7; 2570 } 2571 } 2572 // Instantly patch open Bank tab caps before bank_status returns. 2573 try { 2574 if (window._shekBankState && data) { 2575 if (data.bank_loan_max_borrow != null) { 2576 const m = parseInt(data.bank_loan_max_borrow, 10) || 10000; 2577 window._shekBankState.max_borrow_per_period = m; 2578 window._shekBankState.amount_tier = m; 2579 } 2580 if (data.bank_loan_duration_days != null) { 2581 const d = parseInt(data.bank_loan_duration_days, 10) || 7; 2582 window._shekBankState.due_days = d; 2583 window._shekBankState.period_days = d; 2584 } 2585 // Limits save resets period usage server-side â clear local "borrowed recently". 2586 if (data.bank_loan_max_borrow != null || data.bank_loan_duration_days != null) { 2587 window._shekBankState.borrowed_last_period = 0; 2588 const maxB = window._shekBankState.max_borrow_per_period || 10000; 2589 window._shekBankState.available_borrow = Math.max(0, Math.min( 2590 window._shekBankState.economy_available || maxB, 2591 maxB 2592 )); 2593 } 2594 if (typeof window.renderBank === 'function') { 2595 window.renderBank(window._shekBankState); 2596 } 2597 } 2598 } catch (_) {} 2599 if (typeof window.loadBankStatus === 'function') { 2600 window.loadBankStatus(); 2601 } 2602 } catch (e) {} 2603 }); 2604 2605 // Server marked users idle â italic names for everyone. 2606 _ws.on('shekel_pot:idle', (data) => { 2607 try { 2608 const myId = window.me && window.me.id != null ? Number(window.me.id) : 0; 2609 const ids = (data && Array.isArray(data.user_ids)) ? data.user_ids.map(Number) : []; 2610 const names = (data && Array.isArray(data.usernames)) 2611 ? data.usernames.map(function (n) { return String(n || '').toLowerCase(); }) 2612 : []; 2613 const idSet = {}; 2614 ids.forEach(function (id) { if (id > 0) idSet[id] = true; }); 2615 names.forEach(function (un) { 2616 if (!un) return; 2617 if (typeof window._ccSetUserListNameIdle === 'function') { 2618 window._ccSetUserListNameIdle(un, true); 2619 } 2620 }); 2621 if (Array.isArray(window.users)) {
2622 window.users.forEach(function (u) { 2623 if (!u) return; 2624 const uid = u.id != null ? Number(u.id) : 0; 2625 const un = String(u.username || '').toLowerCase(); 2626 if ((uid > 0 && idSet[uid]) || (un && names.indexOf(un) !== -1)) { 2627 u.session_status = 'idle'; 2628 if (typeof window._ccSetUserListNameIdle === 'function') { 2629 window._ccSetUserListNameIdle(u.username, true); 2630 } 2631 } 2632 }); 2633 } 2634 if (myId && idSet[myId] && typeof window.ccMarkSelfIdleLocally === 'function') { 2635 var idleMs = (typeof window.CC_IDLE_AFTER_MS === 'number') ? window.CC_IDLE_AFTER_MS : (10 * 60 * 1000); 2636 var recent = typeof window.ccMsSinceUserActivity === 'function' && window.ccMsSinceUserActivity() < idleMs; 2637 if (!recent) window.ccMarkSelfIdleLocally(); 2638 } 2639 } catch (e) {} 2640 }); 2641 2642 // Server woke a user â bold names for everyone immediately. 2643 _ws.on('shekel_pot:active', (data) => { 2644 try { 2645 const myId = window.me && window.me.id != null ? Number(window.me.id) : 0; 2646 const ids = (data && Array.isArray(data.user_ids)) ? data.user_ids.map(Number) : []; 2647 const names = (data && Array.isArray(data.usernames)) 2648 ? data.usernames.map(function (n) { return String(n || '').toLowerCase(); }) 2649 : []; 2650 const idSet = {}; 2651 ids.forEach(function (id) { if (id > 0) idSet[id] = true; }); 2652 names.forEach(function (un) { 2653 if (!un) return; 2654 if (typeof window._ccSetUserListNameIdle === 'function') { 2655 window._ccSetUserListNameIdle(un, false); 2656 } 2657 }); 2658 if (Array.isArray(window.users)) { 2659 window.users.forEach(function (u) { 2660 if (!u) return; 2661 const uid = u.id != null ? Number(u.id) : 0; 2662 const un = String(u.username || '').toLowerCase(); 2663 if ((uid > 0 && idSet[uid]) || (un && names.indexOf(un) !== -1)) { 2664 u.session_status = 'active'; 2665 if (typeof window._ccSetUserListNameIdle === 'function') { 2666 window._ccSetUserListNameIdle(u.username, false); 2667 } 2668 } 2669 }); 2670 } 2671 if (myId && idSet[myId] && typeof window.ccMarkSelfActiveLocally === 'function') { 2672 window.ccMarkSelfActiveLocally(); 2673 } 2674 } catch (e) {} 2675 }); 2676 2677 _ws.on('casino_games:update', (data) => { 2678 try { 2679 // Global catalog only. Applying it raw wiped every per-user disable 2680 // ("games I turned off for this account came back on"). Re-fetch our 2681 // effective flags so global offs still hide and personal offs stick. 2682 if (typeof window.ccRefreshCasinoGameFlagsFromServer === 'function') { 2683 window.ccRefreshCasinoGameFlagsFromServer(); 2684 } else if (data && typeof window.ccApplyCasinoGameFlagsFromPayload === 'function') { 2685 window.ccApplyCasinoGameFlagsFromPayload(data); 2686 } 2687 } catch (e) {} 2688 }); 2689 2690 _ws.on('user_casino_games:update', (data) => { 2691 try { 2692 const myId = window.me && window.me.id ? Number(window.me.id) : 0; 2693 if (!data || !myId || Number(data.user_id) !== myId) return; 2694 if (typeof window.ccApplyCasinoGameFlagsFromPayload === 'function') { 2695 window.ccApplyCasinoGameFlagsFromPayload(data); 2696 } 2697 } catch (e) {} 2698 }); 2699 2700 _ws.on('webcam_classic_layout:update', (data) => { 2701 try { 2702 if (data && data.users && typeof window.ccApplyClassicWebcamLayoutFromPayload === 'function') { 2703 // force: admin may intentionally clear the list (empty []). 2704 window.ccApplyClassicWebcamLayoutFromPayload(data.users, { force: true }); 2705 } 2706 } catch (e) {} 2707 }); 2708 2709 _ws.on('ticker:paid_shout', (data) => { 2710 try { 2711 if (data && Array.isArray(data.shouts)) window._ccPaidTickerShouts = data.shouts; 2712 if (typeof ccApplyPaidTickerShouts === 'function') ccApplyPaidTickerShouts(); 2713 } catch (e) {} 2714 }); 2715 2716 _ws.on('ticker:birthday', (data) => {
2717 try { 2718 if (data && Array.isArray(data.entries)) { 2719 window._ccBirthdayTickerEntries = data.entries; 2720 window._ccBirthdayTickerMmdd = data.mmdd || ''; 2721 } 2722 if (typeof ccApplyBirthdayTickerShouts === 'function') { 2723 ccApplyBirthdayTickerShouts({ force: true }); 2724 } 2725 try { if (typeof ccRetuneTickerScroll === 'function') ccRetuneTickerScroll(true); } catch (_) {} 2726 } catch (e) {} 2727 }); 2728 2729 // Virtual gifts catalog changed in the admin panel â refresh the Gifts tab live. 2730 _ws.on('gifts:updated', () => { 2731 try { if (typeof window.loadGiftsCatalog === 'function') window.loadGiftsCatalog(); } catch (e) {} 2732 }); 2733 2734 _ws.on('announcement_templates:updated', (data) => { 2735 try { 2736 if (window.CCAnnouncementTranslate && typeof window.CCAnnouncementTranslate.apply === 'function') { 2737 window.CCAnnouncementTranslate.apply(data || {}); 2738 } 2739 } catch (e) {} 2740 }); 2741 2742 // Someone sent a gift â full-screen GIF + sound for sender and recipient. 2743 _ws.on('gift:sent', (data) => { 2744 try { 2745 // Backup path: open Accept from gift:sent if mystery_gift:offer was missed. 2746 if (data && data.mystery_pending && data.pending_id) { 2747 const meName = (window.currentUsername || (window.me && window.me.username) || '').trim().toLowerCase(); 2748 const toName = String(data.to || '').trim().toLowerCase(); 2749 if (meName && toName && meName === toName && typeof window.promptMysteryGiftAccept === 'function') { 2750 window.promptMysteryGiftAccept({ 2751 pending_id: data.pending_id, 2752 from: data.from, 2753 to: data.to, 2754 image_url: data.image_url, 2755 }); 2756 } 2757 } 2758 } catch (e) {} 2759 try { if (typeof window.playGiftAnimation === 'function') window.playGiftAnimation(data); } catch (e) {} 2760 }); 2761 2762 _ws.on('spotify_dj:skip_request', function () {}); 2763 _ws.on('spotify_dj:skip_result', function () {}); 2764 2765 _ws.on('mystery_gift:offer', (data) => { 2766 try { 2767 if (!data || !data.pending_id) return; 2768 const meId = window.me && window.me.id != null ? Number(window.me.id) : 0; 2769 const recipientId = data.recipient_id != null ? Number(data.recipient_id) : 0; 2770 if (recipientId > 0 && meId > 0 && recipientId !== meId) return; 2771 const meName = (window.currentUsername || (window.me && window.me.username) || '').trim().toLowerCase(); 2772 const toName = String(data.to || '').trim().toLowerCase(); 2773 if (!meName || !toName || meName !== toName) return; 2774 // Open Accept immediately on the WS tick â don't wait for gift anim / chat paint. 2775 if (typeof window.promptMysteryGiftAccept === 'function') { 2776 window.promptMysteryGiftAccept(data); 2777 } 2778 } catch (e) {} 2779 }); 2780 2781 _ws.on('mystery_gift:resolved', (data) => { 2782 try { 2783 if (!data) return; 2784 const meName = (window.currentUsername || (window.me && window.me.username) || '').trim().toLowerCase(); 2785 const toName = String(data.to || '').trim().toLowerCase(); 2786 const fromName = String(data.from || '').trim().toLowerCase(); 2787 const involved = (meName && (meName === toName || meName === fromName)); 2788 if (!involved) return; 2789 if (typeof data.shekels === 'number' && typeof applyShekelsFromApiResponse === 'function') { 2790 applyShekelsFromApiResponse({ shekels: data.shekels }); 2791 } else if (typeof data.balance === 'number' && typeof setPoints === 'function') { 2792 setPoints(data.balance); 2793 } else if (typeof refreshBalance === 'function') { 2794 refreshBalance(); 2795 } 2796 if (typeof window.dismissMysteryGiftPrompt === 'function') { 2797 try { window.dismissMysteryGiftPrompt(data.pending_id); } catch (_) {} 2798 } 2799 } catch (e) {} 2800 }); 2801 2802 _ws.on('platinum:welcome', (data) => { 2803 try { if (typeof _ccHandlePlatinumWelcomeJoin === 'function') _ccHandlePlatinumWelcomeJoin(data); } catch (e) {} 2804 }); 2805 2806 // Global / targeted system message overlay (admin broadcast or specific users) 2807 _ws.on('global_system_message', (data) => { 2808 if (!data || !data.message) return; 2809 var targetUser = (data.target_username || '').trim(); 2810 if (targetUser) { 2811 var meName = (window.currentUsername || (window.me && window.me.username) || '').trim(); 2812 if (!meName || targetUser.toLowerCase() !== meName.toLowerCase()) return; 2813 } 2814 const messageKey = `${data.message_id || ''}_${data.timestamp || 0}_${targetUser || 'all'}`; 2815 if (window.shownGlobalMessages.has(messageKey)) return; 2816 window.shownGlobalMessages.add(messageKey); 2817 const normalized = Object.assign({}, data, { type: data.type || data.message_type || 'announcement' }); 2818 showGlobalMessage(normalized); 2819 }); 2820
2821 // Someone sent you a defense / UNO Reverse card â toast + refresh inventory. 2822 _ws.on('shekel:card_gift', (data) => { 2823 if (!data) return; 2824 const from = (data.from || 'Someone'); 2825 const label = (data.label || 'a card'); 2826 const qty = parseInt(data.qty, 10) || 1; 2827 const msg = `${from} sent you ${qty} ${label} card${qty === 1 ? '' : 's'}! ð`; 2828 try { if (typeof showToast === 'function') showToast(msg, 'success'); } catch (e) {} 2829 try { if (typeof refreshShopCardsInventory === 'function') refreshShopCardsInventory(); } catch (e) {} 2830 }); 2831 2832 // Polls updated â paint from the push FIRST (instant for everyone). 2833 // Background HTTP reconcile is delayed so a stale empty list cannot wipe the new poll. 2834 _ws.on('polls:updated', (data) => { 2835 window._lastRefreshPollsAt = Date.now(); 2836 let painted = false; 2837 try { 2838 if (data && data.action === 'close' && data.poll_id != null 2839 && typeof window.ccRemoveActivePoll === 'function') { 2840 window.ccRemoveActivePoll(data.poll_id, { forceModal: true }); 2841 painted = true; 2842 } else if (data && (data.action === 'create' || data.poll) 2843 && data.poll && typeof window.ccUpsertActivePoll === 'function') { 2844 window.ccUpsertActivePoll(data.poll, { forceModal: true }); 2845 painted = true; 2846 } 2847 } catch (_) {} 2848 // Pulse the polls button even if the modal is closed. 2849 try { 2850 const badge = document.getElementById('pollsBadge'); 2851 const list = Array.isArray(window.activePolls) ? window.activePolls 2852 : (Array.isArray(window.pollsActive) ? window.pollsActive : []); 2853 const n = list.length; 2854 if (badge && n > 0) { 2855 badge.textContent = String(n); 2856 badge.style.display = 'inline-block'; 2857 badge.classList.add('pulse'); 2858 } 2859 } catch (_) {} 2860 if (typeof refreshPolls === 'function') { 2861 const delay = painted ? 1500 : 0; 2862 clearTimeout(window._ccPollsPushRefreshTimer); 2863 window._ccPollsPushRefreshTimer = setTimeout(function () { 2864 refreshPolls({ fromPush: true, forceHttp: true, forceModal: true }); 2865 }, delay); 2866 } 2867 }); 2868 // Dedicated create event (same paint path) â some clients filter generic updates. 2869 _ws.on('polls:created', (data) => { 2870 window._lastRefreshPollsAt = Date.now(); 2871 try { 2872 const poll = data && (data.poll || data); 2873 if (poll && poll.id != null && typeof window.ccUpsertActivePoll === 'function') { 2874 window.ccUpsertActivePoll(poll, { forceModal: true }); 2875 } 2876 } catch (_) {} 2877 }); 2878 _ws.on('suggestions:updated', () => { 2879 window._lastRefreshSuggestionsAt = Date.now(); 2880 if (typeof refreshSuggestions === 'function') refreshSuggestions(); 2881 }); 2882 _ws.on('staff_notes:updated', () => { if (typeof refreshStaffNotesBadge === 'function') refreshStaffNotesBadge(); }); 2883 _ws.on('complaints:updated', () => { if (typeof refreshComplaints === 'function') refreshComplaints(); }); 2884 _ws.on('lobby:welcome', (payload) => { 2885 try { 2886 if (payload && payload.message != null) window._ccLobbyWelcomeText = String(payload.message || ''); 2887 if (typeof window.ccPaintLobbyWelcomeAnnouncement === 'function') { 2888 window.ccPaintLobbyWelcomeAnnouncement(window._ccLobbyWelcomeText); 2889 } 2890 } catch (_) {} 2891 }); 2892 2893 // Profile inbox â instant unread badge + list refresh for recipient 2894 _ws.on('profile:inbox', (data) => { 2895 try { if (typeof ccHandleProfileInboxWs === 'function') ccHandleProfileInboxWs(data); } catch (e) {} 2896 }); 2897 2898 _ws.on('dm:group_invite', (data) => { 2899 try { if (typeof window.ccHandleDmGroupInviteWs === 'function') window.ccHandleDmGroupInviteWs(data); } catch (e) {} 2900 }); 2901 _ws.on('dm:group_invite_result', (data) => { 2902 try { if (typeof window.ccHandleDmGroupInviteResultWs === 'function') window.ccHandleDmGroupInviteResultWs(data); } catch (e) {} 2903 }); 2904 2905 // Live chat-socket roster â add anyone in the room the moment they connect. 2906 _ws.on('users:presence', (payload) => {
2907 try { 2908 var cur = (window.currentRoomId != null ? window.currentRoomId : 1) | 0; 2909 var rid = payload && payload.room_id != null && payload.room_id !== '' 2910 ? ((Number(payload.room_id) || 1) | 0) 2911 : cur; 2912 var lobbyWhileWp = typeof window.ccWpShouldUseLobbyUserList === 'function' 2913 && window.ccWpShouldUseLobbyUserList(); 2914 var ridIsWp = rid >= 9001 && rid <= 9005; 2915 // Theater Participants has its own roster. Keep window.users as lobby. 2916 if (lobbyWhileWp && ridIsWp) return; 2917 if (lobbyWhileWp && rid === 1) { 2918 cur = 1; 2919 } 2920 // Presence is one room's membership. Applying another room's snapshot is 2921 // what made the lobby collapse to a private room's size and flip back. 2922 if (rid !== cur) return; 2923 if (payload && Array.isArray(payload.usernames) 2924 && typeof window._ccReconcileRosterFromPresence === 'function') { 2925 window._ccReconcileRosterFromPresence( 2926 payload.usernames, 2927 Array.isArray(payload.users) ? payload.users : null, 2928 { roomId: rid, pendingRows: payload.pending_rows } 2929 ); 2930 } 2931 } catch (_) {} 2932 }); 2933 2934 // User list changed (join/leave / VisibleâHidden) â instant DOM patch + server reconcile 2935 _ws.on('users:changed', (payload) => { 2936 var cur = (window.currentRoomId != null ? window.currentRoomId : 1) | 0; 2937 if (typeof window.ccWpShouldUseLobbyUserList === 'function' && window.ccWpShouldUseLobbyUserList()) { 2938 cur = 1; 2939 } 2940 var scopeRoom = null; 2941 if (payload && typeof payload === 'object' && ('room_id' in payload)) { 2942 var raw = payload.room_id; 2943 scopeRoom = (raw == null || raw === '') ? 1 : ((Number(raw) || 1) | 0); 2944 } 2945 if (scopeRoom != null && scopeRoom !== cur) { 2946 var rsnSkip = payload && payload.reason != null ? String(payload.reason) : ''; 2947 var lastSockLeave = !!(payload && (payload.last_socket || payload.gone)) 2948 && (rsnSkip === 'leave_room' || rsnSkip === 'leave' || rsnSkip === 'left' || rsnSkip === 'account_switch'); 2949 if (!lastSockLeave) { 2950 if (typeof window.wsDebugOn === 'function' && window.wsDebugOn()) console.log('[WS user-list path] users:changed skipped (room ' + scopeRoom + ' != current ' + cur + ')'); 2951 return; 2952 } 2953 } 2954 if (payload && payload.username) { 2955 var un = String(payload.username).trim(); 2956 if (/^bailout-watch-\d+/i.test(un)) return; 2957 if (typeof window._ccIsSystemUsername === 'function' && window._ccIsSystemUsername(un)) return; 2958 var reason = String(payload.reason || ''); 2959 var userObj = (payload.user && typeof payload.user === 'object') ? payload.user : null; 2960 if (un && (reason === 'userlist_hidden' || reason === 'userlist_visible')) { 2961 if (typeof window._ccHandleUserlistVisibilityChange === 'function') { 2962 try { 2963 window._ccHandleUserlistVisibilityChange(un, reason === 'userlist_hidden', userObj); 2964 } catch (_) {} 2965 } 2966 // Hidden/Visible handled â do not fall through into join paint. 2967 return; 2968 } 2969 if (un) { 2970 if (reason === 'join_room' || reason === 'join') { 2971 const joinHidden = !!(userObj && ( 2972 userObj.hidden_from_userlist === 1 2973 || userObj.hidden_from_userlist === true 2974 || userObj.hidden_from_userlist === '1' 2975 )) || (typeof window._ccIsPublisherUserlistHidden === 'function' 2976 && window._ccIsPublisherUserlistHidden(un)); 2977 const joinPendingGuest = typeof ccUserIsPendingGuest === 'function' 2978 ? ccUserIsPendingGuest(userObj || { username: un, role: (userObj && userObj.role) || '' }) 2979 : !!(userObj && (userObj.guest_pending_verify === 1 2980 || userObj.guest_pending_verify === true 2981 || userObj.guest_pending_verify === '1' 2982 || String((userObj && userObj.role) || '').toLowerCase() === 'guest' 2983 || /^guest_/i.test(un))); 2984 const joinSelf = typeof window._ccIsSelfRosterName === 'function' 2985 && window._ccIsSelfRosterName(un); 2986 if (joinHidden && typeof window._ccMarkUserlistHidden === 'function') { 2987 window._ccMarkUserlistHidden(un, true); 2988 } 2989 const hidePendingGuest = false;
2990 if (joinPendingGuest && typeof ccMarkPendingGuestNames === 'function') { 2991 try { ccMarkPendingGuestNames(userObj || un, true); } catch (_) {} 2992 } 2993 const hideHiddenPeer = joinHidden && !joinSelf 2994 && (typeof window._ccShouldHideHiddenPeerFromMyList !== 'function' 2995 || window._ccShouldHideHiddenPeerFromMyList(un)); 2996 if (hideHiddenPeer || hidePendingGuest) { 2997 if (typeof _ccRemoveUserFromList === 'function') { 2998 try { _ccRemoveUserFromList(un, { notDeparted: true }); } catch (_) {} 2999 } 3000 } else { 3001 var joinWasLeft = typeof window._ccUserExplicitlyLeft === 'function' 3002 && (window._ccUserExplicitlyLeft(un) 3003 || (userObj && userObj.display_name && window._ccUserExplicitlyLeft(userObj.display_name))); 3004 var joinAlreadyOn = typeof window._ccUserListHasName === 'function' 3005 && (window._ccUserListHasName(un) 3006 || (userObj && userObj.display_name && window._ccUserListHasName(userObj.display_name))); 3007 // First appearance or return after leave â never announce while 3008 // they are still sitting on Online (refresh join-before-leave). 3009 if (!joinHidden && (joinWasLeft || !joinAlreadyOn) && typeof window._ccAnnounceJoinFromWs === 'function') { 3010 window._ccAnnounceJoinFromWs(payload, { force: !!joinWasLeft }); 3011 } 3012 if (typeof window._ccClearUserExplicitLeft === 'function') { 3013 window._ccClearUserExplicitLeft(un); 3014 if (userObj && userObj.display_name) window._ccClearUserExplicitLeft(userObj.display_name); 3015 } 3016 if (typeof window._ccPresenceTrackJoin === 'function') { 3017 try { window._ccPresenceTrackJoin(un); } catch (_) {} 3018 } 3019 if (typeof window._ccAddUserToListInstant === 'function') { 3020 window._ccAddUserToListInstant(un, userObj ? Object.assign({}, userObj, joinHidden ? { hidden_from_userlist: 1 } : {}) : userObj, { fromJoin: true, allowHidden: !!joinHidden }); 3021 } 3022 } 3023 } else if (reason === 'roster_update') { 3024 // Detail that arrived after the join (country flag, for one). 3025 // Merges into the existing row; must never re-announce. 3026 const updHidden = !!(userObj && ( 3027 userObj.hidden_from_userlist === 1 3028 || userObj.hidden_from_userlist === true 3029 || userObj.hidden_from_userlist === '1' 3030 )) || (typeof window._ccIsPublisherUserlistHidden === 'function' 3031 && window._ccIsPublisherUserlistHidden(un)); 3032 const updSelf = typeof window._ccIsSelfRosterName === 'function' 3033 && window._ccIsSelfRosterName(un); 3034 if (updHidden && typeof window._ccMarkUserlistHidden === 'function') { 3035 window._ccMarkUserlistHidden(un, true); 3036 } 3037 const hideUpdPeer = updHidden && !updSelf 3038 && (typeof window._ccShouldHideHiddenPeerFromMyList !== 'function' 3039 || window._ccShouldHideHiddenPeerFromMyList(un)); 3040 if (hideUpdPeer) { 3041 if (typeof _ccRemoveUserFromList === 'function') { 3042 try { _ccRemoveUserFromList(un, { notDeparted: true }); } catch (_) {} 3043 } 3044 } else if (userObj && typeof window._ccAddUserToListInstant === 'function') { 3045 userObj._roleAuthoritative = true; 3046 var wasLeft = typeof window._ccUserExplicitlyLeft === 'function' 3047 && (window._ccUserExplicitlyLeft(un) 3048 || (userObj.display_name && window._ccUserExplicitlyLeft(userObj.display_name))); 3049 if (wasLeft && !updHidden && typeof window._ccAnnounceJoinFromWs === 'function') { 3050 window._ccAnnounceJoinFromWs(payload, { force: true }); 3051 } 3052 window._ccAddUserToListInstant(un, Object.assign({}, userObj, updHidden ? { hidden_from_userlist: 1 } : {}), { fromJoin: true, allowHidden: !!updHidden }); 3053 } 3054 } else if (reason === 'leave_room' || reason === 'leave' || reason === 'left' 3055 || reason === 'account_switch') { 3056 if (reason !== 'account_switch' && scopeRoom != null 3057 && (typeof window.ccIsWatchPartyRoom === 'function' 3058 ? window.ccIsWatchPartyRoom(scopeRoom) 3059 : (scopeRoom >= 9001 && scopeRoom <= 9005))) { 3060 return; 3061 } 3062 var leaveName = un; 3063 var leaveAlias = ''; 3064 if (userObj) { 3065 leaveName = (typeof ccAppleGuestListLabel === 'function' && ccAppleGuestListLabel(userObj)) 3066 || String(userObj.display_name || userObj.guest_chosen_alias || userObj.username || un).trim() 3067 || un; 3068 leaveAlias = String(userObj.guest_chosen_alias || '').trim(); 3069 } 3070 var leaveHidden = (typeof window._ccIsPublisherUserlistHidden === 'function' 3071 && window._ccIsPublisherUserlistHidden(un)); 3072 var skipAnn = reason === 'account_switch' || leaveHidden; 3073 // Always drop by wire username + AppleGuest label + alias so ghosts vanish instantly. 3074 if (typeof _ccHandleUserLeftRoom === 'function') { 3075 _ccHandleUserLeftRoom(un, { skipAnnounce: skipAnn, sameRoom: true, immediate: reason === 'account_switch' }); 3076 if (leaveName && leaveName !== un) { 3077 try { _ccHandleUserLeftRoom(leaveName, { skipAnnounce: true, sameRoom: true, immediate: reason === 'account_switch' }); } catch (_) {} 3078 } 3079 if (leaveAlias && leaveAlias !== un && leaveAlias !== leaveName) { 3080 try { _ccHandleUserLeftRoom(leaveAlias, { skipAnnounce: true, sameRoom: true, immediate: reason === 'account_switch' }); } catch (_) {} 3081 } 3082 } else if (typeof _ccRemoveUserFromList === 'function') { 3083 _ccRemoveUserFromList(un); 3084 if (leaveName && leaveName !== un) _ccRemoveUserFromList(leaveName); 3085 if (leaveAlias && leaveAlias !== un && leaveAlias !== leaveName) _ccRemoveUserFromList(leaveAlias); 3086 } 3087 } 3088 } 3089 // Slim payloads carry platinum_active / double_earn_active / dm_privacy â 3090 // repaint that user's badges even when roster membership has not changed. 3091 if (un && userObj && reason !== 'leave_room' && reason !== 'leave' && reason !== 'left' 3092 && typeof window._ccApplyRosterUserBadgeFields === 'function') { 3093 try { window._ccApplyRosterUserBadgeFields(un, userObj); } catch (_) {} 3094 } 3095 if (un && (reason === 'session_idle' || reason === 'session_cleanup_idle')) { 3096 if (typeof window._ccSetUserListNameIdle === 'function') { 3097 try { window._ccSetUserListNameIdle(un, true); } catch (_) {} 3098 } 3099 return; 3100 } 3101 if (un && (reason === 'session_active' || reason === 'ping_real_activity' || reason === 'heartbeat_real_activity')) { 3102 if (typeof window._ccSetUserListNameIdle === 'function') { 3103 try { window._ccSetUserListNameIdle(un, false); } catch (_) {} 3104 } 3105 return; 3106 } 3107 if (un && (reason === 'badge_update' || reason === 'dm_privacy' || reason === 'pet_update')) { 3108 if (reason === 'dm_privacy' && userObj && typeof window._ccApplyDmPrivacyBadgeForUser === 'function') { 3109 try { 3110 var privMode = (typeof window._ccGetDmPrivacyMode === 'function') 3111 ? window._ccGetDmPrivacyMode(userObj) 3112 : (userObj.dm_privacy_mode || 'all'); 3113 if (privMode !== 'dnd' && privMode !== 'friends') privMode = 'all'; 3114 window._ccApplyDmPrivacyBadgeForUser(un, privMode); 3115 } catch (_) {} 3116 } 3117 return; 3118 } 3119 } 3120 if (typeof window.wsDebugOn === 'function' && window.wsDebugOn()) { 3121 var d = window.wsDebugInit(); 3122 if (d) { d.lastUsersChanged = Date.now(); d.usersChangedCount = (d.usersChangedCount || 0) + 1; } 3123 console.log('[WS user-list path] users:changed received â scheduling immediate loadUsers()', new Date().toISOString()); 3124 } 3125 var hasFullUser = !!(payload && payload.user && (payload.user.id | 0) > 0); 3126 var reason = payload && payload.reason ? String(payload.reason) : ''; 3127 var unGap = payload && payload.username ? String(payload.username).trim() : ''; 3128 var isJoin = reason === 'join_room' || reason === 'join'; 3129 var isLeave = reason === 'leave_room' || reason === 'leave' || reason === 'left' 3130 || reason === 'account_switch'; 3131 var instantOk = isJoin || isLeave || reason === 'roster_update' 3132 || reason === 'userlist_hidden' || reason === 'userlist_visible'; 3133 // If instant paint could not land (name-only join) or leave left them on the 3134 // roster, force a short gap fetch so every client converges â soft 15âmin 3135 // reconcile is too slow and left Online (12) vs Online (11) mismatches. 3136 var stillMissing = !!(isJoin && unGap && typeof window._ccRosterMissingUsername === 'function' 3137 && window._ccRosterMissingUsername(unGap)); 3138 var stillPresent = false;
3139 if (isLeave && unGap && Array.isArray(window.users)) { 3140 try { 3141 stillPresent = window.users.some(function (u) { 3142 return u && typeof _ccUserListNameMatches === 'function' 3143 ? _ccUserListNameMatches(u, unGap) 3144 : (String(u.username || '').toLowerCase() === unGap.toLowerCase()); 3145 }); 3146 } catch (_) { stillPresent = false; } 3147 } 3148 var needsGapFetch = (isJoin && hasFullUser && stillMissing); 3149 if (isLeave && stillPresent && typeof _ccRemoveUserFromList === 'function') { 3150 try { _ccRemoveUserFromList(unGap); } catch (_) {} 3151 return; 3152 } 3153 if (isJoin && stillMissing && !hasFullUser && unGap) { 3154 // Thin join (username only) â the chat join line paints them. A users 3155 // poll right now is usually stale and used to wipe the new row. 3156 window._ccJoinRosterFallbackAt = window._ccJoinRosterFallbackAt || {}; 3157 var fbKey = unGap.toLowerCase(); 3158 if (!window._ccJoinRosterFallbackAt[fbKey]) { 3159 window._ccJoinRosterFallbackAt[fbKey] = Date.now(); 3160 setTimeout(function () { 3161 try { 3162 if (typeof window._ccRosterMissingUsername === 'function' 3163 && window._ccRosterMissingUsername(unGap) 3164 && typeof window.scheduleUserListRefresh === 'function') { 3165 window.scheduleUserListRefresh({ 3166 immediate: true, 3167 force: true, 3168 mustFetch: true, 3169 allowSolo: true, 3170 joinVerify: true 3171 }); 3172 } 3173 } catch (_) {} 3174 }, 1600); 3175 } 3176 return; 3177 } 3178 if (needsGapFetch && typeof window.scheduleUserListRefresh === 'function') { 3179 window.scheduleUserListRefresh({ 3180 immediate: true, 3181 force: true, 3182 mustFetch: true, 3183 allowSolo: true, 3184 joinVerify: !!isJoin 3185 }); 3186 return; 3187 } 3188 // WS-first: never storm HostGator with loadUsers on every presence tick. 3189 // Instant DOM patch above is authoritative; rare soft reconcile only. 3190 if (window._ccWsConnected && window._ccWsConnected()) { 3191 if (typeof window.scheduleUserListRefresh === 'function') { 3192 window.scheduleUserListRefresh({ immediate: false, force: false, wsSoft: true }); 3193 } 3194 return; 3195 } 3196 // WS down â keep HTTP reconcile so roster can recover. 3197 if (instantOk && hasFullUser) { 3198 if (typeof window.scheduleUserListRefresh === 'function') { 3199 window.scheduleUserListRefresh({ immediate: false, force: false }); 3200 } 3201 } else if (typeof window.scheduleUserListRefresh === 'function') { 3202 window.scheduleUserListRefresh({ immediate: true, force: true }); 3203 } else if (typeof loadUsers === 'function') { 3204 loadUsers({ force: true, immediate: true }); 3205 } 3206 }); 3207 3208 _ws.on('chat:typing', (payload) => { 3209 if (!payload || !payload.username) return; 3210 var cur = (window.currentRoomId != null ? window.currentRoomId : 1) | 0; 3211 var msgRoom = (payload.room_id == null || payload.room_id === '') ? 1 : ((Number(payload.room_id) || 1) | 0); 3212 if (msgRoom !== cur) return; 3213 if (typeof ccSetRemoteUserTyping === 'function') { 3214 ccSetRemoteUserTyping(payload.username, !!(payload.active === true || payload.active === 1), msgRoom); 3215 } 3216 }); 3217 3218 _ws.on('user:profile_photo', (payload) => { 3219 if (!payload || !payload.username) return; 3220 var cur = (window.currentRoomId != null ? window.currentRoomId : 1) | 0; 3221 if (payload.room_id != null && payload.room_id !== '' && ((Number(payload.room_id) || 1) | 0) !== cur) return; 3222 if (typeof window._ccApplyUserAvatarLive === 'function') { 3223 window._ccApplyUserAvatarLive(payload.username, payload.profile_photo_url || '', payload.user_id); 3224 } 3225 }); 3226 3227 // Instant leave + roster removal when the last chat socket is gone. 3228 // Announce is owned by users:changed (leave_room / leave) so Guest_/AppleGuest 3229 // cannot produce two different "has left" lines when both events fire. 3230 _ws.on('presence:left', (data) => { 3231 if (!data || !data.username) return; 3232 if (typeof window.ccIsInternalWatchUsername === 'function' 3233 && window.ccIsInternalWatchUsername(data.username)) { 3234 return; 3235 } 3236 var cur = (window.currentRoomId != null ? window.currentRoomId : 1) | 0; 3237 var lastSocket = !!(data.last_socket || data.gone || data.global); 3238 var msgRoom = (data.room_id == null || data.room_id === '') ? 1 : ((Number(data.room_id) || 1
3238) | 0); 3239 if (typeof window.ccIsWatchPartyRoom === 'function' ? window.ccIsWatchPartyRoom(msgRoom) : (msgRoom >= 9001 && msgRoom <= 9005)) { 3240 return; 3241 } 3242 if (!lastSocket && msgRoom !== cur) return; 3243 if (typeof _ccHandleUserLeftRoom === 'function') { 3244 _ccHandleUserLeftRoom(data.username, { skipAnnounce: true, sameRoom: true }); 3245 } 3246 if (typeof ccSetRemoteUserTyping === 'function') { 3247 ccSetRemoteUserTyping(data.username, false, msgRoom); 3248 } 3249 }); 3250 3251 // Mutes updated (mute/unmute/force_mute) â refetch quickly so force-mutes apply while users are online 3252 _ws.on('mutes:updated', (payload) => { 3253 // Do NOT pre-stamp window._lastMutesFetch here â doing so tripped the 3254 // 10-minute 'mutes' poll skip-guard, so refreshMutedBy() bailed out and 3255 // the "staff muted" badge took minutes to appear/clear. Force the 3256 // refresh instead so force-mute add/remove reflects instantly. 3257 3258 // Personal mutes name both people. Staff force-mutes name both parties 3259 // (and linked alts). Apply locally right away so the block/unblock is 3260 // live for them, and let everyone else converge on a jittered delay. 3261 let affected = true; 3262 let appliedLive = false; 3263 const kind = payload && payload.kind ? String(payload.kind) : ''; 3264 try { 3265 if (kind === 'force_mute' || kind === 'force_unmute' || (payload && payload.user_a && payload.user_b && !payload.muter)) { 3266 affected = false; 3267 if (typeof window.ccApplyForceMuteEventLive === 'function') { 3268 appliedLive = window.ccApplyForceMuteEventLive(payload); 3269 affected = appliedLive; 3270 } 3271 } else if (payload && payload.muter && payload.target) { 3272 affected = false; 3273 if (typeof window.ccApplyPersonalMuteEventLive === 'function') { 3274 appliedLive = window.ccApplyPersonalMuteEventLive(payload); 3275 affected = appliedLive; 3276 } 3277 } 3278 } catch (_) {} 3279 const delay = appliedLive ? 0 : (affected ? 300 : (1500 + Math.floor(Math.random() * 2500))); 3280 if (appliedLive && typeof ccRefreshUserListAfterMuteChange === 'function') { 3281 try { ccRefreshUserListAfterMuteChange(); } catch (_) {} 3282 } 3283 clearTimeout(window._mutesUpdatedTimer); 3284 window._mutesUpdatedTimer = setTimeout(async () => { 3285 if (typeof refreshMutedBy === 'function') await refreshMutedBy(true); 3286 else if (typeof ccRefreshUserListAfterMuteChange === 'function') ccRefreshUserListAfterMuteChange(); 3287 // Live-refresh the Force Mute panel (staff force mutes + personal mutes) 3288 // if it's open on the Active Mutes tab â no manual refresh needed. 3289 try { 3290 if (typeof loadForceMutesList === 'function' && 3291 typeof _ccForceMuteManageVisible === 'function' && 3292 _ccForceMuteManageVisible()) { 3293 loadForceMutesList(); 3294 } 3295 } catch (e) {} 3296 try { 3297 if (typeof loadPersonalMutesList === 'function' && 3298 typeof _ccPersonalMutesModalOpen === 'function' && 3299 _ccPersonalMutesModalOpen()) { 3300 loadPersonalMutesList(); 3301 } 3302 } catch (e) {} 3303 }, delay); 3304 }); 3305 3306 _ws.on('friends:updated', (payload) => { 3307 try { 3308 if (!payload) return; 3309 const me = String((window.me && window.me.username) || window.currentUsername || '').toLowerCase(); 3310 if (!me) return; 3311 const a = String(payload.user_a || '').trim(); 3312 const b = String(payload.user_b || '').trim(); 3313 const aLc = a.toLowerCase(); 3314 const bLc = b.toLowerCase(); 3315 let other = ''; 3316 if (aLc === me) other = b; 3317 else if (bLc === me) other = a; 3318 if (!other) return; 3319 const otherLc = other.toLowerCase(); 3320 window._ccMyFriendsLc = window._ccMyFriendsLc || {}; 3321 if (payload.op === 'remove') delete window._ccMyFriendsLc[otherLc]; 3322 else window._ccMyFriendsLc[otherLc] = true; 3323 const row = (typeof window._ccFindUserListRow === 'function') 3324 ? window._ccFindUserListRow(other) 3325 : null; 3326 if (row && typeof _ccSyncFriendBadge === 'function') { 3327 _ccSyncFriendBadge(row, payload.op !== 'remove'); 3328 } 3329 if (typeof window._ccRefreshMyFriendsCache === 'function') { 3330 window._ccRefreshMyFriendsCache(true); 3331 } 3332 } catch (e) { console.warn('friends:updated handler', e); } 3333 }); 3334 3335 // New chat message via WebSocket (avoids HTTP poll for most messages) 3336 // Batched WebSocket message handler â flushes in chunks. Keeps draining while the 3337 // tab is backgrounded (second screen / other window) so chat stays live. 3338 let _wsMsgQueue = []; 3339 let _wsMsgFlushScheduled = false;
3340 if (!window._wsMsgPendingIds) window._wsMsgPendingIds = new Set(); 3341 3342 const _WS_FLUSH_CHUNK_VISIBLE = 64; 3343 const _WS_FLUSH_CHUNK_HIDDEN = 24; 3344 const _WS_FLUSH_CHUNK_CATCHUP = 12; 3345 let _wsRenderQueue = []; 3346 let _wsMsgFlushing = false; 3347 window._wsRenderQueueLen = function () { return _wsRenderQueue.length + _wsMsgQueue.length; }; 3348 3349 function _ccWsIsLiveTypedLine(data) { 3350 if (!data || window._ccCatchUpBusy) return false; 3351 const t = data.type; 3352 // System ð lines still flush on the coalesced 0ms timer â sync flush 3353 // on every announcement hitchs games / webcam on desktop. 3354 if (t === 'system') return false; 3355 return t !== 'join' && t !== 'leave' && t !== 'force_logout' 3356 && t !== 'kick' && t !== 'ban'; 3357 } 3358 3359 function _ccDiscardWsMessages(list, advanceLastId) { 3360 if (!Array.isArray(list) || !list.length) return; 3361 if (!window.processedAllMessageIds) window.processedAllMessageIds = new Set(); 3362 if (!window._wsMsgPendingIds) window._wsMsgPendingIds = new Set(); 3363 for (let i = 0; i < list.length; i++) { 3364 const data = list[i]; 3365 if (!data) continue; 3366 const mid = ccNormalizeChatMessageId(data.id); 3367 if (mid) window._wsMsgPendingIds.delete(mid); 3368 if (data.type === 'force_logout' && typeof rowHTML === 'function') { 3369 try { rowHTML(data); } catch (_) {} 3370 } else if (data.type === 'whisper' && typeof routeWhisper === 'function') { 3371 try { routeWhisper(data); } catch (_) {} 3372 } 3373 if (!advanceLastId) continue; 3374 if (mid) window.processedAllMessageIds.add(mid); 3375 if (data.id != null && (typeof lastId === 'undefined' || data.id > lastId)) { 3376 lastId = data.id; 3377 } 3378 } 3379 } 3380 3381 window._ccCapWsRenderQueue = function (maxKeep) { 3382 maxKeep = typeof maxKeep === 'number' ? maxKeep : 40; 3383 if (_wsMsgQueue.length) { 3384 let incoming = _wsMsgQueue; 3385 _wsMsgQueue = []; 3386 if (maxKeep <= 0) { 3387 _ccDiscardWsMessages(incoming, false); 3388 } else { 3389 incoming = (typeof _ccPrepareWsBatch === 'function') ? _ccPrepareWsBatch(incoming) : incoming; 3390 _ccPreapplyClearDirectives(incoming); 3391 for (let i = 0; i < incoming.length; i++) _wsRenderQueue.push(incoming[i]); 3392 } 3393 } 3394 if (maxKeep <= 0) { 3395 if (_wsRenderQueue.length) _ccDiscardWsMessages(_wsRenderQueue.splice(0), false); 3396 return; 3397 } 3398 if (_wsRenderQueue.length <= maxKeep) return; 3399 const systems = []; 3400 const others = []; 3401 for (let i = 0; i < _wsRenderQueue.length; i++) { 3402 const row = _wsRenderQueue[i]; 3403 if (row && row.type === 'system') systems.push(row); 3404 else others.push(row); 3405 } 3406 const maxOthers = Math.max(0, maxKeep - systems.length); 3407 if (others.length > maxOthers) { 3408 const drop = others.splice(0, others.length - maxOthers); 3409 _ccDiscardWsMessages(drop, false); 3410 } 3411 _wsRenderQueue = systems.concat(others); 3412 }; 3413 3414 window._ccDiscardAllWsChatQueues = function () { 3415 window._ccCapWsRenderQueue(0); 3416 }; 3417 3418 function _ccScheduleWsFlush() { 3419 if (_wsMsgFlushScheduled) return; 3420 _wsMsgFlushScheduled = true; 3421 // Never wait on rAF for chat. Webcam docks / games starve animation 3422 // frames on desktop while iOS already used setTimeout(0) â that is the 3423 // 5â10s âiPhone saw it firstâ gap. Poll intervals and the socket 3424 // protocol stay unchanged; this only paints rows the socket already has. 3425 setTimeout(_flushWsMsgQueue, 0); 3426 } 3427 3428 function _ccFlushWsChatNow() { 3429 if (_wsMsgFlushing) { 3430 _ccScheduleWsFlush(); 3431 return; 3432 } 3433 _flushWsMsgQueue(); 3434 } 3435 3436 function _flushWsMsgQueue() { 3437 _wsMsgFlushScheduled = false; 3438 if (_wsMsgFlushing) return; 3439 _wsMsgFlushing = true; 3440 try { 3441 if (_wsMsgQueue.length) { 3442 let incoming = _wsMsgQueue; 3443 _wsMsgQueue = []; 3444 incoming = (typeof _ccPrepareWsBatch === 'function') ? _ccPrepareWsBatch(incoming) : incoming; 3445 _ccPreapplyClearDirectives(incoming); 3446 for (let i = 0; i < incoming.length; i++) _wsRenderQueue.push(incoming[i]); 3447 } 3448 if (!_wsRenderQueue.length) return; 3449 // Soft safety cap â keep live while hidden; only drop ancient overflow. 3450 if (_wsRenderQueue.length > 80) window._ccCapWsRenderQueue(60); 3451 const chatEl = document.getElementById('chat'); 3452 if (!chatEl) { 3453 _wsRenderQueue.forEach((data) => { 3454 if (data && data.id != null) { 3455 const mid = ccNormalizeChatMessageId(data.id); 3456 window._wsMsgPendingIds.delete(mid || data.id); 3457 if (window.processedAllMessageIds && mid) window.processedAllMessageIds.delete(mid); 3458 } 3459 }); 3460 _wsRenderQueue = []; 3461 if (typeof queuePollAfterSend === 'function') queuePollAfterSend(150); 3462 return; 3463 } 3464 const catchUp = !!(window._ccCatchUpBusy || window._tabWasHidden
3464); 3465 const chunkSize = catchUp 3466 ? _WS_FLUSH_CHUNK_CATCHUP 3467 : (document.hidden ? _WS_FLUSH_CHUNK_HIDDEN : _WS_FLUSH_CHUNK_VISIBLE); 3468 const batch = _wsRenderQueue.splice(0, Math.min(_wsRenderQueue.length, chunkSize)); 3469 const fragsByRoom = new Map(); 3470 const tmp = document.createElement('div'); 3471 let newLastId = null; 3472 const followTail = ccShouldFollowChatTail(); 3473 const focusedRoom = (window.currentRoomId != null ? window.currentRoomId : 1) | 0; 3474 function fragForRoom(rid) { 3475 rid = rid | 0; 3476 if (!fragsByRoom.has(rid)) fragsByRoom.set(rid, document.createDocumentFragment()); 3477 return fragsByRoom.get(rid); 3478 } 3479 for (const data of batch) { 3480 const mid = ccNormalizeChatMessageId(data.id); 3481 // Flush owns the claim â only skip when already painted/processed. 3482 // Do not treat "claimed" as skip or we would drop every queued message. 3483 if (mid && typeof ccChatHasMessageRow === 'function' && ccChatHasMessageRow(mid)) { 3484 if (typeof ccDedupeChatMessageRows === 'function') ccDedupeChatMessageRows(mid); 3485 if (typeof ccMarkChatMessageProcessed === 'function') ccMarkChatMessageProcessed(mid); 3486 window._wsMsgPendingIds.delete(mid); 3487 continue; 3488 } 3489 if (mid && typeof ccIsChatMessageProcessed === 'function' && ccIsChatMessageProcessed(mid)) { 3490 window._wsMsgPendingIds.delete(mid); 3491 continue; 3492 } 3493 if (window.me && ccUsernameEquals(data.username, window.me.username) && data.type !== 'whisper' && data.type !== 'system') { 3494 if (ccTryUpgradeLocalEchoToMessage(data)) { 3495 window._wsMsgPendingIds.delete(mid); 3496 continue; 3497 } 3498 if (!ccChatHasMessageRow(mid)) ccRemoveLocalChatEchoForOwnMessage(data); 3499 } 3500 if (typeof window.ccAbsorbDuplicateChatLine === 'function' && window.ccAbsorbDuplicateChatLine(data)) { 3501 window._wsMsgPendingIds.delete(mid); 3502 continue; 3503 } 3504 if (data && data.username && data.type !== 'system' && data.type !== 'whisper') { 3505 try { 3506 if (typeof window._ccEnsureChatSpeakerOnRoster === 'function') { 3507 window._ccEnsureChatSpeakerOnRoster(data.username); 3508 } 3509 } catch (_) {} 3510 } 3511 if (data.type === 'system' && typeof data.text === 'string' && (data.text.includes('[[YT:play') || data.text.includes('[[YT:queued'))) { 3512 tryHandleYouTubeSystemDirectives(data.text); 3513 } 3514 if ((data.text && String(data.text).indexOf('[[DIRECTIVE]]') !== -1) 3515 || (data.message && String(data.message).indexOf('[[DIRECTIVE]]') !== -1)) { 3516 if (mid && typeof ccMarkChatMessageProcessed === 'function') ccMarkChatMessageProcessed(mid); 3517 else if (mid) { 3518 if (!window.processedAllMessageIds) window.processedAllMessageIds = new Set(); 3519 window.processedAllMessageIds.add(mid); 3520 } 3521 window._wsMsgPendingIds.delete(mid || data.id); 3522 if (typeof ccStripDirectiveRowsFromChat === 'function') { 3523 try { ccStripDirectiveRowsFromChat(); } catch (_) {} 3524 } 3525 continue; 3526 } 3527 let html = ''; 3528 let skipJoinLeaveDup = false; 3529 if (data.type === 'system' && data.text 3530 && typeof window.ccTextLooksLikeInternalWatchAnnouncement === 'function' 3531 && window.ccTextLooksLikeInternalWatchAnnouncement(data.text)) { 3532 skipJoinLeaveDup = true; 3533 } 3534 if (data.type === 'system' && data.text && ccIsJoinLeaveAnnouncementText(data.text)) { 3535 const normAnn = data.text.trim().toLowerCase(); 3536 window._ccRecentJoinLeaveLines = window._ccRecentJoinLeaveLines || new Map(); 3537 const seenAnn = window._ccRecentJoinLeaveLines.get(normAnn); 3538 const prematureJoin = (typeof ccIsLeaveAnnouncementText === 'function' && !ccIsLeaveAnnouncementText(data.text)) 3539 && (typeof _ccParseJoinRoomName === 'function') 3540 && (function () { 3541 const whoJoin = _ccParseJoinRoomName(data.text); 3542 if (!whoJoin) return false;
3543 const left = typeof window._ccUserExplicitlyLeft === 'function' && window._ccUserExplicitlyLeft(whoJoin); 3544 if (left) return false; 3545 if (typeof window._ccUserListHasName !== 'function' || !window._ccUserListHasName(whoJoin)) return false; 3546 const lc = String(whoJoin).toLowerCase(); 3547 const at = window._ccPresenceJoinAt && window._ccPresenceJoinAt[lc]; 3548 if (!at || (Date.now() - at) < 2500) return false; 3549 return true; 3550 })(); 3551 if (prematureJoin) skipJoinLeaveDup = true; 3552 else if (seenAnn && Date.now() - seenAnn < 4000) skipJoinLeaveDup = true; 3553 else if (typeof window._ccJoinLeaveKindRecentlyShown === 'function') { 3554 const isLeave = typeof ccIsLeaveAnnouncementText === 'function' && ccIsLeaveAnnouncementText(data.text); 3555 const who = isLeave 3556 ? ((typeof _ccParseLeftRoomUsername === 'function') ? _ccParseLeftRoomUsername(data.text) : '') 3557 : ((typeof _ccParseJoinRoomName === 'function') ? _ccParseJoinRoomName(data.text) : ''); 3558 const ttl = isLeave ? (30 * 60 * 1000) : 8000; 3559 if (who && window._ccJoinLeaveKindRecentlyShown(who, isLeave ? 'leave' : 'join', ttl)) { 3560 skipJoinLeaveDup = true; 3561 } 3562 } 3563 if (!skipJoinLeaveDup) window._ccRecentJoinLeaveLines.set(normAnn, Date.now()); 3564 } 3565 if (!skipJoinLeaveDup && data.type === 'system' && typeof window.ccChatAlreadyHasCamStart === 'function' 3566 && window.ccChatAlreadyHasCamStart(data.text || '')) { 3567 if (mid && typeof ccMarkChatMessageProcessed === 'function') ccMarkChatMessageProcessed(mid); 3568 if (mid) window._wsMsgPendingIds.delete(mid); 3569 continue; 3570 } 3571 if (!skipJoinLeaveDup) { 3572 try { 3573 html = typeof rowHTML === 'function' ? rowHTML(data) : ''; 3574 } catch (_) { 3575 html = ''; 3576 } 3577 } 3578 if (!html && data.type === 'system' && data.text && !skipJoinLeaveDup) { 3579 const watchLine = typeof window.ccTextLooksLikeInternalWatchAnnouncement === 'function' 3580 && window.ccTextLooksLikeInternalWatchAnnouncement(data.text); 3581 const banKickLine = /\bhas (banned|kicked)\b/i.test(data.text); 3582 const wpJoinLeaveHidden = typeof window.ccShouldSuppressWatchPartyJoinLeaveRow === 'function' 3583 && window.ccShouldSuppressWatchPartyJoinLeaveRow(data); 3584 // rowHTML already hid watch/dup-ban copies â do not re-paint them 3585 // without the bell via this last-resort path. 3586 if (!watchLine && !wpJoinLeaveHidden && !(banKickLine && typeof rowHTML === 'function')) { 3587 const userHid = typeof window.ccUserHidThisAnnouncement === 'function' 3588 && window.ccUserHidThisAnnouncement(data); 3589 if (!userHid && typeof window.ccPaintLiveSystemAnnouncement === 'function') { 3590 try { 3591 if (window.ccPaintLiveSystemAnnouncement(data)) { 3592 html = ' '; 3593 } 3594 } catch (_) {} 3595 } 3596 if ((!html || html === '') && !userHid) { 3597 html = _ccFallbackSystemAnnouncementHtml(data); 3598 } 3599 } 3600 } 3601 if (html) { 3602 const paintInfo = (typeof window.ccWpLobbyPaintRoom === 'function') 3603 ? window.ccWpLobbyPaintRoom(data, focusedRoom) 3604 : { 3605 rid: (typeof window.ccRoomIdOfMessage === 'function') 3606 ? (window.ccRoomIdOfMessage(data) | 0) 3607 : focusedRoom, 3608 lobbyMirror: false 3609 }; 3610 const paintRid = paintInfo.rid | 0; 3611 tmp.innerHTML = html; 3612 while (tmp.firstChild) { 3613 const paintNode = tmp.firstChild; 3614 if (paintNode && paintNode.nodeType === 1) { 3615 paintNode.setAttribute('data-cc-chat-room', String(paintRid || 1)); 3616 } 3617 if (paintInfo.lobbyMirror && typeof window.ccWpMarkLobbyMirrorNode === 'function') { 3618 window.ccWpMarkLobbyMirrorNode(paintNode); 3619 } 3620 if (typeof window.ccStampChatLineRow === 'function') window.ccStampChatLineRow(paintNode, data); 3621 fragForRoom(paintRid).appendChild(paintNode); 3622 } 3623 if (paintRid === focusedRoom) newLastId = mid || data.id; 3624 if (paintRid !== focusedRoom && typeof window.ccNoteRoomUnread === 'function') { 3625 try { window.ccNoteRoomUnread(paintRid, data); } catch (_) {} 3626 } 3627 if (mid && typeof ccDedupeChatMessageRows === 'function') { 3628 // Collapse any echo/poll twin that snuck in before this append. 3629 try { ccDedupeChatMessageRows(mid); } catch (_) {} 3630 } 3631 } 3632 if (data.type === 'system' && data.text && typeof loadUsers === 'function') { 3633 const hideJoinLeave = typeof ccShouldHideJoinLeaveAnnouncement === 'function' && ccShouldHideJoinLeaveAnnouncement(data); 3634 if (!hideJoinLeave && data.text && ccIsJoinAnnouncementText(data.text)) { 3635 if (_ccIsRosterEventForCurrentRoom(data) && typeof _ccHandleUserJoinedRoom === 'function') { 3636 _ccHandleUserJoinedRoom(data.text, { skipAnnounce: true, message: data }); 3637 } 3638 // Play even when the join row is suppressed/deduped or the tab is 3639 // backgrounded â autoplay retry will flush on next gesture/focus. 3640 if (typeof _ccPlayPlatinumWelcomeForJoinMessage === 'function' 3641 && (html || (data && data.platinum_welcome)) 3642 && _ccIsRosterEventForCurrentRoom(data)) { 3643 _ccPlayPlatinumWelcomeForJoinMessage(data.text, data); 3644 } 3645 } 3646 if (!hideJoinLeave && data.text && ccIsLeaveAnnouncementText(data.text)) { 3647 if (_ccIsRosterEventForCurrentRoom(data)) { 3648 if (typeof _ccHandleUserLeftRoom === 'function') _ccHandleUserLeftRoom(data.text, { skipAnnounce: true, sameRoom: true }); 3649 } else if (typeof _ccParseLeftRoomUsername === 'function' && typeof window._ccUndockUserImmediately === 'function') { 3650 const leftName = _ccParseLeftRoomUsername(data.text); 3651 if (leftName) window._ccUndockUserImmediately(leftName, 'user_left'); 3652 } 3653 } 3654 if (typeof ccApplyPollSystemMessageSideEffects === 'function') { 3655 ccApplyPollSystemMessageSideEffects(data); 3656 } 3657 } 3658 if (data.type === 'whisper' && typeof routeWhisper === 'function') routeWhisper(data); 3659 if (data.id != null && data.id !== '') { 3660 const pendingId = mid || ccNormalizeChatMessageId(data.id); 3661 window._wsMsgPendingIds.delete(pendingId); 3662 if (!window.processedAllMessageIds) window.processedAllMessageIds = new Set(); 3663 const whisperHandled = data.type === 'whisper'; 3664 if (html || whisperHandled) { 3665 if (pendingId) window.processedAllMessageIds.add(pendingId);
3666 // Background tab title badge â main chat rows + PMs (Epik-style "(N) â¦"). 3667 if (typeof window.ccNotifyTabUnreadMessage === 'function') { 3668 try { window.ccNotifyTabUnreadMessage(data); } catch (_) {} 3669 } 3670 } else { 3671 if (pendingId) window.processedAllMessageIds.delete(pendingId); 3672 if (typeof queuePollAfterSend === 'function') queuePollAfterSend(80); 3673 } 3674 } 3675 } 3676 fragsByRoom.forEach(function (frag, rid) { 3677 if (!frag.childNodes.length) return; 3678 const target = (typeof window.ccRoomChatEl === 'function') 3679 ? window.ccRoomChatEl(rid) 3680 : ((rid | 0) === focusedRoom ? chatEl : null); 3681 if (!target) return; 3682 target.appendChild(frag); 3683 if (typeof window.wireTriviaImagesIn === 'function') window.wireTriviaImagesIn(target); 3684 const isVisibleChat = !!(chatEl && (target === chatEl || target.id === 'chat')); 3685 if (isVisibleChat) { 3686 if ((rid | 0) === focusedRoom && newLastId != null && typeof lastId !== 'undefined') lastId = newLastId; 3687 if (followTail && typeof ccScheduleChatScrollToBottom === 'function') ccScheduleChatScrollToBottom(chatEl); 3688 else if (followTail) chatEl.scrollTop = chatEl.scrollHeight; 3689 if (typeof schedulePrune === 'function') schedulePrune(); 3690 if (typeof ccLiteModeTrimChatEl === 'function') ccLiteModeTrimChatEl(chatEl); 3691 if (typeof ccScheduleAnnouncementFadesIn === 'function') ccScheduleAnnouncementFadesIn(chatEl); 3692 if (typeof window.ccPruneLiveGameAnnouncements === 'function') { 3693 try { window.ccPruneLiveGameAnnouncements(chatEl); } catch (_) {} 3694 } 3695 } else if (typeof ccLiteModeTrimChatEl === 'function') { 3696 try { ccLiteModeTrimChatEl(target); } catch (_) {} 3697 } 3698 }); 3699 try { 3700 batch.forEach(function (d) { 3701 const did = ccNormalizeChatMessageId(d && d.id); 3702 if (!did) return; 3703 if (typeof ccChatRememberMessageRow === 'function') { 3704 const host = document.getElementById('ccRoomChatHost') || chatEl; 3705 const el = host && host.querySelector('[data-message-id="' + did + '"]'); 3706 if (el) ccChatRememberMessageRow(did, el); 3707 } 3708 if (typeof ccDedupeChatMessageRows === 'function') ccDedupeChatMessageRows(did); 3709 }); 3710 } catch (_) {} 3711 if (_wsRenderQueue.length || _wsMsgQueue.length) { 3712 if (catchUp) { 3713 setTimeout(function () { _ccScheduleWsFlush(); }, 48); 3714 } else { 3715 _ccScheduleWsFlush(); 3716 } 3717 } 3718 } finally { 3719 _wsMsgFlushing = false; 3720 } 3721 } 3722 window._ccFlushWsChatQueue = _flushWsMsgQueue; 3723 3724 window._ccPushWsChatMessages = function (msgs) { 3725 if (!Array.isArray(msgs) || !msgs.length) return; 3726 for (let i = 0; i < msgs.length; i++) { 3727 const data = msgs[i]; 3728 if (!data) continue; 3729 if (data.type === 'system' && data.text 3730 && typeof window.ccTextLooksLikeInternalWatchAnnouncement === 'function' 3731 && window.ccTextLooksLikeInternalWatchAnnouncement(data.text)) { 3732 continue; 3733 } 3734 if (typeof _ccIsMessageForOpenChatRoom === 'function' && !_ccIsMessageForOpenChatRoom(data) 3735 && data.type !== 'whisper' && data.type !== 'private' && data.type !== 'force_logout') { 3736 const targeted = data.type === 'system' && data.target_user_id != null; 3737 const sitewide = data.type === 'system' && ( 3738 (typeof _ccIsMessageForCurrentRoom === 'function' && _ccIsMessageForCurrentRoom(data)) 3739 || data.broadcast_all_rooms === true 3740 || data.broadcast_all_rooms === 1 3741 || data.broadcast_all_rooms === '1' 3742 || data.announcement_sitewide === true 3743 || (typeof ccIsSitewidePublicAnnouncementText === 'function' 3744 && ccIsSitewidePublicAnnouncementText(data.text)) 3745 ); 3746 if (!targeted && !sitewide) continue; 3747 } 3748 const mid = ccNormalizeChatMessageId(data.id); 3749 if (mid && ccShouldSkipIncomingChatMessage(mid)) continue; 3750 if (!window._wsMsgPendingIds) window._wsMsgPendingIds = new Set(); 3751 if (mid) { 3752 if (typeof ccClaimIncomingChatMessage === 'function') { 3753 if (!ccClaimIncomingChatMessage(mid)) continue; 3754 } else if (window._wsMsgPendingIds.has(mid)) { 3755 continue; 3756 } else { 3757 window._wsMsgPendingIds.add(mid); 3758 } 3759 } else { 3760 window._wsMsgPendingIds.add(data.id); 3761 } 3762 _wsMsgQueue.push(data); 3763 } 3764 if (_wsMsgQueue.length + _wsRenderQueue.length > 80) { 3765 window._ccCapWsRenderQueue(60); 3766 } 3767 var livePush = false;
3768 for (let j = 0; j < msgs.length; j++) { 3769 if (_ccWsIsLiveTypedLine(msgs[j])) { livePush = true; break; } 3770 } 3771 if (livePush) _ccFlushWsChatNow(); 3772 else _ccScheduleWsFlush(); 3773 }; 3774 3775 // User-targeted force logout (global kick/ban). Fires regardless of which chat 3776 // room the victim is currently viewing, because PHP routes this via the Node 3777 // userSocket map in MediaServer/server.js. We reuse rowHTML() / handleForceLogout() 3778 // so the existing modal + countdown + index.html redirect still drives the UI. 3779 _ws.on('user:force_logout', (envelope) => { 3780 try { 3781 if (envelope && envelope.reason === 'geo_flag_refresh' && envelope.username) { 3782 const meName = window.me && window.me.username; 3783 const match = meName && (typeof ccUsernameEquals === 'function' 3784 ? ccUsernameEquals(meName, envelope.username) 3785 : String(meName).toLowerCase() === String(envelope.username).toLowerCase()); 3786 if (match) { 3787 try { 3788 if (typeof api === 'function') api('me').then(function (r) { 3789 if (r && r.ok && r.user) { 3790 window.me = Object.assign({}, window.me || {}, r.user); 3791 window._ccChatSessionLive = true; 3792 } 3793 }).catch(function () {}); 3794 } catch (_) {} 3795 } 3796 return; 3797 } 3798 const msg = (envelope && envelope.message) ? envelope.message : envelope; 3799 if (!msg || !msg.id) return; 3800 if (!window.processedAllMessageIds) window.processedAllMessageIds = new Set(); 3801 if (window.processedAllMessageIds.has(msg.id)) return; 3802 window.processedAllMessageIds.add(msg.id); 3803 // rowHTML handles the force_logout branch (marks as processed, checks target, triggers modal) 3804 if (typeof rowHTML === 'function') { 3805 try { rowHTML(msg); } catch (e) { console.warn('user:force_logout rowHTML error', e); } 3806 } 3807 ccMaybeHandleForceLogoutMessage(msg); 3808 } catch (err) { 3809 console.error('user:force_logout handler error', err); 3810 } 3811 }); 3812 3813 _ws.on('votekick:update', (payload) => { 3814 try { 3815 if (typeof window.ccVotekickApply === 'function') window.ccVotekickApply(payload); 3816 } catch (err) { 3817 console.error('votekick:update handler error', err); 3818 } 3819 }); 3820 3821 // Staff warning: gold chat line for everyone; popup only for the warned user. 3822 _ws.on('user:warned', (payload) => { 3823 try { 3824 if (!payload) return; 3825 const uname = payload.username || ''; 3826 const meName = window.me && window.me.username; 3827 const isMe = !!(meName && uname && (typeof ccUsernameEquals === 'function' 3828 ? ccUsernameEquals(meName, uname) 3829 : String(meName).toLowerCase() === String(uname).toLowerCase())); 3830 if (typeof window.ccPaintStaffWarnAnnouncement === 'function') { 3831 try { window.ccPaintStaffWarnAnnouncement(payload); } catch (_) {} 3832 } 3833 if (isMe && typeof ccNotifyUserWarned === 'function') { 3834 ccNotifyUserWarned(payload); 3835 } 3836 } catch (err) { 3837 console.error('user:warned handler error', err); 3838 } 3839 }); 3840 3841 // Structured jail apply â do not wait for chat text parse or moderation poll. 3842 _ws.on('user:jailed', (payload) => { 3843 try { 3844 if (!payload) return; 3845 const uname = payload.username || ''; 3846 const meName = window.me && window.me.username; 3847 const isMe = !!(meName && uname && (typeof ccUsernameEquals === 'function' 3848 ? ccUsernameEquals(meName, uname) 3849 : String(meName).toLowerCase() === String(uname).toLowerCase())); 3850 const jailInfo = { 3851 reason: payload.reason || 'No reason specified', 3852 expires_at: payload.expires_at || null, 3853 remaining_time: payload.remaining_time != null 3854 ? payload.remaining_time 3855 : (payload.duration_seconds > 0 ? payload.duration_seconds : null), 3856 }; 3857 if (isMe && typeof ccNotifyUserJailed === 'function') { 3858 ccNotifyUserJailed(jailInfo); 3859 if (typeof window.ccEnforceJailMediaAndGames === 'function') { 3860 try { window.ccEnforceJailMediaAndGames(); } catch (_) {} 3861 } 3862 if (typeof applyCapabilityGates === 'function') { 3863 try { applyCapabilityGates(); } catch (_) {} 3864 } 3865 } 3866 if (uname && typeof window._ccApplyUserJailBadge === 'function') { 3867 let expMs = null; 3868 if (payload.indefinite) expMs = null; 3869 else if (payload.expires_at) { 3870 const parsed = Date.parse(String(payload.expires_at).replace(' ', 'T') + (/[zZ]|[+-]\d{2}:?\d{2}$/.test(String(payload.expires_at)) ? '' : 'Z')); 3871 expMs = Number.isFinite(parsed) ? parsed : null; 3872 } else if (payload.duration_seconds > 0) { 3873 expMs = Date.now() + (Number(payload.duration_seconds) * 1000); 3874 } 3875 window._ccApplyUserJailBadge(uname, payload.indefinite ? null : expMs); 3876 } 3877 if (uname && typeof window._ccApplyRosterUserBadgeFields === 'function') { 3878 try { 3879 window._ccApplyRosterUserBadgeFields(uname, { 3880 is_jailed: 1, 3881 jail_expires_at: payload.expires_at || null 3882 }); 3883 } catch (_) {} 3884 } 3885 } catch (err) { 3886 console.error('user:jailed handler error', err); 3887 } 3888 }); 3889 3890 // Early unjail â clear *jailed* badge immediately (do not wait for roster poll). 3891 _ws.on('user:unjailed', (payload) => {
3892 try { 3893 if (!payload) return; 3894 const uname = payload.username || ''; 3895 if (!uname) return; 3896 const meName = window.me && window.me.username; 3897 const isMe = !!(meName && (typeof ccUsernameEquals === 'function' 3898 ? ccUsernameEquals(meName, uname) 3899 : String(meName).toLowerCase() === String(uname).toLowerCase())); 3900 if (typeof window._ccClearUserJailBadge === 'function') { 3901 window._ccClearUserJailBadge(uname); 3902 } 3903 if (typeof window._ccApplyRosterUserBadgeFields === 'function') { 3904 try { window._ccApplyRosterUserBadgeFields(uname, { is_jailed: 0, jail_expires_at: '' }); } catch (_) {} 3905 } 3906 if (isMe) { 3907 try { window._ccJailAppliedAt = 0; } catch (_) {} 3908 try { window._ccJailUnlockGraceUntil = 0; } catch (_) {} 3909 if (typeof ccFinishJailSentence === 'function') { 3910 ccFinishJailSentence({ silent: true }); 3911 } 3912 if (typeof applyCapabilityGates === 'function') { 3913 try { applyCapabilityGates(); } catch (_) {} 3914 } 3915 } 3916 } catch (err) { 3917 console.error('user:unjailed handler error', err); 3918 } 3919 }); 3920 3921 // Kick bailout / early release â swap kick modal to "bailed out, refresh". 3922 // Strict username match only (never show for linked mains/alts). 3923 _ws.on('user:bailed_out', (payload) => { 3924 try { 3925 if (!payload) return; 3926 const uname = payload.username || ''; 3927 if (!uname) return; 3928 const meName = window.me && window.me.username; 3929 const isMe = !!(meName && (typeof ccUsernameEquals === 'function' 3930 ? ccUsernameEquals(meName, uname) 3931 : String(meName).toLowerCase() === String(uname).toLowerCase())); 3932 if (!isMe) return; 3933 // Only the kicked account's kick UI should flip â never interrupt a 3934 // linked main who is still chatting (Outis while Ruthless is bailed). 3935 const onKickUi = !!(window.CcModerationPopup 3936 && typeof window.CcModerationPopup.isKickOverlayActive === 'function' 3937 && window.CcModerationPopup.isKickOverlayActive()) 3938 || !!document.getElementById('cc-ban-popup-overlay'); 3939 if (!onKickUi) return; 3940 try { 3941 window.dispatchEvent(new CustomEvent('cc:user-bailed-out', { 3942 detail: { 3943 username: uname, 3944 by: payload.by || '', 3945 kind: payload.kind || 'kick', 3946 source: payload.source || '' 3947 } 3948 })); 3949 } catch (_) {} 3950 if (window.CcModerationPopup && typeof window.CcModerationPopup.renderBailedOutOverlay === 'function') { 3951 window.CcModerationPopup.renderBailedOutOverlay({ by: payload.by || '' }); 3952 } else if (window.CcModerationPopup && typeof window.CcModerationPopup.checkBan === 'function') { 3953 window.CcModerationPopup.checkBan(); 3954 } 3955 } catch (err) { 3956 console.error('user:bailed_out handler error', err); 3957 } 3958 }); 3959 3960 _ws.on('chat:poll_nudge', (data) => { 3961 if (!data) return; 3962 const msgRoom = (data.room_id == null || data.room_id === '') ? 1 : (data.room_id | 0) || 1; 3963 const currentRoom = (window.currentRoomId != null ? window.currentRoomId : 1) | 0; 3964 if (msgRoom !== currentRoom) return; 3965 if (typeof queuePollAfterSend === 'function') queuePollAfterSend(80); 3966 else if (typeof poll === 'function') setTimeout(() => { try { poll(); } catch (_) {} }, 80); 3967 }); 3968 3969 _ws.on('trivia:session', (data) => { 3970 if (window.ccChallenges && typeof window.ccChallenges.onTriviaSessionPush === 'function') { 3971 try { window.ccChallenges.onTriviaSessionPush(data || {}); } catch (_) {} 3972 } 3973 if (window.triviaModule && typeof window.triviaModule.onTriviaSessionPush === 'function') { 3974 try { window.triviaModule.onTriviaSessionPush(data || {}); } catch (_) {} 3975 } 3976 }); 3977 3978 _ws.on('rooms:update', (data) => { 3979 if (typeof window.ccApplySharedRoomsUpdate === 'function') { 3980 try { window.ccApplySharedRoomsUpdate(data || {}); } catch (_) {} 3981 } 3982 }); 3983 3984 _ws.on('game:announce', (data) => {
3985 try { 3986 const t = data && (data.text || data.message); 3987 if (t && String(t).indexOf('[[DIRECTIVE]]') !== -1) return; 3988 if (typeof window.ccPaintGameLobbyAnnouncement === 'function') { 3989 window.ccPaintGameLobbyAnnouncement(data || {}); 3990 } 3991 } catch (_) {} 3992 }); 3993 3994 _ws.on('challenge:game', (data) => { 3995 if (typeof window.ccPaintLiveGameChatMessagesFromPush === 'function') { 3996 try { window.ccPaintLiveGameChatMessagesFromPush(data || {}); } catch (_) {} 3997 } 3998 if (window.ccChallenges && typeof window.ccChallenges.onLiveGamePush === 'function') { 3999 try { window.ccChallenges.onLiveGamePush(data || {}); } catch (_) {} 4000 } 4001 }); 4002 4003 _ws.on('challenge:podium', (data) => { 4004 if (window.ccChallenges && typeof window.ccChallenges.onPodiumPush === 'function') { 4005 try { window.ccChallenges.onPodiumPush(data || {}); } catch (_) {} 4006 } 4007 }); 4008 4009 _ws.on('chat:messages_cleared', (data) => { 4010 try { 4011 if (typeof window.ccApplyMessagesClearedPush === 'function') { 4012 window.ccApplyMessagesClearedPush(data); 4013 } else if (typeof ccApplyClearDirectiveDom === 'function') { 4014 const ids = []; 4015 if (data && data.message_id) ids.push(data.message_id); 4016 if (data && Array.isArray(data.message_ids)) ids.push.apply(ids, data.message_ids); 4017 if (data && Array.isArray(data.removed_ids)) ids.push.apply(ids, data.removed_ids); 4018 ccApplyClearDirectiveDom({ 4019 directive: { 4020 kind: 'clear', 4021 scope: String((data && data.scope) || 'single'), 4022 username: data && data.username, 4023 message_id: data && (data.message_id || ids[0] || null), 4024 removed_ids: ids, 4025 room_id: data && data.room_id 4026 }, 4027 watermarkId: Number((data && (data.watermark_id || data.id)) || 0) || 0 4028 }, data); 4029 } 4030 } catch (_) {} 4031 }); 4032 4033 _ws.on('chat:announcements_removed', (data) => { 4034 if (!data) return; 4035 if (Array.isArray(data.table_ids) && typeof window.ccMarkGameAnnounceTablesMuted === 'function') { 4036 try { window.ccMarkGameAnnounceTablesMuted(data.table_ids, data.scope === 'all'); } catch (_) {} 4037 } 4038 if (data.table_id && typeof window.ccMarkGameAnnounceTablesMuted === 'function') { 4039 try { window.ccMarkGameAnnounceTablesMuted([data.table_id], false); } catch (_) {} 4040 } 4041 if (data.table_id && typeof window.ccRemoveGameCreateAnnouncementsByTableId === 'function') { 4042 try { window.ccRemoveGameCreateAnnouncementsByTableId(data.table_id); } catch (_) {} 4043 } 4044 if (!('room_id' in data) && !Array.isArray(data.message_ids) && data.scope !== 'all') return; 4045 const msgRoom = (data.room_id == null || data.room_id === '') ? 1 : (data.room_id | 0) || 1; 4046 const currentRoom = (window.currentRoomId != null ? window.currentRoomId : 1) | 0; 4047 const allRooms = data.broadcast_all_rooms === true || data.broadcast_all_rooms === 1 || !!data.table_id; 4048 if (!allRooms && msgRoom !== currentRoom) return; 4049 if (data.scope === 'all') { 4050 removeAllAnnouncementsLocally(); 4051 } else if (Array.isArray(data.message_ids)) { 4052 data.message_ids.forEach((id) => removeAnnouncementLocally(String(id))); 4053 if (typeof window.ccPruneLiveGameAnnouncements === 'function') { 4054 try { window.ccPruneLiveGameAnnouncements(); } catch (_) {} 4055 } 4056 } 4057 }); 4058 4059 _ws.on('game:announcements_cleared', (data) => { 4060 try { 4061 const ids = (data && (data.table_ids || data.tableIds)) || []; 4062 if (typeof window.ccMarkGameAnnounceTablesMuted === 'function') { 4063 window.ccMarkGameAnnounceTablesMuted(ids, !!(data && data.mute_all_known)); 4064 } 4065 if (typeof window.removeAllAnnouncementsLocally === 'function' && data && data.mute_all_known) { 4066 window.removeAllAnnouncementsLocally(); 4067 } else { 4068 ids.forEach(function (tid) { 4069 if (tid && typeof window.ccRemoveGameCreateAnnouncementsByTableId === 'function') { 4070 window.ccRemoveGameCreateAnnouncementsByTableId(tid); 4071 } 4072 }); 4073 } 4074 if (typeof window.ccPruneLiveGameAnnouncements === 'function') { 4075 window.ccPruneLiveGameAnnouncements(); 4076 } 4077 } catch (_) {} 4078 }); 4079 4080 _ws.on('game:lobby_started', (data) => {
4081 try { 4082 const tid = data && (data.table_id || data.tableId); 4083 if (tid && typeof window.ccRemoveGameCreateAnnouncementsByTableId === 'function') { 4084 window.ccRemoveGameCreateAnnouncementsByTableId(tid); 4085 } 4086 } catch (_) {} 4087 }); 4088 4089 socket._ccChatMsgHandlerLive = true; 4090 function _ccSafeOnWsChatMessage(data) { 4091 try { 4092 _ccOnWsChatMessage(data); 4093 } catch (e) { 4094 console.warn('[WS] chat message handler error', e); 4095 const t = data && (data.text || data.message); 4096 if (t && String(t).indexOf('[[DIRECTIVE]]') !== -1) return; 4097 if (data && data.type === 'system' && data.id && data.text) { 4098 try { 4099 if (!window._wsMsgPendingIds) window._wsMsgPendingIds = new Set(); 4100 _wsMsgQueue.push(data); 4101 _ccScheduleWsFlush(); 4102 } catch (_) {} 4103 } 4104 } 4105 } 4106 _ws.on('chat:new_message', _ccSafeOnWsChatMessage); 4107 _ws.on('chat:private_message', _ccSafeOnWsChatMessage); 4108 4109 var earlyBuf = window._ccEarlyWsMsgBuffer; 4110 if (earlyBuf && earlyBuf.length) { 4111 window._ccEarlyWsMsgBuffer = []; 4112 earlyBuf.forEach(function (msg) { 4113 try { _ccSafeOnWsChatMessage(msg); } catch (e) { console.warn('[WS] early message flush failed:', e); } 4114 }); 4115 } 4116 4117 function _ccOnWsChatMessage(data) { 4118 if (!data) return; 4119 const directiveText = String(data.text || data.message || ''); 4120 if (directiveText.indexOf('[[DIRECTIVE]]') !== -1) { 4121 try { 4122 const parsed = (typeof _ccParseClearDirective === 'function') ? _ccParseClearDirective(data) : null; 4123 if (parsed) { 4124 if (typeof _ccApplyClearDirectiveState === 'function') _ccApplyClearDirectiveState(parsed); 4125 if (typeof ccApplyClearDirectiveDom === 'function') ccApplyClearDirectiveDom(parsed, data); 4126 else { 4127 const directive = parsed.directive || {}; 4128 const wm = parsed.watermarkId; 4129 if (directive.scope === 'all' && typeof clearChatLocally === 'function') clearChatLocally(wm); 4130 else if (directive.scope === 'user' && directive.username && typeof clearUserMessagesLocally === 'function') { 4131 clearUserMessagesLocally(directive.username, wm); 4132 } else if (directive.scope === 'single' && directive.message_id && typeof clearSingleMessageLocally === 'function') { 4133 clearSingleMessageLocally(String(directive.message_id), directive.message_text || null); 4134 } 4135 } 4136 } 4137 } catch (_) {} 4138 if (typeof ccStripDirectiveRowsFromChat === 'function') { 4139 try { ccStripDirectiveRowsFromChat(); } catch (_) {} 4140 } 4141 return; 4142 } 4143 if (typeof ccIsDeployUpdateAnnouncementText === 'function' 4144 && ccIsDeployUpdateAnnouncementText(String(data.text || data.message || ''))) { 4145 var paintedDeploy = false; 4146 try { 4147 if (typeof window.ccPaintDeployUpdateAnnouncement === 'function') { 4148 paintedDeploy = !!window.ccPaintDeployUpdateAnnouncement(data); 4149 } 4150 } catch (_) {} 4151 if (paintedDeploy) return; 4152 } 4153 if (data.type === 'system' && data.text 4154 && typeof window.ccTextLooksLikeInternalWatchAnnouncement === 'function' 4155 && window.ccTextLooksLikeInternalWatchAnnouncement(data.text)) { 4156 return; 4157 } 4158 if (data.type === 'system' && data.text && _ccIsGameLobbyAnnouncement(data) 4159 && typeof window.ccPaintGameLobbyAnnouncement === 'function') { 4160 try { window.ccPaintGameLobbyAnnouncement(data); } catch (_) {} 4161 try { 4162 const mid = (typeof ccNormalizeChatMessageId === 'function') ? ccNormalizeChatMessageId(data.id) : 0; 4163 if (mid) { 4164 if (typeof ccMarkChatMessageProcessed === 'function') ccMarkChatMessageProcessed(mid); 4165 else { 4166 if (!window.processedAllMessageIds) window.processedAllMessageIds = new Set(); 4167 window.processedAllMessageIds.add(mid); 4168 } 4169 } 4170 } catch (_) {} 4171 return; 4172 } 4173 if (!data.id && data.type !== 'system') return; 4174 // Keep processing while backgrounded (second screen / unfocused window) so 4175 // chat stays live â only skip noisy welcome sounds when hidden. 4176 if (data.silent_mute || data.silent_mute_staff_only) { 4177 const myId = window.me && window.me.id;
4178 const isSender = myId != null && Number(data.user_id) === Number(myId); 4179 const staffSees = (typeof hasSilentMutePermission === 'function' && hasSilentMutePermission()) 4180 || (typeof hasAdminPanelAccess === 'function' && hasAdminPanelAccess()); 4181 if (!isSender && !staffSees) return; 4182 } 4183 if (false && (data.guest_staff_only || data.guest_pending_verify)) { 4184 const myId = window.me && window.me.id; 4185 const isSender = myId != null && Number(data.user_id) === Number(myId); 4186 const staffSees = (typeof ccViewerSeesPendingGuests === 'function' && ccViewerSeesPendingGuests()) 4187 || (typeof ccUserIsModeratorTierRole === 'function' && ccUserIsModeratorTierRole((window.me && window.me.role) || '')); 4188 if (!isSender && !staffSees) return; 4189 } 4190 if (data.username && data.type !== 'system' && data.type !== 'force_logout') { 4191 const myId = window.me && window.me.id; 4192 const isIncomingWhisper = (data.type === 'whisper' || data.type === 'private') 4193 && myId != null && Number(data.user_id) !== Number(myId); 4194 const blockWhisper = isIncomingWhisper 4195 && typeof ccShouldBlockIncomingWhisperFrom === 'function' && ccShouldBlockIncomingWhisperFrom(data.username); 4196 const blockChat = data.type !== 'whisper' && data.type !== 'private' 4197 && typeof ccShouldHideUserMessages === 'function' && ccShouldHideUserMessages(data.username); 4198 if (blockWhisper || blockChat) return; 4199 // Anyone on the live socket who chats should already be Online. 4200 // Do not invent roster rows (or force an HTTP users poll) from a chat line. 4201 try { 4202 const chatName = String(data.username || '').trim(); 4203 const chatHidden = typeof window._ccIsPublisherUserlistHidden === 'function' 4204 && window._ccIsPublisherUserlistHidden(chatName); 4205 const chatSelf = typeof window._ccIsSelfRosterName === 'function' 4206 && window._ccIsSelfRosterName(chatName); 4207 if (chatName && chatHidden && !chatSelf) { 4208 try { 4209 if (typeof _ccRemoveUserFromList === 'function') _ccRemoveUserFromList(chatName, { notDeparted: true }); 4210 } catch (_) {} 4211 } 4212 } catch (_) {} 4213 } 4214 // force_logout rows are user-targeted, not room-scoped. Let them through 4215 // even if the current tab is viewing a different room; rowHTML decides 4216 // whether to fire the modal (target match) or swallow the message. 4217 if (data.type === 'force_logout') { 4218 if (!window.processedAllMessageIds) window.processedAllMessageIds = new Set(); 4219 if (window.processedAllMessageIds.has(data.id)) return; 4220 window.processedAllMessageIds.add(data.id); 4221 if (typeof rowHTML === 'function') { try { rowHTML(data); } catch (_) {} } 4222 ccMaybeHandleForceLogoutMessage(data); 4223 return; 4224 } 4225 if (data.type === 'system' && typeof ccMaybeJailedFromSystemMessage === 'function') { 4226 ccMaybeJailedFromSystemMessage(data.text); 4227 } 4228 if (data.type === 'system' && data.text && typeof ccMaybeWarnedFromSystemMessage === 'function') { 4229 try { ccMaybeWarnedFromSystemMessage(data.text); } catch (_) {} 4230 } 4231 if (data.type === 'system' && data.text && typeof window._ccOnWheelShekelNicknameLanding === 'function') { 4232 window._ccOnWheelShekelNicknameLanding(data.text); 4233 } 4234 if (data.type === 'system' && typeof window.ccCelebrateShekelSend === 'function') { 4235 window.ccCelebrateShekelSend(data); 4236 } 4237 if (data.type === 'system' && data.text && typeof _ccIsLiveGameChallengesMessage === 'function' && _ccIsLiveGameChallengesMessage(data.text)) { 4238 if (window.ccChallenges && typeof window.ccChallenges.onLiveGameMessageHint === 'function') { 4239 try { window.ccChallenges.onLiveGameMessageHint(); } catch (_) {} 4240 } 4241 } 4242 if (data.type === 'system' && data.text && typeof _ccIsJailSystemMessage === 'function' && _ccIsJailSystemMessage(data.text)) { 4243 // Instant *jailed* badge + self red timer (do not wait on users API). 4244 try { 4245 if (typeof _ccApplyJailBadgesFromSystemMessage === 'function') { 4246 _ccApplyJailBadgesFromSystemMessage(data.text); 4247 } 4248 } catch (_) {} 4249 try { 4250 if (typeof ccMaybeJailedFromSystemMessage === 'function') { 4251 ccMaybeJailedFromSystemMessage(data.text); 4252 } 4253 } catch (_) {} 4254 } 4255 const isLiveGameWs = data.type === 'system' && data.text && ( 4256 (typeof ccIsLiveGameAnnouncementText === 'function' && ccIsLiveGameAnnouncementText(data.text)) 4257 || (typeof _ccIsLiveGameChallengesMessage === 'function' && _ccIsLiveGameChallengesMessage(data.text)) 4258 || /LIVE GAMES!!/i.test(data.text) 4259 || String(data.text).indexOf('js-open-challenges') !== -1 4260 ); 4261 const isGameLobbyWs = data.type === 'system' && data.text && _ccIsGameLobbyAnnouncement(data); 4262 const hideLiveGameWs = isLiveGameWs && ( 4263 (typeof ccUserExplicitlyHidLiveGame === 'function') 4264 ? ccUserExplicitlyHidLiveGame() 4265 : ((typeof ccFeatureOffIs === 'function' && (ccFeatureOffIs('ann_livegame') || ccFeatureOffIs('ann_all'))) 4266 || (typeof localStorage !== 'undefined' && ( 4267 localStorage.getItem('cc-pref-hideLiveGame') === 'true' 4268 || localStorage.getItem('cc-pref-showChatAnnouncements') === 'false' 4269 ))) 4270 ); 4271 const isStaffWarnWs = data.type === 'system' && data.text 4272 && typeof ccIsStaffWarnAnnouncementText === 'function' 4273 && ccIsStaffWarnAnnouncementText(data.text); 4274 const isForceMuteJailWs = data.type === 'system' && data.text 4275 && typeof ccIsForceMuteMentionJailAnnouncementText === 'function' 4276 && ccIsForceMuteMentionJailAnnouncementText(data.text); 4277 const hideKindWs = !isLiveGameWs && !isGameLobbyWs && !isStaffWarnWs && !isForceMuteJailWs && data.type === 'system' && data.text 4278 && typeof ccShouldHideAnnouncementKind === 'function' 4279 && ccShouldHideAnnouncementKind(data); 4280 const hideAllAnnWs = !isLiveGameWs && !isGameLobbyWs && !isStaffWarnWs && !isForceMuteJailWs && data.type === 'system' && data.text 4281 && typeof ccShouldHideAllChatAnnouncements === 'function' 4282 && ccShouldHideAllChatAnnouncements(data); 4283 const hideJoinLeaveWs = data.type === 'system' && data.text 4284 && !_ccIsGameLobbyAnnouncement(data) 4285 && typeof ccShouldHideJoinLeaveAnnouncement === 'function' && ccShouldHideJoinLeaveAnnouncement(data); 4286 const hideSpotifyNpWs = data.type === 'system' && data.text 4287 && typeof ccShouldHideSpotifyNowPlayingAnnouncement === 'function' 4288 && ccShouldHideSpotifyNowPlayingAnnouncement(data); 4289 if (data.type === 'system' && data.text && _ccIsRosterEventForCurrentRoom(data)) { 4290 if (data.text && typeof ccIsJoinAnnouncementText === 'function' && ccIsJoinAnnouncementText(data.text) 4291 && typeof _ccHandleUserJoinedRoom === 'function') { 4292 _ccHandleUserJoinedRoom(data.text, { skipAnnounce: true, message: data }); 4293 } 4294 if (data.text && typeof ccIsLeaveAnnouncementText === 'function' && ccIsLeaveAnnouncementText(data.text) 4295 && typeof _ccHandleUserLeftRoom === 'function') { 4296 _ccHandleUserLeftRoom(data.text, { skipAnnounce: true, sameRoom: true }); 4297 } 4298 } 4299 if (hideLiveGameWs || hideKindWs || hideAllAnnWs || hideJoinLeaveWs || hideSpotifyNpWs) { 4300 if (hideJoinLeaveWs && data.platinum_welcome && typeof _ccPlayPlatinumWelcomeForJoinMessage === 'function') { 4301 try { _ccPlayPlatinumWelcomeForJoinMessage(data.text, data); } catch (_) {} 4302 } 4303 if (!window.processedAllMessageIds) window.processedAllMessageIds = new Set(); 4304 if (data.id) window.processedAllMessageIds.add(data.id); 4305 return; 4306 } 4307 if (data.type === 'system' && data.text && typeof ccIsJoinLeaveAnnouncementText === 'function' 4308 && ccIsJoinLeaveAnnouncementText(data.text) 4309 && typeof window._ccJoinLeaveKindRecentlyShown === 'function') { 4310 const isLeaveLine = typeof ccIsLeaveAnnouncementText === 'function' && ccIsLeaveAnnouncementText(data.text); 4311 const whoLine = isLeaveLine 4312 ? ((typeof _ccParseLeftRoomUsername === 'function') ? _ccParseLeftRoomUsername(data.text) : '') 4313 : ((typeof _ccParseJoinRoomName === 'function') ? _ccParseJoinRoomName(data.text) : ''); 4314 const ttlLine = isLeaveLine ? (30 * 60 * 1000) : 8000; 4315 if (whoLine && window._ccJoinLeaveKindRecentlyShown(whoLine, isLeaveLine ? 'leave' : 'join', ttlLine)) { 4316 if (!window.processedAllMessageIds) window.processedAllMessageIds = new Set(); 4317 if (data.id) window.processedAllMessageIds.add(data.id); 4318 return; 4319 } 4320 } 4321 if (data.type === 'system' && data.text && _ccIsRosterEventForCurrentRoom(data)) { 4322 if (data.text && data.text.includes('started their camera')) { 4323 const camMatch = data.text.match(/ð¹\s*([^|]+?)\s+has\s+started\s+their\s+camera/i); 4324 if (camMatch && camMatch[1] && typeof window._ccAddUserToListInstant === 'function') { 4325 window._ccAddUserToListInstant(camMatch[1].trim()); 4326 } 4327 } 4328 } 4329 if (!('room_id' in data) && data.type !== 'whisper' && data.type !== 'private' && data.type !== 'force_logout' && data.type !== 'system') { 4330 data.room_id = 1; 4331 } 4332 const isWpOnlyAnn = data.type === 'system' && data.text 4333 && typeof window.ccIsWatchPartyOnlyAnnouncementText === 'function' 4334 && window.ccIsWatchPartyOnlyAnnouncementText(data.text); 4335 const isJoinLeaveAnn = data.type === 'system' && data.text && !isWpOnlyAnn 4336 && ( 4337 (typeof ccIsJoinLeaveAnnouncementText === 'function' && ccIsJoinLeaveAnnouncementText(data.text)) 4338 || /has joined the party/i.test(String(data.text || '')) 4339 ); 4340 const isGlobalRoomAnn = data.type === 'system' && !isJoinLeaveAnn && !isWpOnlyAnn && ( 4341 data.room_id == null || data.room_id === '' 4342 || _ccWsBroadcastAllRooms(data) 4343 || data.announcement_sitewide === true 4344 || data.announcement_sitewide === 1 4345 || data.announcement_sitewide === '1' 4346 || _ccIsGameLobbyAnnouncement(data) 4347 || (typeof ccIsSitewideModerationAnnouncementText === 'function' 4348 && ccIsSitewideModerationAnnouncementText(data.text)) 4349 || (typeof ccIsSitewidePublicAnnouncementText === 'function' 4350 && ccIsSitewidePublicAnnouncementText(data.text)) 4351 ); 4352 const msgRoom = (data.room_id == null || data.room_id === '') ? 1 : (data.room_id | 0) || 1; 4353 const currentRoom = (window.currentRoomId != null ? window.currentRoomId : 1) | 0; 4354 const _ccWsPrivateDelivery = data.type === 'whisper' || data.type === 'private' || data.type === 'force_logout' 4355 || (data.type === 'system' && data.target_user_id != null); 4356 if (!_ccWsPrivateDelivery && !isWpOnlyAnn && !isGlobalRoomAnn && msgRoom !== currentRoom 4357 && !(typeof window.ccIsOpenChatRoom === 'function' && window.ccIsOpenChatRoom(msgRoom))) return; 4358 if (data.type !== 'system' && data.username && typeof window._ccTouchRosterFromChat === 'function') { 4359 const touchName = data.username; 4360 const touchData = data; 4361 setTimeout(function () { 4362 try { window._ccTouchRosterFromChat(touchName, touchData); } catch (_) {} 4363 }, 0); 4364 } 4365 if (data.type === 'whisper') { 4366 if (typeof ccWhisperMessageForMe === 'function' && !ccWhisperMessageForMe(data)) return; 4367 } 4368 if (data.type === 'system' && data.target_user_id != null) { 4369 const myId = window.me && window.me.id;
4370 if (myId != null && Number(data.target_user_id) !== Number(myId)) return; 4371 } 4372 const wsMid = ccNormalizeChatMessageId(data.id); 4373 if (wsMid && ccShouldSkipIncomingChatMessage(wsMid)) { 4374 return; 4375 } 4376 window._lastWsMessageAt = Date.now(); 4377 window._lastWsChatMessageAt = Date.now(); 4378 if (!_ccWsIsLiveTypedLine(data) 4379 && typeof _ccShouldHoldMsgUntilJoin === 'function' && _ccShouldHoldMsgUntilJoin(data)) { 4380 if (_ccHoldMsgUntilJoin(data)) return; 4381 } 4382 if (!window.processedAllMessageIds) window.processedAllMessageIds = new Set(); 4383 if (!window._wsMsgPendingIds) window._wsMsgPendingIds = new Set(); 4384 // Atomic claim â if poll already claimed/painted this id, drop the WS copy. 4385 if (wsMid) { 4386 if (typeof ccClaimIncomingChatMessage === 'function') { 4387 if (!ccClaimIncomingChatMessage(wsMid)) return; 4388 } else if (window._wsMsgPendingIds.has(wsMid)) { 4389 return; 4390 } else { 4391 window._wsMsgPendingIds.add(wsMid); 4392 } 4393 } else { 4394 window._wsMsgPendingIds.add(data.id); 4395 } 4396 4397 _wsMsgQueue.push(data); 4398 if (_ccWsIsLiveTypedLine(data)) _ccFlushWsChatNow(); 4399 else _ccScheduleWsFlush(); 4400 } 4401 4402 // Asylum-style join bootstrap â cams + lean presence before any HTTP seed. 4403 _ws.on('chat:bootstrap', (payload) => { 4404 try { 4405 if (typeof window._ccApplyChatBootstrap === 'function') { 4406 window._ccApplyChatBootstrap(payload); 4407 } else if (payload && Array.isArray(payload.active_webcams) 4408 && typeof window._ccApplyActiveWebcamList === 'function') { 4409 window._ccApplyActiveWebcamList(payload.active_webcams, 'bootstrap'); 4410 } 4411 } catch (_) {} 4412 }); 4413 4414 // ââ Server-authoritative webcam start: add to active set, show roster icon ââ 4415 _ws.on("webcam:started", (data) => { 4416 if (!data || !data.username) return; 4417 const username = data.username; 4418 if (typeof window._ccIsSystemUsername === 'function' && window._ccIsSystemUsername(username)) return; 4419 const mediaHoldStart = !!(data && (data.via === 'media-hold' || data.keepAlive === true)); 4420 // Hidden users must not reappear / auto-open for anyone else. 4421 try { 4422 const meName = (window.me && window.me.username) || window.currentUsername || ''; 4423 const isSelf = meName && String(username).toLowerCase() === String(meName).toLowerCase(); 4424 const hiddenPub = typeof window._ccIsPublisherUserlistHidden === 'function' 4425 && window._ccIsPublisherUserlistHidden(username); 4426 if (hiddenPub && !isSelf) { 4427 const invited = typeof window._ccHasWebcamViewInvite === 'function' 4428 && window._ccHasWebcamViewInvite(username); 4429 if (!invited) { 4430 if (typeof window._ccUndockUserImmediately === 'function') { 4431 try { window._ccUndockUserImmediately(username, 'userlist_hidden'); } catch (_) {} 4432 } 4433 return; 4434 } 4435 try { if (typeof window._ccActiveWebcamAdd === 'function') window._ccActiveWebcamAdd(username); } catch (_) {} 4436 try { 4437 if (typeof window._ccAttachLiveIfDocked === 'function') { 4438 window._ccAttachLiveIfDocked(username, data.via || 'webcam_started'); 4439 } 4440 } catch (_) {} 4441 return; 4442 } 4443 } catch (_) {} 4444 if (typeof window._ccAddUserToListInstant === 'function') { 4445 try { window._ccAddUserToListInstant(username); } catch (_) {} 4446 } 4447 // Cam can light up before WS join patch arrives â force one roster fetch 4448 // when they are still missing from Online (does not invent ghost rows). 4449 try { 4450 if (typeof window._ccEnsureRosterForLiveMediaUser === 'function') { 4451 window._ccEnsureRosterForLiveMediaUser(username); 4452 } 4453 } catch (_) {} 4454 try { if (typeof window._ccActiveWebcamAdd === 'function') window._ccActiveWebcamAdd(username); } catch (_) {} 4455 try { 4456 if (typeof window._ccStashLiveWebcamProducers === 'function') { 4457 window._ccStashLiveWebcamProducers(username, data); 4458 } 4459 } catch (_) {}
4460 try { 4461 if (data.videoOff === true && window.chatCrackWebcam 4462 && typeof window.chatCrackWebcam.setRemoteCameraOffOverlay === 'function') { 4463 window.chatCrackWebcam.setRemoteCameraOffOverlay(username, true, data.videoOffMsg || data.cameraOffMessage || ''); 4464 } else if (data.videoOff === false && window.chatCrackWebcam 4465 && typeof window.chatCrackWebcam.setRemoteCameraOffOverlay === 'function') { 4466 window.chatCrackWebcam.setRemoteCameraOffOverlay(username, false); 4467 } 4468 } catch (_) {} 4469 try { 4470 if (typeof window._ccRevealLiveCamTile === 'function' && data.videoOff === false) { 4471 window._ccRevealLiveCamTile(username, { forceClearSlider: true }); 4472 } 4473 } catch (_) {} 4474 try { 4475 if (window._recentlyStoppedWebcams && typeof window._recentlyStoppedWebcams.delete === 'function') { 4476 window._recentlyStoppedWebcams.delete(String(username).toLowerCase()); 4477 } 4478 } catch (_) {} 4479 // A favourite the viewer closed stays closed for that cam session only. 4480 // After a real cam down (webcam:stopped) and back up, it opens again. 4481 try { 4482 const recamKey = String(username).toLowerCase(); 4483 const offAt = window._ccFavCamOffAt && window._ccFavCamOffAt.get(recamKey); 4484 if (offAt && !mediaHoldStart) { 4485 window._ccFavCamOffAt.delete(recamKey); 4486 const closedAt = window._ccFavClosedAt && window._ccFavClosedAt.get(recamKey); 4487 if (closedAt && closedAt <= offAt && typeof window._ccClearFavouriteCamSuppression === 'function') { 4488 window._ccClearFavouriteCamSuppression(username); 4489 } 4490 } 4491 } catch (_) {} 4492 // Preserve the publisher's lock state: a locked cam must light up ORANGE 4493 // for everyone, not green. Prefer the flag on the event, then the 4494 // last-known persisted/instance lock. 4495 const startedLocked = (typeof data.isLocked === 'boolean') 4496 ? data.isLocked 4497 : (typeof window._ccLastKnownWebcamLock === 'function' ? window._ccLastKnownWebcamLock(username) : false); 4498 let startedVis = data.visibilityMode || 'public'; 4499 try { 4500 const prevRaw = localStorage.getItem('webcam_status_' + username); 4501 if (prevRaw) { 4502 const prev = JSON.parse(prevRaw); 4503 if (!data.visibilityMode && prev.visibilityMode) startedVis = prev.visibilityMode; 4504 } 4505 } catch (_) {} 4506 const showStartedIcon = () => { 4507 try { 4508 const persist = { 4509 isOn: true, 4510 isLocked: !!startedLocked, 4511 visibilityMode: startedVis, 4512 ppvRatePerMin: data.ppvRatePerMin != null ? data.ppvRatePerMin : undefined, 4513 broadcasterOnly: data.broadcasterOnly === true, 4514 timestamp: Date.now(), 4515 serverReceivedAt: Date.now(), 4516 }; 4517 if (typeof window._ccSetRemoteWebcamBroadcasterOnly === 'function') { 4518 try { window._ccSetRemoteWebcamBroadcasterOnly(username, data.broadcasterOnly === true); } catch (_) {} 4519 } 4520 if (typeof window._ccWriteWebcamStatus === 'function') { 4521 window._ccWriteWebcamStatus(username, persist); 4522 } else { 4523 localStorage.setItem('webcam_status_' + username, JSON.stringify(persist)); 4524 } 4525 if (startedLocked && typeof window._ccSetRemoteWebcamLock === 'function') { 4526 window._ccSetRemoteWebcamLock(username, true, true); 4527 } else if (typeof window._ccSetRemoteWebcamLock === 'function') { 4528 window._ccSetRemoteWebcamLock(username, false, true); 4529 } 4530 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.updateWebcamIconForUser === 'function') {
4531 const iconLocked = !!startedLocked; 4532 window.chatCrackWebcam.updateWebcamIconForUser(username, true, iconLocked, 'webcam_started'); 4533 } 4534 } catch (_) {} 4535 }; 4536 showStartedIcon(); 4537 try { 4538 var startedVia = data && data.via ? String(data.via) : ''; 4539 if (!mediaHoldStart && startedVia !== 'media-hold' && startedVia !== 'register-resync' && startedVia !== 'reproduce') { 4540 if (!window._ccCamStartAnnPending) window._ccCamStartAnnPending = {}; 4541 var camAnnKey = String(username).toLowerCase(); 4542 if (!window._ccCamStartAnnPending[camAnnKey]) { 4543 window._ccCamStartAnnPending[camAnnKey] = setTimeout(function () { 4544 delete window._ccCamStartAnnPending[camAnnKey]; 4545 if (typeof window._ccEnsureRemoteWebcamStartAnnouncement === 'function') { 4546 window._ccEnsureRemoteWebcamStartAnnouncement(username, { via: startedVia }); 4547 } 4548 }, 1200); 4549 } 4550 } 4551 } catch (_) {} 4552 try { 4553 // A media-hold / keepAlive resync is exactly when a favourite's cam comes 4554 // back after a publisher reconnect, so skipping it here was leaving the 4555 // tile closed until the next page refresh. Deliberate closes are already 4556 // honoured by the suppression check inside the schedule gate, and an 4557 // open dock is skipped by the docked check, so there is nothing left for 4558 // this gate to protect. 4559 if (typeof _ccScheduleFavouriteAutoOpenWhenInRoom === 'function' && typeof _ccIsFavouriteCam === 'function' && _ccIsFavouriteCam(username)) { 4560 var favOpenOpts = { 4561 aggressive: true, 4562 isLocked: false, 4563 immediate: true, 4564 producers: Array.isArray(data.producers) ? data.producers : undefined, 4565 roomId: data.roomId 4566 }; 4567 if (startedLocked) { 4568 // Invite-lock: favourites must click the blue icon and wait for Accept. 4569 } else if (startedVis === 'public' || startedVis === 'friends_only') { 4570 var favFriend = startedVis === 'public' 4571 || !!(window._ccMyFriendsLc && window._ccMyFriendsLc[String(username).toLowerCase()]); 4572 if (favFriend && typeof window._ccPlayLiveWebcam === 'function') { 4573 try { 4574 window._ccPlayLiveWebcam(username, { 4575 favouriteAutoOpen: true, 4576 instantPaint: true, 4577 forceLive: true, 4578 forceReconnect: false, 4579 granted: startedVis === 'friends_only', 4580 producers: favOpenOpts.producers, 4581 roomId: favOpenOpts.roomId 4582 }); 4583 } catch (_) {} 4584 } 4585 _ccScheduleFavouriteAutoOpenWhenInRoom(username, favOpenOpts); 4586 } 4587 } 4588 // Already-open dock: only heal if the tile is actually black. forceReconnect 4589 // on every webcam:started (including register-resync) flashed live cams. 4590 try { 4591 var dockOpen = !!(window.chatCrackWebcam && window.chatCrackWebcam.dockedWebcams 4592 && (window.chatCrackWebcam.dockedWebcams.has(username) 4593 || window.chatCrackWebcam.dockedWebcams.has(String(username).toLowerCase()))); 4594 if (dockOpen && window.chatCrackWebcam) { 4595 if (typeof window._ccCancelWebcamProducerCloseGrace === 'function') { 4596 window._ccCancelWebcamProducerCloseGrace(username); 4597 } 4598 if (typeof window.chatCrackWebcam.hideStreamDisconnectedOverlay === 'function') { 4599 window.chatCrackWebcam.hideStreamDisconnectedOverlay(username); 4600 } 4601 if (!mediaHoldStart) { 4602 var recovering = typeof window._ccWebcamHasDisconnectOverlay === 'function' 4603 && window._ccWebcamHasDisconnectOverlay(username); 4604 var paintedNow = typeof window._ccDockTileHasPaintedVideo === 'function' 4605 && window._ccDockTileHasPaintedVideo(username); 4606 var gameOpen = typeof window._ccWebcamGameUiOpen === 'function' && window._ccWebcamGameUiOpen(); 4607 var realReproduce = startedVia === 'reproduce' && !paintedNow; 4608 if (typeof window._ccEnsureDockShowsLiveVideo === 'function') { 4609 window._ccEnsureDockShowsLiveVideo(username, { forceLive: realReproduce || recovering || !paintedNow }); 4610 } 4611 if (typeof window.chatCrackWebcam.viewUserWebcam === 'function' 4612 && (!paintedNow || recovering || realReproduce) 4613 && (!gameOpen || !paintedNow || recovering)) { 4614 if (typeof window._ccAttachLiveIfDocked === 'function' && (!paintedNow || recovering || realReproduce)) { 4615 window._ccAttachLiveIfDocked(username, startedVia || 'webcam_started'); 4616 } else { 4617 window.chatCrackWebcam.viewUserWebcam(username, !!startedLocked, { 4618 forceReconnect: !paintedNow || realReproduce, 4619 favouriteAutoOpen: typeof _ccIsFavouriteCam === 'function' && _ccIsFavouriteCam(username), 4620 instantPaint: !paintedNow || realReproduce, 4621 forceLive: true, 4622 keepLastFrame: !!paintedNow && !realReproduce 4623 }); 4624 } 4625 } 4626 } 4627 } 4628 } catch (_) {} 4629 if (typeof window._ccApplyFavouriteDockPlacementScheduled === 'function') { 4630 window._ccApplyFavouriteDockPlacementScheduled(); 4631 } 4632 } catch (_) {} 4633 // User row may not exist yet â retry briefly so the icon appears instantly once they render 4634 const nl = String(username).toLowerCase(); 4635 const hasRow = () => Array.from(document.querySelectorAll('#users .user[data-username]')).some( 4636 el => String(el.getAttribute('data-username') || '').toLowerCase() === nl 4637 ); 4638 if (!hasRow()) { 4639 try { 4640 if (typeof window._ccEnsureRosterForLiveMediaUser === 'function') { 4641 window._ccEnsureRosterForLiveMediaUser(username); 4642 } 4643 } catch (_) {} 4644 let n = 0; 4645 const t = setInterval(() => { 4646 n++; 4647 showStartedIcon(); 4648 if (!hasRow() && n === 6) { 4649 try { 4650 if (typeof window._ccEnsureRosterForLiveMediaUser === 'function') { 4651 window._ccEnsureRosterForLiveMediaUser(username); 4652 } 4653 } catch (_) {} 4654 } 4655 if (hasRow() || n >= 16) clearInterval(t); 4656 }, 150); 4657 } 4658 }); 4659 4660 _ws.on("webcam:producer_changed", (data) => {
4661 try { 4662 if (typeof window._ccOnWebcamProducerChanged === 'function') { 4663 window._ccOnWebcamProducerChanged(data); 4664 } 4665 } catch (_) {} 4666 }); 4667 4668 // ââ Dropped connection: keep the dock, show reconnect overlay. Tab/browser close still uses webcam:stopped. ââ 4669 _ws.on("webcam:connection_lost", (data) => { 4670 if (!data || !data.username) return; 4671 if (typeof window._ccMediaHoldActive === 'function' && window._ccMediaHoldActive()) return; 4672 const username = data.username; 4673 const me = window.currentUsername || (window.me && window.me.username); 4674 if (me && String(username).toLowerCase() === String(me).toLowerCase()) return; 4675 if (typeof window._ccUserExplicitlyLeft === 'function' && window._ccUserExplicitlyLeft(username)) { 4676 if (typeof window._ccUndockUserImmediately === 'function') { 4677 try { window._ccUndockUserImmediately(username, 'user_left'); } catch (_) {} 4678 } 4679 return; 4680 } 4681 if (typeof window._ccScheduleWebcamProducerCloseGrace === 'function') { 4682 window._ccScheduleWebcamProducerCloseGrace(username, { reconnectMs: data.reconnect_ms || 75000 }); 4683 } else if (window.chatCrackWebcam && typeof window.chatCrackWebcam.showStreamDisconnectedState === 'function') { 4684 window.chatCrackWebcam.showStreamDisconnectedState(username, { reconnectMs: data.reconnect_ms || 75000 }); 4685 } 4686 }); 4687 4688 // ââ Server-authoritative webcam stop: undock immediately, no grace period ââ 4689 _ws.on("webcam:stopped", (data) => { 4690 if (!data || !data.username) return; 4691 if (typeof window._ccMediaHoldActive === 'function' && window._ccMediaHoldActive()) return; 4692 const username = data.username; 4693 try { if (typeof window._ccCancelWebcamProducerCloseGrace === 'function') window._ccCancelWebcamProducerCloseGrace(username); } catch (_) {} 4694 // Authoritative: drop from active set so any subsequent re-light attempt fails the gate.
4695 try { if (typeof window._ccActiveWebcamRemove === 'function') window._ccActiveWebcamRemove(username); } catch (_) {} 4696 try { if (typeof window._ccClearRemoteWebcamLock === 'function') window._ccClearRemoteWebcamLock(username); } catch (_) {} 4697 try { 4698 if (window._ccFavouriteAccessSent) window._ccFavouriteAccessSent.delete(String(username).toLowerCase()); 4699 } catch (_) {} 4700 try { if (typeof window._ccCancelFeaturedAutoOpen === 'function') window._ccCancelFeaturedAutoOpen(username); } catch (_) {} 4701 try { 4702 if (typeof window.setWebcamNowPlaying === 'function') window.setWebcamNowPlaying(username, null); 4703 } catch (_) {} 4704 const me = window.currentUsername || (window.me && window.me.username); 4705 if (username === me) return; // don't undock your own webcam 4706 try { 4707 if (!window._ccFavCamOffAt) window._ccFavCamOffAt = new Map(); 4708 window._ccFavCamOffAt.set(String(username).toLowerCase(), Date.now()); 4709 } catch (_) {} 4710 4711 _chatLog(`ð webcam:stopped received for ${username} â undocking immediately`); 4712 4713 if (typeof window._ccUndockUserImmediately === 'function') { 4714 try { window._ccUndockUserImmediately(username, 'webcam_stopped'); } catch (e) { 4715 console.warn('_ccUndockUserImmediately error:', e); 4716 } 4717 return; 4718 } 4719 4720 // Remove docked webcam tile unconditionally 4721 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.removeDockedWebcam === 'function') { 4722 try { window.chatCrackWebcam.removeDockedWebcam(username); } catch (e) { 4723 console.warn('removeDockedWebcam error:', e); 4724 } 4725 } 4726 // Also remove any floating webcam popup 4727 try { 4728 const floatingWebcam = document.querySelector(`.floating-webcam[data-username="${username}"]`); 4729 if (floatingWebcam) floatingWebcam.remove(); 4730 } catch (e) {} 4731 // Clean up eyeball / docked-tile state for the user who stopped. 4732 // NOTE: `webcamViewers` is the set of people watching *us*; the cam 4733 // owner who just stopped is unrelated, so we intentionally do not 4734 // touch it here. The server emits per-viewer `webcam:viewer_removed` 4735 // events for anyone who was watching this user's cam, which the 4736 // handler above processes individually. 4737 if (window.chatCrackWebcam) { 4738 if (window.chatCrackWebcam.dockedWebcams) { 4739 // dockedWebcams may store the publisher's casing â try the exact 4740 // value first and fall back to a case-insensitive sweep so a 4741 // casing drift can't strand a stale entry. 4742 const dw = window.chatCrackWebcam.dockedWebcams; 4743 if (!dw.delete(username)) { 4744 const lc = String(username).toLowerCase(); 4745 for (const k of Array.from(dw)) { 4746 if (String(k).toLowerCase() === lc) dw.delete(k); 4747 } 4748 } 4749 } 4750 window.chatCrackWebcam.refreshEyeballIcons(); 4751 } 4752 4753 // CRITICAL: Persist the "off" state so the roster webcam icon goes away. 4754 // Without this, `webcam_status_<username>` still has `isOn: true` from an 4755 // earlier `webcam:update`, and `renderUserList` re-applies the icon every 4756 // time it runs (for up to WEBCAM_RECENT_MS = 1 hour). This is the main 4757 // reason the CCAPP user's icon stays on after they disconnect. 4758 try { 4759 const key = 'webcam_status_' + username; 4760 const existing = localStorage.getItem(key); 4761 let status; 4762 try { 4763 status = existing ? JSON.parse(existing) : {}; 4764 } catch (_) { status = {}; } 4765 status.isOn = false;
4766 status.isLocked = false; 4767 status.timestamp = Date.now(); 4768 status.serverReceivedAt = Date.now(); 4769 localStorage.setItem(key, JSON.stringify(status)); 4770 } catch (_) {} 4771 // Clear the visible icon right now (authoritative stop â not a soft sync). 4772 try { 4773 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.updateWebcamIconForUser === 'function') { 4774 window.chatCrackWebcam.updateWebcamIconForUser(username, false, false, 'webcam_stopped'); 4775 } 4776 } catch (_) {} 4777 // Suppress list: record a short window during which any subsequent 4778 // `get_webcam_statuses` response for this user must be ignored if it 4779 // still says isOn=true. The server's webcam_statuses.json file can lag 4780 // behind reality â especially for CCAPP publishers that never POST to 4781 // update_webcam_status â so without this, the very next baseline poll 4782 // re-lights the icon and undoes the clear we just performed. 4783 try { 4784 if (!window._recentlyStoppedWebcams) window._recentlyStoppedWebcams = new Map(); 4785 window._recentlyStoppedWebcams.set(String(username).toLowerCase(), Date.now()); 4786 } catch (_) {} 4787 }); 4788 4789 _ws.on("webcam:closure", (data) => { 4790 _chatLog("ð WebSocket webcam closure received (main):", data); 4791 _chatLog("ð Event type: webcam:closure"); 4792 _chatLog("ð Event data:", JSON.stringify(data)); 4793 4794 // Validate data structure 4795 if (!data || !data.username) { 4796 console.error("â Invalid webcam closure data received:", data); 4797 return; 4798 } 4799 // Dedupe: same user closure within 2.5s = skip (reduces lag from duplicate events) 4800 const closureKey = data.username; 4801 const lastByUser = window._lastClosureByUser || {}; 4802 if (lastByUser[closureKey] && (Date.now() - lastByUser[closureKey]) < 2500) return; 4803 lastByUser[closureKey] = Date.now(); 4804 window._lastClosureByUser = lastByUser; 4805 4806 // CRITICAL FIX: Prevent webcam:closure events from stopping our own webcam 4807 // UNLESS it's a force close (indicated by force_closed flag) OR server says it's off 4808 const isSelf = (data.username === (window.currentUsername || (window.me && window.me.username))); 4809 const isForceClose = !!(data.force_closed || data.forceClosed || data.force_closed_by || data.forceClosedBy); 4810 4811 // CRITICAL: Also check if we recently received a force close command 4812 const wasForceClosed = window._lastForceCloseUsername === data.username; 4813 4814 // CRITICAL: Check if server says webcam is off but we think it's on - this might be a force close 4815 // Use loose equality to catch both false and "false" string 4816 const serverSaysOff = data.isOn === false || data.isOn == false || data.isOn === 'false'; 4817 const weThinkOn = window.chatCrackWebcam && window.chatCrackWebcam.isWebcamOn === true; 4818 4819 _chatLog(`ð webcam:closure evaluation for ${data.username}:`, { isSelf, isForceClose, wasForceClosed, serverSaysOff, weThinkOn }); 4820 // Ignore closure for self unless it's an explicit force close (prevents server echo / stale data from stopping our webcam) 4821 if (isSelf && !isForceClose && !wasForceClosed) { 4822 _chatLog(`ð IGNORING webcam:closure event for myself (${data.username})`); 4823 return; 4824 } 4825 4826 // Only stop our own webcam on explicit force close, not on "server says off" (avoids false stops from echoed/stale data) 4827 if (isSelf && (isForceClose || wasForceClosed)) { 4828 _chatLog(`ð Processing force close/webcam:closure event for myself (${data.username})`); 4829 4830 // Set flag to indicate this is a force close 4831 window._lastForceCloseUsername = data.username; 4832 4833 // CRITICAL: Stop the webcam immediately - this is a force close or server says it's off 4834 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.stopWebcam === 'function') { 4835 _chatLog(`ð Force close: stopping webcam immediately`); 4836 window.chatCrackWebcam.stopWebcam(); 4837 setTimeout(() => { 4838 if (window.chatCrackWebcam.isWebcamOn) { 4839 console.error('â Force close: Webcam did not stop from closure event! Retrying...'); 4840 window.chatCrackWebcam.stopWebcam(); 4841 } else { _chatLog('â Force close: Webcam stopped successfully'); } 4842 }, 500); 4843 } else { 4844 console.error('â Force close: stopWebcam function not available!'); 4845 } 4846 return; // Don't process as a viewer event 4847 } 4848 4849 if (!isSelf) { 4850 // Only viewer/UI cleanup for others. Absolutely do not touch publisher state here. 4851 // (removeDockedWebcam already isolates viewer teardown.) 4852 } 4853 4854 _chatLog(`ð Processing webcam closure for OTHER user: ${data.username}`); 4855 if (window.chatCrackWebcam && !window.chatCrackWebcam.closeAllDockedWebcamsForUser) { 4856 window.chatCrackWebcam.closeAllDockedWebcamsForUser = window.chatCrackWebcam.removeDockedWebcam; 4857 } 4858 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.removeDockedWebcam === 'function') { 4859 window.chatCrackWebcam.removeDockedWebcam(data.username); 4860 4861 // NEW: Remove floating webcam if it exists 4862 const floatingWebcam = document.querySelector(`.floating-webcam[data-username="${data.username}"]`); 4863 if (floatingWebcam) { 4864 _chatLog(`ð Removing floating webcam for ${data.username}`); 4865 floatingWebcam.remove(); 4866 localStorage.removeItem(`floating_webcam_${data.username}`); 4867 } 4868 4869 // NOTE (eyeball fix 2026-05): Previously this branch wiped the entire 4870 // `webcamViewers` Set whenever *any other user* closed *their* webcam. 4871 // That was wrong on its face â `webcamViewers` tracks people watching 4872 // YOUR webcam, not the one that just closed â and it's the primary 4873 // reason the green eyeball would silently disappear ("there are 4874 // people viewing my webcam and I don't see a green eyeball"). The 4875 // correct cleanup happens server-side in MediaServer/server.js on 4876 // disconnect, which fans out per-viewer `webcam:viewer_removed` 4877 // events that our handler above already processes. We intentionally 4878 // do nothing here. 4879 4880 // CRITICAL FIX: Immediately clean up stale eyeball statuses when webcam closes 4881 if (window.chatCrackWebcam.cleanupStaleEyeballStatuses) { 4882 // Debug messages removed for cleaner console 4883 const cleanedCount = window.chatCrackWebcam.cleanupStaleEyeballStatuses(); 4884 if (cleanedCount > 0) { 4885 // Debug messages removed for cleaner console 4886 } 4887 } 4888 4889 // CRITICAL FIX: Clean up localStorage viewer tracking for this user 4890 // When a webcam is closed, remove all localStorage entr
4890ies that track viewing this user 4891 if (window.chatCrackWebcam) { 4892 // Clean up any localStorage entries that track viewing this specific user 4893 const keysToRemove = []; 4894 for (let i = 0; i < localStorage.length; i++) { 4895 const key = localStorage.key(i); 4896 if (key && key.startsWith('webcam_viewer_')) { 4897 // Check if this key tracks viewing the closed webcam user 4898 const viewerUsername = key.replace('webcam_viewer_', ''); 4899 // If the viewer was viewing the closed webcam user, remove the entry 4900 if (viewerUsername === data.username) { 4901 keysToRemove.push(key); 4902 } 4903 } 4904 } 4905 4906 // Remove the localStorage entries 4907 for (const key of keysToRemove) { 4908 localStorage.removeItem(key); 4909 _chatLog(`ðï¸ Cleaned up localStorage viewer tracking: ${key}`); 4910 } 4911 4912 if (keysToRemove.length > 0) { 4913 console.log(`ðï¸ Cleaned up ${keysToRemove.length} localStorage entries, refreshing eyeball icons`); 4914 window.chatCrackWebcam.refreshEyeballIcons(); 4915 } 4916 } 4917 } else { 4918 console.error("â Webcam system not available for closure handling"); 4919 } 4920 }); 4921 4922 _chatLog("ð CRITICAL: Main webcam:closure event listener set up successfully"); 4923 4924 // CRITICAL: Clear own webcam status on connect to prevent stale status from persisting 4925 // This runs BEFORE the baseline fetch to ensure clean state 4926 (async function clearOwnWebcamOnConnect() { 4927 const currentUser = window.currentUsername || window.me?.username || username; 4928 // This clear is meant for a FRESH page load. On a WS reconnect while we 4929 // are actively broadcasting, posting isOn:false + broadcasting a 4930 // webcam:update OFF would flash our icon off for the whole room â one of 4931 // the flicker sources. Skip it when the local publisher is live. 4932 if (typeof window._ccIsSelfWebcamLive === 'function' && window._ccIsSelfWebcamLive()) { 4933 _chatLog('ð§¹ Skipping own webcam clear on connect â currently broadcasting'); 4934 return; 4935 } 4936 if (currentUser) { 4937 try { 4938 // Clear from localStorage first 4939 const offlineStatus = { 4940 username: currentUser, 4941 isOn: false, 4942 isLocked: false, 4943 timestamp: Date.now() 4944 }; 4945 localStorage.setItem('webcam_status_' + currentUser, JSON.stringify(offlineStatus)); 4946 4947 // Clear from server database 4948 await fetch('api.php?action=update_webcam_status&_cc_force_http=1', { 4949 method: 'POST', 4950 headers: { 'Content-Type': 'application/json' }, 4951 body: JSON.stringify(offlineStatus) 4952 }); 4953 4954 // Broadcast via WebSocket to other users 4955 socket.emit('webcam:update', offlineStatus); 4956 4957 _chatLog('ð§¹ Cleared own webcam status on connect for:', currentUser); 4958 } catch (err) { 4959 // Non-critical, continue 4960 } 4961 } 4962 })(); 4963 4964 // PHASE 1 FIX: Always perform baseline fetch on WS connect to seed the cache 4965 // Delay set to 1500ms to ensure own webcam clear completes first 4966 setTimeout(() => { 4967 fetch('api.php?action=get_webcam_statuses') 4968 .then(response => response.json()) 4969 .then(data => { 4970 if (data.ok && data.webcam_statuses) { 4971 // Force refresh webcam icons for all online users after baseline fetch 4972 setTimeout(() => { 4973 if (window.chatCrackWebcam && window.chatCrackWebcam.forceRefreshAllWebcamIcons) { 4974 window.chatCrackWebcam.forceRefreshAllWebcamIcons(); 4975 } 4976 }, 1000); 4977 4978 Object.entries(data.webcam_statuses).forEach(([username, status]) => { 4979 // RACE CONDITION PROTECTION: Don't override recently reprocessed webcams 4980 if (window.recentlyReprocessedWebcams && window.recentlyReprocessedWebcams.includes(username)) { 4981 // Debug messages removed for cleaner console 4982 return; // Skip this user 4983 } 4984 4985 // SUPPRESS RECENTLY-STOPPED: If we received a webcam:stopped for 4986 // this user within the last 30s, ignore any stale isOn=true coming 4987 // from the server's webcam_statuses.json file (it lags behind 4988 // reality for CCAPP publishers that never POST to update_webcam_status, 4989 // and for web sessions that closed abruptly). Without this, the 4990 // baseline fetch on every WS reconnect would re-light the roster 4991 // icon for users who are no longer streaming. 4992 try { 4993 const stopMap = window._recentlyStoppedWebcams; 4994 if (stopMap) { 4995 const lcUser = String(username).toLowerCase(); 4996 if (stopMap.has(lcUser)) { 4997 const since = Date.now() - stopMap.get(lcUser); 4998 if (since < 30000 && status && status.isOn) { 4999 status = { ...status, isOn: false, isLocked: fal
4999se }; 5000 } else if (since >= 30000) { 5001 stopMap.delete(lcUser); 5002 } 5003 } 5004 } 5005 } catch (_) {} 5006 5007 // PHASE 1 FIX: Don't filter out webcams just because they've been running for a while 5008 // A webcam that's been on for 2+ minutes is still valid 5009 if (status.isOn) { 5010 // Check if user is actually online before applying server status 5011 const lower = String(username).toLowerCase(); 5012 const isUserListArray = Array.isArray(window.users); 5013 let isOnline = isUserListArray 5014 ? window.users.some(u => u && String(u.username).toLowerCase() === lower) 5015 : (window.users && typeof window.users === 'object' && Object.keys(window.users).some(k => String(k).toLowerCase() === lower)); 5016 // Fallback to DOM presence if users map/array not authoritative 5017 if (!isOnline) { 5018 const el = document.querySelector(`#users .user[data-username]`); 5019 if (el) { 5020 const allUserElements = document.querySelectorAll('#users .user'); 5021 for (const element of allUserElements) { 5022 const elementUsername = String(element.getAttribute('data-username') || '').toLowerCase(); 5023 if (elementUsername === lower) { isOnline = true; break; } 5024 } 5025 } 5026 } 5027 // If user is online and server says webcam is ON, trust the server regardless of timestamp staleness 5028 // This fixes the issue where active webcams were being marked OFF due to old timestamps 5029 if (isOnline) { 5030 const stickyShow = typeof window._ccWebcamRosterIconShouldShow === 'function' 5031 && window._ccWebcamRosterIconShouldShow(username); 5032 // Trust server isOn:true for online users â stale-off is handled by 5033 // webcam:stopped + _recentlyStoppedWebcams, not by downgrading here. 5034 const effectiveOn = !!(status.isOn); 5035 const toStore = { ...status, isOn: effectiveOn, serverReceivedAt: effectiveOn ? Date.now() : (status.serverReceivedAt || Date.now()) }; 5036 if (typeof window._ccSetRemoteWebcamLock === 'function') { 5037 if (status.isLocked) window._ccSetRemoteWebcamLock(username, true, true); 5038 else window._ccSetRemoteWebcamLock(username, false, true); 5039 } 5040 if (typeof window._ccWriteWebcamStatus === 'function') { 5041 window._ccWriteWebcamStatus(username, toStore); 5042 } else { 5043 localStorage.setItem('webcam_status_' + username, JSON.stringify(toStore)); 5044 } 5045 if (window.chatCrackWebcam && window.chatCrackWebcam.updateWebcamIconForUser) {
5046 const iconLocked = effectiveOn ? !!status.isLocked : false; 5047 window.chatCrackWebcam.updateWebcamIconForUser(username, effectiveOn, iconLocked, 'server_sync'); 5048 } 5049 } else { 5050 // User is not online, mark as offline instead of applying server status 5051 const bootAgeMs = Date.now() - (window._ccBootAt || Date.now()); 5052 const isCurrentUser = username === (window.currentUsername || 'Dolaf'); 5053 if (isCurrentUser || bootAgeMs < 10000) { 5054 // Do not override current user's icon or early in boot; refresh timestamp only 5055 const refreshed = { ...status, timestamp: Date.now(), serverReceivedAt: Date.now() }; 5056 localStorage.setItem('webcam_status_' + username, JSON.stringify(refreshed)); 5057 // Keep whatever icon state is currently shown 5058 // console.log(`ð§ â³ Defer offline override for ${username} (current/early boot)`); 5059 } else { 5060 if (typeof window._ccActiveWebcamHas === 'function' && window._ccActiveWebcamHas(username)) { 5061 return; 5062 } 5063 if (typeof window._ccWebcamRosterIconShouldShow === 'function' 5064 && window._ccWebcamRosterIconShouldShow(username)) { 5065 return; 5066 } 5067 // User not online, marking webcam as offline instead of applying server status 5068 const offlineStatus = { ...status, isOn: false, timestamp: Date.now() }; 5069 localStorage.setItem('webcam_status_' + username, JSON.stringify(offlineStatus)); 5070 if (window.chatCrackWebcam && window.chatCrackWebcam.updateWebcamIconForUser) { 5071 window.chatCrackWebcam.updateWebcamIconForUser(username, false, false, 'offline_override'); 5072 } 5073 } 5074 } 5075 } else { 5076 // Server explicitly says this user's webcam is OFF. Previously we 5077 // silently skipped here, which left a stale `isOn: true` localStorage 5078 // entry (from a prior webcam:update broadcast) intact â and 5079 // renderUserList kept re-lighting the roster icon on every tick. 5080 //
5081 // With the 3-min ghost-downgrade now active in api.php, a user 5082 // who stopped streaming (or whose web session died without 5083 // posting isOn=false, or who's on CCAPP and therefore never 5084 // posts to update_webcam_status at all) comes back here as 5085 // isOn=false. Treat that as authoritative: clear localStorage 5086 // and actively remove the icon so it doesn't persist forever. 5087 // 5088 // Skip only for the current user (we manage their own state 5089 // from the local publisher), and skip if we don't have a 5090 // stored status yet (nothing to clear). 5091 const isCurrentUserOff = username === (window.currentUsername || 'Dolaf'); 5092 if (!isCurrentUserOff) { 5093 try { 5094 const key = 'webcam_status_' + username; 5095 const existing = localStorage.getItem(key); 5096 if (existing) { 5097 let parsed = {}; 5098 try { parsed = JSON.parse(existing) || {}; } catch (_) {} 5099 // Only act if localStorage currently believes webcam is ON â 5100 // otherwise there's nothing stale to clear. 5101 if (parsed && parsed.isOn) { 5102 if (typeof window._ccActiveWebcamHas === 'function' && window._ccActiveWebcamHas(username)) { 5103 return; 5104 } 5105 if (typeof window._ccWebcamRosterIconShouldShow === 'function' 5106 && window._ccWebcamRosterIconShouldShow(username)) { 5107 return; 5108 } 5109 const cleared = { ...parsed, isOn: false, isLocked: false, timestamp: Date.now(), serverReceivedAt: Date.now() }; 5110 localStorage.setItem(key, JSON.stringify(cleared)); 5111 if (window.chatCrackWebcam && window.chatCrackWebcam.updateWebcamIconForUser) { 5112 window.chatCrackWebcam.updateWebcamIconForUser(username, false, false, 'server_sync'); 5113 } 5114 } 5115 } 5116 } catch (_) {} 5117 } 5118 } 5119 }); 5120 } 5121 }) 5122 .catch(error => { 5123 console.error('â Baseline fetch failed:', error); 5124 }); 5125 }, 1500); // Increased delay to allow own webcam clear to complete first 5126 5127 // âââ ROSTER WEBCAM ICON TTL SWEEP âââââââââââââââââââââââââââââââââââââ 5128 // Independent of every other cleanup path: every 30s walk every user we 5129 // see in the roster and force isOn=false in localStorage if their 5130 // webcam_status entry hasn't been "refreshed" in 90 seconds. 5131 // 5132 // "Refreshed" means we've received a webcam:beat, webcam:update, or 5133 // baseline fetch hit for them inside the last 90s â any of those write 5134 // `serverReceivedAt: Date.now()`. If none of those have happened, the 5135 // remote user is either (a) not streaming anymore, (b) has crashed or 5136 // gone offline, or (c) is on CCAPP and their stop event got lost between 5137 // app/socket/MediaServer/PHP. In any of those cases, the global 1-hour 5138 // WEBCAM_RECENT_MS lets the icon persist forever, which is exactly the 5139 // bug we keep hitting. This sweep gives us a tight 90-second floor. 5140 // 5141 // Skipped: 5142 // ⢠Current user â their state is owned by the local publisher. 5143 // ⢠Anyone we have an active dock tile for with a live MediaStream â 5144 // the stream itself is the truth there, regardless of heartbeat. 5145 if (!window._webcamTtlSweepStarted) { 5146 window._webcamTtlSweepStarted = true; 5147 const TTL_MS = 10 * 60 * 1000; // 10 min â only clear long-idle ghosts, not active broadcasts 5148 setInterval(() => { 5149 try { 5150 // No socket = no beats can possibly arrive; ageing out icons while 5151 // we're offline just makes them flicker off and back on reconnect. 5152 if (!window.socket || !window.socket.connected) return; 5153 const me = (window.currentUsername || window.me?.username || ''); 5154 const meLc = String(me).toLowerCase(); 5155 const userEls = document.querySelectorAll('#users .user[data-username]'); 5156 if (!userEls || userEls.length === 0) return; 5157 const now = Date.now(); 5158 let cleared = 0;
5159 userEls.forEach(el => { 5160 const username = el.getAttribute('data-username') || ''; 5161 if (!username) return; 5162 if (String(username).toLowerCase() === meLc) return; // skip self 5163 const key = 'webcam_status_' + username; 5164 const raw = localStorage.getItem(key); 5165 if (!raw) return; 5166 let parsed; 5167 try { parsed = JSON.parse(raw); } catch (_) { return; } 5168 if (!parsed || parsed.isOn !== true) return; 5169 // Skip if we currently have a live <video> for them â stream is authoritative 5170 try { 5171 const v = document.getElementById('docked-video-' + username); 5172 if (v && v.srcObject) { 5173 const tracks = (v.srcObject.getVideoTracks && v.srcObject.getVideoTracks()) || []; 5174 if (tracks.some(t => t.readyState === 'live')) return; 5175 } 5176 } catch (_) {} 5177 // Authoritative active set wins over heartbeat age: the MediaServer 5178 // says this user has a live producer, so the icon must stay lit for 5179 // the full broadcast â missed beats (tab throttling, WS churn) are 5180 // not a reason to grey it. Refresh the timestamp so the entry 5181 // doesn't come back stale on every sweep pass. 5182 if (typeof window._ccActiveWebcamHas === 'function' && window._ccActiveWebcamHas(username)) { 5183 try { 5184 parsed.serverReceivedAt = now; 5185 parsed.timestamp = now; 5186 localStorage.setItem(key, JSON.stringify(parsed)); 5187 } catch (_) {} 5188 return; 5189 } 5190 const ts = (parsed.serverReceivedAt != null ? parsed.serverReceivedAt : parsed.timestamp) || 0; 5191 const age = now - ts; 5192 if (age > TTL_MS) { 5193 if (typeof window._ccActiveWebcamHas === 'function' && window._ccActiveWebcamHas(username)) { 5194 try { 5195 parsed.serverReceivedAt = now; 5196 parsed.timestamp = now; 5197 localStorage.setItem(key, JSON.stringify(parsed)); 5198 } catch (_) {} 5199 return; 5200 } 5201 // Never sweep a visibly lit roster icon â something else must turn it off. 5202 try { 5203 const row = (typeof window._ccFindUserListRow === 'function') 5204 ? window._ccFindUserListRow(username) : null; 5205 const icon = row ? row.querySelector('.user-webcam-icon') : null; 5206 if (icon && !icon.classList.contains('inactive') 5207 && (icon.classList.contains('active') || icon.classList.contains('locked') || icon.classList.contains('ppv'))) { 5208 try { 5209 parsed.serverReceivedAt = now; 5210 parsed.timestamp = now; 5211 localStorage.setItem(key, JSON.stringify(parsed)); 5212 } catch (_) {} 5213 return; 5214 } 5215 } catch (_) {} 5216 if (typeof window._ccWebcamRosterIconShouldShow === 'function' 5217 && window._ccWebcamRosterIconShouldShow(username)) { 5218 try { 5219 parsed.serverReceivedAt = now; 5220 parsed.timestamp = now; 5221 localStorage.setItem(key, JSON.stringify(parsed)); 5222 } catch (_) {} 5223 return; 5224 } 5225 const updated = { 5226 ...parsed, 5227 isOn: false, 5228 isLocked: false, 5229 timestamp: now, 5230 serverReceivedAt: now, 5231 ttlSweepCleared: true 5232 }; 5233 localStorage.setItem(key, JSON.stringify(updated)); 5234 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.updateWebcamIconForUser === 'function') { 5235 window.chatCrackWebcam.updateWebcamIconForUser(username, false, false, 'ttl_sweep'); 5236 } 5237 if (Array.isArray(window.persistentWebcamIconData)) { 5238 window.persistentWebcamIconData = window.persistentWebcamIconData.filter( 5239 it => String(it?.username || '').toLowerCase() !== String(username).toLowerCase() 5240 ); 5241 } 5242 cleared++; 5243 } 5244 }); 5245 if (cleared > 0) { 5246 console.log(`ð§¹ [TTL SWEEP] Cleared ${cleared} stale webcam icon(s) (no heartbeat in ${TTL_MS/1000}s)`); 5247 } 5248 } catch (e) { 5249 console.warn('TTL sweep error:', e); 5250 } 5251 }, 30 * 1000); 5252 } 5253
5254 // Bind main socket to webcam module when available (or queue for later init). 5255 if (window.chatCrackWebcam) { 5256 window.chatCrackWebcam.socket = socket; 5257 } else { 5258 window._ccPendingWebcamSocket = socket; 5259 } 5260 setTimeout(() => { 5261 if (window.chatCrackWebcam && window.chatCrackWebcam.refreshWebcamIconsFromStorage) { 5262 window.chatCrackWebcam.refreshWebcamIconsFromStorage(); 5263 } 5264 if (window.chatCrackScreenShare?.refreshScreenIconsFromStorage) { 5265 window.chatCrackScreenShare.refreshScreenIconsFromStorage(); 5266 } 5267 if (window.chatCrackScreenShare?._refreshActiveScreenSet) { 5268 window.chatCrackScreenShare._refreshActiveScreenSet(() => { 5269 window.chatCrackScreenShare.refreshScreenIconsFromStorage(); 5270 }); 5271 } 5272 }, 1000); 5273 5274 // Listen for role promotions and refresh roles/capabilities immediately 5275 try { window.socket.off && window.socket.off('user:promoted'); } catch(_e) {} 5276 _ws.on('user:promoted', async (evt) => { 5277 try { 5278 if (evt && evt.platinum_revoked && evt.target && typeof _ccApplyPlatinumExpiryLive === 'function') { 5279 _ccApplyPlatinumExpiryLive(evt.target, evt.role_id || 'vip'); 5280 } 5281 if (evt && evt.platinum_revoked && typeof window.scheduleUserListRefresh === 'function') { 5282 window.scheduleUserListRefresh({ immediate: true }); 5283 } 5284 const promoMid = evt && (evt.system_message_id || evt.message_id); 5285 const promoText = evt && evt.text; 5286 if (promoMid && promoText && typeof window._ccPushWsChatMessages === 'function') { 5287 const mid = (typeof ccNormalizeChatMessageId === 'function') 5288 ? ccNormalizeChatMessageId(promoMid) 5289 : (Number(promoMid) || 0); 5290 if (mid && !(typeof ccChatHasMessageRow === 'function' && ccChatHasMessageRow(mid))) { 5291 window._ccPushWsChatMessages([{ 5292 id: mid, 5293 type: 'system', 5294 text: promoText, 5295 room_id: null, 5296 created_ts: Math.floor(Date.now() / 1000), 5297 username: evt.actor || 'System', 5298 user_id: evt.actor_user_id || 1 5299 }]); 5300 } 5301 } 5302 // Peers must paint the new role immediately â demote DJâVIP used to leave 5303 // everyone else showing DJ because only the target ran /me. 5304 const promoWho = evt && (evt.target || evt.username); 5305 const promoRole = evt && (evt.role_id || evt.role); 5306 if (promoWho && promoRole && typeof window._ccApplyRosterUserBadgeFields === 'function') { 5307 try { 5308 window._ccApplyRosterUserBadgeFields(promoWho, { 5309 role: String(promoRole).toLowerCase(),
5310 display_name: evt.display_name || undefined, 5311 _roleAuthoritative: true 5312 }); 5313 } catch (_) {} 5314 } 5315 await ccHandleSelfPromotion(evt); 5316 } catch (e) { 5317 console.error('â Error handling user:promoted event:', e); 5318 } 5319 }); 5320 try { window.socket.off && window.socket.off('permissions:refresh'); } catch(_e) {} 5321 _ws.on('permissions:refresh', async (evt) => { 5322 try { 5323 if (evt && evt.reason === 'screen_share_grant' && evt.username && typeof ccSetUserScreenShareGrant === 'function') { 5324 if (hasAdminPanelAccess()) { 5325 ccSetUserScreenShareGrant(evt.username, !!(evt.screen_share_granted * 1)); 5326 } 5327 } 5328 if (evt && evt.silent) { 5329 await ccRefreshSelfPermissionsSilent(evt); 5330 return; 5331 } 5332 const refreshWho = evt && (evt.username || evt.target); 5333 const refreshRole = evt && (evt.role_id || evt.role); 5334 if (refreshWho && refreshRole 5335 && (evt.reason === 'role_changed' || evt.reason === 'promoted') 5336 && typeof window._ccApplyRosterUserBadgeFields === 'function') { 5337 try { 5338 window._ccApplyRosterUserBadgeFields(refreshWho, { 5339 role: String(refreshRole).toLowerCase(),
5340 display_name: evt.display_name || undefined, 5341 _roleAuthoritative: true 5342 }); 5343 } catch (_) {} 5344 } 5345 await ccHandleSelfPromotion(evt); 5346 } catch (e) { 5347 console.error('â Error handling permissions:refresh event:', e); 5348 } 5349 }); 5350 try { window.socket.off && window.socket.off('roles:updated'); } catch (_e) {} 5351 _ws.on('roles:updated', (payload) => { 5352 try { 5353 if (typeof window._ccApplyLiveRolesUpdate === 'function') { 5354 window._ccApplyLiveRolesUpdate(payload || {}); 5355 } else if (typeof loadUserRoles === 'function') { 5356 window._rolesLastLoaded = 0; 5357 loadUserRoles(true); 5358 } 5359 } catch (e) { 5360 console.error('â Error handling roles:updated event:', e); 5361 } 5362 }); 5363 try { window.socket.off && window.socket.off('vcall:staff_disable'); } catch (_e) {} 5364 _ws.on('vcall:staff_disable', (evt) => { 5365 try { 5366 const who = evt && evt.username ? String(evt.username) : ''; 5367 const me = (window.me && window.me.username) || window.currentUsername || ''; 5368 if (!who || !me || who.toLowerCase() !== String(me).toLowerCase()) return; 5369 const disabled = !!(evt.disabled === true || evt.disabled === 1 || evt.disabled === '1'); 5370 if (typeof window.ccApplyVoiceCallsStaffDisable === 'function') { 5371 window.ccApplyVoiceCallsStaffDisable(disabled); 5372 } else { 5373 if (!window.me) window.me = {}; 5374 window.me.disable_voice_calls = disabled ? 1 : 0; 5375 } 5376 if (typeof showToast === 'function') { 5377 showToast( 5378 disabled 5379 ? 'Voice and video calls were disabled on your account.' 5380 : 'Voice and video calls were re-enabled on your account.', 5381 'info' 5382 ); 5383 } 5384 } catch (e) { 5385 console.error('â Error handling vcall:staff_disable:', e); 5386 } 5387 }); 5388 5389 // Listen for nickname changes from other users 5390 try { window.socket.off && window.socket.off('nickname_change'); } catch(_e) {} 5391 _ws.on('nickname_change', (data) => { 5392 try { 5393 if (data && data.target_username) { 5394 window._lastNicknamesFetch = Date.now(); 5395 if (typeof window._ccApplyNicknameUpdate === 'function') { 5396 window._ccApplyNicknameUpdate(data); 5397 } else { 5398 window.userNicknames[data.target_username] = { 5399 nickname: data.nickname, 5400 color: data.color, 5401 expiresAt: data.expiresAt, 5402 setBy: data.setBy, 5403 setAt: data.setAt 5404 }; 5405 saveNicknames(); 5406 if (window.users && typeof renderUserList === 'function') { 5407 const usersArray = Object.values(window.users).filter(u => u && typeof u === 'object'); 5408 if (usersArray.length > 0) renderUserList(usersArray); 5409 } 5410 } 5411 } 5412 } catch (e) { 5413 console.error('â Error handling nickname_change event:', e); 5414 } 5415 }); 5416 5417 // Listen for nickname removals from other users 5418 try { window.socket.off && window.socket.off('nickname_remove'); } catch(_e) {} 5419 _ws.on('nickname_remove', (data) => { 5420 try { 5421 if (!data || !data.target_username) return; 5422 window._lastNicknamesFetch = Date.now(); 5423 const removed = window._ccCanonicalUsername 5424 ? window._ccCanonicalUsername(data.target_username) 5425 : data.target_username; 5426 window.userNicknames = window.userNicknames || {}; 5427 Object.keys(window.userNicknames).forEach(function (k) { 5428 if (String(k).toLowerCase() === String(removed).toLowerCase() 5429 || String(k).toLowerCase() === String(data.target_username).toLowerCase()) { 5430 delete window.userNicknames[k]; 5431 } 5432 }); 5433 saveNicknames(); 5434 if (window.users && typeof renderUserList === 'function') { 5435 const usersArray = Array.isArray(window.users) 5436 ? window.users 5437 : Object.values(window.users).filter(u => u && typeof u === 'object'); 5438 if (usersArray.length > 0) renderUserList(usersArray); 5439 } 5440 if (typeof window._ccUpdateMediaDockTitlesForUser === 'function') { 5441 window._ccUpdateMediaDockTitlesForUser(removed); 5442 } 5443 if (typeof window._ccRefreshChatDisplayNamesForUser === 'function') { 5444 window._ccRefreshChatDisplayNamesForUser(removed); 5445 } 5446 } catch (e) { 5447 console.error('â Error handling nickname_remove event:', e); 5448 } 5449 }); 5450 5451 // Permanent username rename (Shekel Username / staff edit) â retarget chat labels instantly 5452 try { window.socket.off && window.socket.off('username_renamed'); } catch(_e) {} 5453 _ws.on('username_renamed', (data) => {
5454 try { 5455 if (!data || !data.old_username || !data.new_username) return; 5456 if (typeof window._ccApplyUsernameRename === 'function') { 5457 window._ccApplyUsernameRename(data.old_username, data.new_username, data.user_id); 5458 } else if (typeof window._ccRewriteChatUsername === 'function') { 5459 window._ccRewriteChatUsername(data.old_username, data.new_username); 5460 } 5461 if (window.me && typeof ccUsernameEquals === 'function' 5462 && ccUsernameEquals(window.me.username, data.old_username)) { 5463 window.me.username = data.new_username; 5464 window.me.display_name = data.new_username; 5465 window.currentUsername = data.new_username; 5466 } 5467 if (typeof loadUsers === 'function') { 5468 try { loadUsers(); } catch (_) {} 5469 } else if (typeof window.scheduleUserListRefresh === 'function') { 5470 window.scheduleUserListRefresh({ immediate: true }); 5471 } 5472 } catch (e) { 5473 console.error('â Error handling username_renamed event:', e); 5474 } 5475 }); 5476 5477 // Featured user events 5478 _ws.on('user:featured', (data) => { 5479 try { 5480 if (!data || !data.username) return; 5481 const un = window._ccCanonicalUsername ? window._ccCanonicalUsername(data.username) : data.username; 5482 const ndata = Object.assign({}, data, { username: un }); 5483 if (typeof window._ccClearFeaturedWebcamDismissed === 'function') { 5484 window._ccClearFeaturedWebcamDismissed(un); 5485 } 5486 _ccMergeFeaturedUser(ndata); 5487 _ccUpdateFeaturedBadges(); 5488 _ccStartFeaturedTimer(); 5489 if (window.users && Array.isArray(window.users) && typeof renderUserList === 'function') { 5490 renderUserList(window.users); 5491 } 5492 const myName = window.currentUsername || (window.me && window.me.username); 5493 if (un && (!myName || String(un).toLowerCase() !== String(myName).toLowerCase()) 5494 && typeof _ccIsFavouriteCam === 'function' && _ccIsFavouriteCam(un) 5495 && typeof _ccScheduleFavouriteAutoOpenWhenInRoom === 'function') { 5496 _ccScheduleFavouriteAutoOpenWhenInRoom(un, { aggressive: true, isLocked: false, immediate: true }); 5497 } 5498 if (myName && String(un).toLowerCase() === String(myName).toLowerCase()) { 5499 _ccFeaturedViewerFastPoll(); 5500 } 5501 showToast(`â ${un} is now featured`, 'info'); 5502 if (typeof window.ccNudgeShekelLeaderboard === 'function') window.ccNudgeShekelLeaderboard(true); 5503 } catch (e) { /* silent */ } 5504 }); 5505 5506 _ws.on('user:unfeatured', (data) => { 5507 try { 5508 const removed = data && data.username; 5509 if (removed) { 5510 const rl = String(removed).toLowerCase(); 5511 window._ccFeaturedUsers = (window._ccFeaturedUsers || []).filter(u => !u || !u.username || 5512 String(u.username).toLowerCase() !== rl); 5513 if (typeof window._ccClearFeaturedWebcamDismissed === 'function') { 5514 window._ccClearFeaturedWebcamDismissed(removed); 5515 } 5516 } else { 5517 window._ccFeaturedUsers = []; 5518 } 5519 _ccUpdateFeaturedBadges(); 5520 if ((window._ccFeaturedUsers || []).length) { 5521 _ccStartFeaturedTimer(); 5522 } else { 5523 _ccStopFeaturedTimer(); 5524 if (window._ccFeaturedFastPollTimer) { clearInterval(window._ccFeaturedFastPollTimer); window._ccFeaturedFastPollTimer = null; } 5525 } 5526 if (window.users && Array.isArray(window.users) && typeof renderUserList === 'function') { 5527 renderUserList(window.users); 5528 } 5529 showToast('Featured user removed', 'info'); 5530 if (typeof window.ccNudgeShekelLeaderboard === 'function') window.ccNudgeShekelLeaderboard(true); 5531 } catch (e) { /* silent */ } 5532 }); 5533 5534 // Wire webcam WS handlers once the main socket is live (do not wait for 5535 // chatCrackWebcam.socket â that created a race where handlers never registered). 5536 setTimeout(() => { 5537 const _wsWire = window.socket || socket; 5538 if (!_wsWire || _wsWire._ccWebcamHandlersWired) return; 5539 _wsWire._ccWebcamHandlersWired = true; 5540 if (window.chatCrackWebcam) { 5541 window.chatCrackWebcam.socket = _wsWire; 5542 _chatLog('â Webcam system bound to socket', _wsWire.connected, _wsWire.id); 5543 } else { 5544 window._ccPendingWebcamSocket = _wsWire; 5545 } 5546 5547 const _ws = _wsWire; 5548 _ws.on("webcam:update", (data) => { 5549 // ============================================================================ 5550 // INSTRUCTION: webcam:update Event Handler 5551 // ============================================================================
5552 // This event is fired when: 5553 // 1. Camera is turned ON/OFF 5554 // 2. Camera is LOCKED/UNLOCKED 5555 // 3. Access is GRANTED/DENIED 5556 // 5557 // When camera is UNLOCKED: 5558 // - Check if video element exists but has no stream (black screen) 5559 // - If so, trigger stream consumption to update the video element 5560 // ============================================================================ 5561 5562 _chatLog(`ð¹ webcam:update received:`, data.username, data.isOn, data.isLocked); 5563 5564 // CRITICAL FIX: Validate data structure and prevent cross-contamination 5565 if (!data || !data.username || typeof data.isOn !== 'boolean') { 5566 console.error("â Invalid webcam update data received:", data); 5567 return; 5568 } 5569 if (typeof window._ccIsAiBotUsername === 'function' && window._ccIsAiBotUsername(data.username)) { 5570 return; 5571 } 5572 5573 // Now Playing: apply before source==='local' early-return (broadcast keeps source). 5574 // Soft-clear: ignore transient nulls (Spotify 204 flicker) unless the 5575 // publisher marks an intentional clear (pause) via nowPlayingClear. 5576 if (Object.prototype.hasOwnProperty.call(data, 'nowPlaying') 5577 && typeof window.setWebcamNowPlaying === 'function') { 5578 try { 5579 const np = data.nowPlaying || null; 5580 if (np) { 5581 window.setWebcamNowPlaying(data.username, np); 5582 } else if (data.nowPlayingClear) { 5583 window.setWebcamNowPlaying(data.username, null); 5584 } else { 5585 const cached = window._ccWebcamNowPlayingByUser 5586 && window._ccWebcamNowPlayingByUser.get(String(data.username || '').toLowerCase()); 5587 if (!cached) window.setWebcamNowPlaying(data.username, null); 5588 } 5589 } catch (_) {} 5590 } 5591 if (data.isOn === false && typeof window.setWebcamNowPlaying === 'function') { 5592 try { window.setWebcamNowPlaying(data.username, null); } catch (_) {} 5593 } 5594 5595 // âââ STALE-OFF GUARD (mirror of the stale isOn:true protection) ââââââ 5596 // webcam:started / webcam:stopped are the ONLY authoritative live/stop 5597 // signals (they mutate the MediaServer activeWebcams set). A plain 5598 // webcam:update with isOn:false is a soft status sync that can be a 5599 // stale cross-shard echo (users on both US+EU sockets get duplicates â 5600 // note the doubled "owner_off" undock logs) or a stale baseline poll. 5601 // If we still consider this publisher authoritatively live (present in 5602 // the active set, kept fresh by webcam:started/beats/get_active) and 5603 // this is not a force-close, the off is almost certainly stale: ignore 5604 // it. Otherwise it tears down a genuinely-live favourite/featured cam 5605 // AND arms the 30s stop-guard that then blocks it from re-docking â 5606 // exactly the "favourite cam won't auto-dock" symptom. Genuine stops 5607 // still arrive via webcam:stopped (which clears the active set first) 5608 // or are reconciled by the 60s webcam:get_active refresh, after which a 5609 // later isOn:false undocks cleanly. 5610 if (data.isOn === false && data.source !== 'local' && !data.accessUpdate) { 5611 const isForceCloseOff = !!(data.force_closed_by || data.forceClosedBy); 5612 const meOff = window.currentUsername || (window.me && window.me.username); 5613 const isSelfOff = meOff && String(data.username).toLowerCase() === String(meOff).toLowerCase(); 5614 const leftOff = typeof window._ccUserExplicitlyLeft === 'function' 5615 && window._ccUserExplicitlyLeft(data.username); 5616 // Soft isOn=false is a poll/echo. It was killing Keith/QueenBitch 5617 // mid-consume after a green-icon click (dock gone, no first frame). 5618 // Real offs arrive as webcam:stopped or a chat leave. 5619 if (!isForceCloseOff && !isSelfOff && !leftOff) { 5620 if (window.WEBCAM_DEBUG) { 5621 console.log(`ð¡ï¸ [STALE-OFF GUARD] Ignoring webcam:update isOn=false for ${data.username}`); 5622 } 5623 return; 5624 } 5625 } 5626 5627 // Keep authoritative active-stream set in sync with live events only. 5628 // isOn:true adds; isOn:false does NOT remove â only webcam:stopped is authoritative OFF. 5629 try { 5630 if (data.isOn === true && data.username 5631 && !(typeof window._ccUserExplicitlyLeft === 'function' && window._ccUserExplicitlyLeft(data.username)) 5632 && typeof window._ccActiveWebcamAdd === 'function') { 5633 window._ccActiveWebcamAdd(data.username); 5634 } 5635 } catch (_) {} 5636 5637 // CRITICAL FIX: Only filter out events with source: 'local' to prevent processing our own events 5638 if (data.source === 'local') { 5639 5640 return; 5641 } 5642 5643 // If another user turns OFF their webcam, record the timestamp for debounce 5644 if (data.isOn === false) { 5645 window._lastRemoteWebcamOff = Date.now(); 5646 } 5647 5648 // Authoritative go-live from the socket: clear STOP GUARD so a real 5649 // stopâstart cycle (e.g. CCAPP MobileGuest toggling cam within 30s) 5650 // is not blocked by _recentlyStoppedWebcams in updateWebcamIconForUser. 5651 try { 5652 if (data.isOn === true && data.username) { 5653 const lc = String(data.username).toLowerCase(); 5654 if (window._recentlyStoppedWebcams && typeof window._recentlyStoppedWebcams.delete === 'function') { 5655 window._recentlyStoppedWebcams.delete(lc); 5656 } 5657 } 5658 } catch (_) {} 5659 5660 // Handle access updates from webcam:update events 5661 if (data.accessUpdate && data.requester && typeof data.granted === 'boolean') { 5662 // Only process if this user is the requester (no hardcoded fallback - fixes same-browser / wrong user) 5663 const myUsername = (window.currentUsername || window.me?.username || '').toLowerCase(); 5664 const requester = (data.requester || '').toLowerCase(); 5665 if (myUsername && requester && myUsername === requester) { 5666 console.log(`ð Processing access update for current user: ${data.requester} -> ${data.username}
5666 = ${data.granted ? 'GRANTED' : 'DENIED'}`); 5667 5668 // Update the docked webcam display (owner granted/denied -> requester's panel updates) 5669 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.updateDockedWebcamDisplay === 'function') { 5670 console.log("ð [WEBCAM] Applying access update: dock for", data.username, "granted:", data.granted); 5671 window.chatCrackWebcam.updateDockedWebcamDisplay(data.username, data.granted); 5672 } else { 5673 console.error("â [WEBCAM] Webcam system not available for access update"); 5674 } 5675 } else if (window.WEBCAM_DEBUG) { 5676 console.log("ð [WEBCAM] Access update not for me: requester", data.requester, "!== me", (window.currentUsername || window.me?.username)); 5677 } 5678 return; 5679 } 5680 5681 // Update localStorage immediately 5682 // CRITICAL FIX: Preserve existing lock status unless explicitly provided in access update 5683 let isLocked = data.isLocked; 5684 const previousStatus = localStorage.getItem('webcam_status_' + data.username); 5685 let previousIsLocked = false; 5686 if (previousStatus) { 5687 try { 5688 const previous = JSON.parse(previousStatus); 5689 previousIsLocked = previous.isLocked || false; 5690 _chatLog(`ð¹ Previous lock for ${data.username}: ${previousIsLocked ? 'LOCKED' : 'UNLOCKED'}`); 5691 } catch (e) { 5692 console.error(`â Error parsing previous status:`, e); 5693 } 5694 } 5695 let prevVisModeEarly = 'public'; 5696 if (previousStatus) { 5697 try { 5698 prevVisModeEarly = JSON.parse(previousStatus).visibilityMode || 'public'; 5699 } catch (_) {} 5700 } 5701 const newVisModeEarly = data.visibilityMode || prevVisModeEarly; 5702 // Only boot viewers on an authoritative PPV switch â not when a late 5703 // joiner's localStorage was missing ppv (that looked like publicâppv). 5704 const ppvViewerReset = data.ppvResetViewers === true; 5705 const switchingToPpv = !!(data.isOn && newVisModeEarly === 'ppv' 5706 && prevVisModeEarly !== 'ppv' 5707 && (ppvViewerReset || data.visibilityChanged === true)); 5708 if (switchingToPpv || ppvViewerReset) { 5709 const ppvMe = window.currentUsername || (window.me && window.me.username); 5710 const ppvIsSelf = ppvMe && String(data.username).toLowerCase() === String(ppvMe).toLowerCase(); 5711 // Keep dock open and show chat Accept/Deny (shekels/min) instead of hard-undock. 5712 if (!ppvIsSelf && typeof window._ccPromptPpvKeepWatching === 'function') { 5713 window._ccPromptPpvKeepWatching(data.username); 5714 } else if (!ppvIsSelf && typeof window._ccCloseRemoteWebcamView === 'function') { 5715 window._ccCloseRemoteWebcamView(data.username, 'ppv_mode_switch'); 5716 } 5717 } 5718 const nowPpvLive = !!(data.isOn && newVisModeEarly === 'ppv'); 5719 if (nowPpvLive && !switchingToPpv && !ppvViewerReset) { 5720 const ppvMe2 = window.currentUsername || (window.me && window.me.username); 5721 const ppvSelf2 = ppvMe2 && String(data.username).toLowerCase() === String(ppvMe2).toLowerCase(); 5722 // Favourites never auto-open PPV; if somehow docked unpaid, prompt. 5723 if (!ppvSelf2 && typeof window._ccIsFavouriteCam === 'function' && window._ccIsFavouriteCam(data.username)) { 5724 if (typeof window._ccCancelWebcamAutoOpenForUser === 'function') { 5725 window._ccCancelWebcamAutoOpenForUser(data.username); 5726 } 5727 const favDocked = typeof window._ccIsViewingUserWebcam === 'function' 5728 && window._ccIsViewingUserWebcam(data.username); 5729 if (favDocked && typeof window._ccPromptPpvKeepWatching === 'function') { 5730 window._ccPromptPpvKeepWatching(data.username); 5731 } else if (!favDocked && typeof window._ccCloseRemoteWebcamView === 'function') { 5732 window._ccCloseRemoteWebcamView(data.username, 'ppv_mode'); 5733 } 5734 } 5735 } 5736 if (typeof window._ccGetRemoteWebcamLock === 'function' && window._ccGetRemoteWebcamLock(data.username)) {
5737 previousIsLocked = true; 5738 } 5739 if (typeof isLocked === 'boolean' && !data.accessUpdate) { 5740 if (isLocked === true) { 5741 if (typeof window._ccSetRemoteWebcamLock === 'function') { 5742 window._ccSetRemoteWebcamLock(data.username, true, true); 5743 } 5744 } else { 5745 if (typeof window._ccSetRemoteWebcamLock === 'function') { 5746 window._ccSetRemoteWebcamLock(data.username, false, true); 5747 } 5748 } 5749 } 5750 5751 if (typeof isLocked === 'undefined' && !data.accessUpdate) { 5752 // For non-access updates, preserve existing lock status 5753 const existingStatus = localStorage.getItem('webcam_status_' + data.username); 5754 if (existingStatus) { 5755 try { 5756 const existing = JSON.parse(existingStatus); 5757 isLocked = existing.isLocked; 5758 console.log(`ð Preserving existing lock status for ${data.username}: ${isLocked}`); 5759 } catch (e) { 5760 console.error(`â Error parsing existing status for ${data.username}:`, e); 5761 isLocked = false; 5762 } 5763 } else { 5764 isLocked = false; 5765 } 5766 } else if (typeof isLocked === 'undefined') { 5767 isLocked = false; 5768 } 5769 5770 // CRITICAL: Detect unlock transition (was locked, now unlocked) 5771 const wasLockedNowUnlocked = previousIsLocked === true && isLocked === false; 5772 // Detect lock transition (was unlocked, now locked) 5773 const wasUnlockedNowLocked = previousIsLocked === false && isLocked === true && data.isOn === true; 5774 _chatLog(`ð¹ Lock change for ${data.username}: ${previousIsLocked ? 'L' : 'U'} -> ${isLocked ? 'L' : 'U'}`); 5775 5776 // Invite-lock: kick every watcher even if Friends Only already 5777 // counted as "locked" (so unlockedâlocked would otherwise be false). 5778 const inviteLockKick = !!(data.inviteLockResetViewers === true 5779 || (data.lockChanged === true && data.isLocked === true)); 5780 if ((wasUnlockedNowLocked || inviteLockKick) && data.username !== window.currentUsername) { 5781 if (typeof window._ccCancelWebcamAutoOpenForUser === 'function') { 5782 try { window._ccCancelWebcamAutoOpenForUser(data.username); } catch (_) {} 5783 } 5784 if (typeof window._ccCloseRemoteWebcamView === 'function') { 5785 try { window._ccCloseRemoteWebcamView(data.username, 'invite_lock'); } catch (_) {} 5786 } else if (window.chatCrackWebcam && typeof window.chatCrackWebcam.removeDockedWebcam === 'function') { 5787 try { window.chatCrackWebcam.removeDockedWebcam(data.username, 'invite_lock'); } catch (_) {} 5788 } 5789 } 5790 5791 // ââ Featured user just locked their webcam ââ 5792 // Close the auto-opened featured dock â they've locked viewers out 5793 if (wasUnlockedNowLocked && typeof _ccIsFeaturedUsername === 'function' && _ccIsFeaturedUsername(data.username) 5794 && data.username !== window.currentUsername) { 5795 console.log(`â [Featured] ${data.username} locked their webcam â closing auto-opened dock`); 5796 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.removeDockedWebcam === 'function') { 5797 try { window.chatCrackWebcam.removeDockedWebcam(data.username, 'featured_locked'); } catch (_) {} 5798 } else { 5799 // Fallback: remove tile directly 5800 const panel = document.getElementById('webcamPanel'); 5801 if (panel) { 5802 const tile = panel.querySelector(`.webcam-dock[data-username="${data.username}"]`); 5803 if (tile) tile.remove(); 5804 } 5805 } 5806 } 5807 if (wasLockedNowUnlocked && newVisModeEarly !== 'ppv') { 5808 try { 5809 if (window._ccFavouriteAccessSent) window._ccFavouriteAccessSent.delete(String(data.username).toLowerCase()); 5810 } catch (_) {} 5811 _chatLog(`ð CAMERA UNLOCKED: ${data.username}`); 5812 5813 const unlockMe = window.currentUsername || (window.me && window.me.username); 5814 const unlockIsSelf = unlockMe && String(data.username).toLowerCase() === String(unlockMe).toLowerCase(); 5815 if (unlockIsSelf && window.chatCrackWebcam && window.chatCrackWebcam.webcamRequests) { 5816 const wc = window.chatCrackWebcam; 5817 const meLc = String(unlockMe).toLowerCase(); 5818 for (const [id, req] of [...wc.webcamRequests]) { 5819 if (req && req.status === 'pending' && String(req.to).toLowerCase() === meLc) { 5820 wc.webcamRequests.delete(id); 5821 } 5822 } 5823 if (typeof wc.refreshEyeballIcons === 'function') wc.refreshEyeballIcons(false); 5824 } 5825 5826 // CRITICAL FIX: Try multiple selectors to find the video element 5827 const panel = document.getElementById('webcamPanel'); 5828 let videoElement = null; 5829 let tile = null; 5830 5831 if (panel) { 5832 // Try finding by tile first 5833 tile = panel.querySelector(`.webcam-dock[data-username="${data.username}"]`); 5834 if (tile) { 5835 videoElement = tile.querySelector('video'); 5836 5837 } 5838 } 5839 5840 // Fallback: try direct video element selector 5841 if (!videoElement) { 5842 videoElement = document.querySelector(`.webcam-dock[data-username="${data.username}"] video`); 5843 if (videoElement) { 5844 5845 tile = videoElement.closest('.webcam-dock'); 5846 } 5847 } 5848 5849 // Another fallback: try by video ID 5850 if (!videoElement) { 5851 videoElement = document.getElementById(`docked-video-${data.username}`); 5852 if (videoElement) { 5853 // Only log in debug mode to reduce console clutter 5854 if (window.WEBCAM_DEBUG) { 5855 5856 } 5857 tile = videoElement.closest('.webcam-dock'); 5858 } 5859 } 5860 5861 if (videoElement) { 5862 // Only log in debug mode to reduce console clutter 5863 if (window.WEBCAM_DEBUG) { 5864 5865 5866 5867 5868 5869 5870 } 5871 5872 // Update lock state in dataset
5873 videoElement.dataset.isLocked = 'false'; 5874 5875 // CRITICAL: Make video element visible if it was hidden 5876 if (videoElement.style.display === 'none') { 5877 // Only log in debug mode to reduce console clutter 5878 if (window.WEBCAM_DEBUG) { 5879 5880 } 5881 videoElement.style.display = 'block'; 5882 videoElement.style.visibility = 'visible'; 5883 videoElement.style.opacity = '1'; 5884 } 5885 5886 // Check if video element has stream 5887 if (videoElement.srcObject && videoElement.srcObject.getTracks().length > 0) { 5888 // Only log in debug mode to reduce console clutter 5889 if (window.WEBCAM_DEBUG) { 5890 5891 5892 } 5893 5894 // CRITICAL: Force video to play if it's not playing. 5895 // Do NOT null srcObject â that forces a black flash even when the 5896 // track is still live. A play() nudge is enough. 5897 if (videoElement.readyState === 0 || videoElement.networkState === 3) { 5898 // Only log in debug mode to reduce console clutter 5899 if (window.WEBCAM_DEBUG) { 5900 5901 } 5902 const playPromise = videoElement.play(); 5903 if (playPromise !== undefined) { 5904 playPromise.catch(() => { 5905 videoElement.muted = true; 5906 const p2 = videoElement.play(); 5907 const finish = () => { 5908 setTimeout(() => { try { videoElement.muted = false; } catch (_) {} }, 250); 5909 }; 5910 if (p2 && typeof p2.then === 'function') p2.then(finish).catch(finish); 5911 else finish(); 5912 }); 5913 } 5914 } else { 5915 // Stream is ready, just ensure it's playing 5916 5917 const playPromise = videoElement.play(); 5918 if (playPromise !== undefined) { 5919 playPromise.catch(error => { 5920 5921 }); 5922 } 5923 } 5924 5925 // Update tile class to show it's active 5926 if (tile) { 5927 tile.classList.add('active'); 5928 tile.classList.remove('locked'); 5929 5930 } 5931 } else { 5932 5933 5934 5935 // Trigger stream consumption by calling dockUserWebcam again with unlocked state 5936 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.dockUserWebcam === 'function') { 5937 5938 window.chatCrackWebcam.dockUserWebcam(data.username, { locked: false, forceUpdate: true, userInitiated: true }); 5939 } else { 5940 } 5941 } 5942 } else { 5943 5944 5945 } 5946 } 5947 5948 const me = window.currentUsername || (window.me && window.me.username); 5949 const isSelf = me && String(data.username).toLowerCase() === String(me).toLowerCase(); 5950 const inActiveSet = typeof window._ccActiveWebcamHas === 'function' && window._ccActiveWebcamHas(data.username); 5951 const live = isSelf 5952 ? (typeof window._ccIsSelfWebcamLive === 'function' && window._ccIsSelfWebcamLive()) 5953 : (inActiveSet 5954 || (typeof window._ccIsUserAuthoritativelyLive === 'function' && window._ccIsUserAuthoritativelyLive(data.username))); 5955 const lockStateChanged = typeof isLocked === 'boolean' && previousIsLocked !== isLocked; 5956 const effectiveOn = !!(data.isOn && (live || lockStateChanged || data.lockChanged || inActiveSet)); 5957 if (typeof isLocked === 'boolean' && effectiveOn && typeof window._ccResolveWebcamLockForIcon === 'function') { 5958 isLocked = window._ccResolveWebcamLockForIcon(data.username, isLocked, 'webcam_update'); 5959 } else if (effectiveOn && typeof window._ccGetRemoteWebcamLock === 'function') { 5960 isLocked = window._ccGetRemoteWebcamLock(data.username); 5961 } 5962 5963 const hasVideoOffField = Object.prototype.hasOwnProperty.call(data, 'videoOff'); 5964 let prevVideoOff = false;
5965 let prevVideoOffMsg = ''; 5966 if (previousStatus) { 5967 try { 5968 const prevParsed = JSON.parse(previousStatus); 5969 prevVideoOff = !!prevParsed.videoOff; 5970 prevVideoOffMsg = String(prevParsed.videoOffMsg || prevParsed.cameraOffMessage || ''); 5971 } catch (_) {} 5972 } 5973 const videoOffNow = hasVideoOffField ? !!data.videoOff : prevVideoOff; 5974 const statusData = { 5975 username: data.username, 5976 isOn: effectiveOn, 5977 isLocked: effectiveOn ? isLocked : false, 5978 videoOff: videoOffNow, 5979 videoOffMsg: videoOffNow 5980 ? String((hasVideoOffField ? (data.videoOffMsg || data.cameraOffMessage) : prevVideoOffMsg) || prevVideoOffMsg || '').trim() 5981 : '', 5982 visibilityMode: data.visibilityMode || (previousStatus ? (function () { 5983 try { const p = JSON.parse(previousStatus); return p.visibilityMode; } catch (e) { return null; } 5984 })() : null) || 'public', 5985 ppvRatePerMin: data.ppvRatePerMin != null ? data.ppvRatePerMin : (previousStatus ? (function () { 5986 try { const p = JSON.parse(previousStatus); return p.ppvRatePerMin; } catch (e) { return null; } 5987 })() : null), 5988 broadcasterOnly: data.broadcasterOnly != null ? !!data.broadcasterOnly : (previousStatus ? (function () { 5989 try { return !!JSON.parse(previousStatus).broadcasterOnly; } catch (e) { return false; } 5990 })() : false), 5991 timestamp: data.timestamp || Date.now(), 5992 serverReceivedAt: Date.now() 5993 }; 5994 5995 const prevVisMode = previousStatus ? (function () { 5996 try { return JSON.parse(previousStatus).visibilityMode || 'public'; } catch (e) { return 'public'; } 5997 })() : 'public'; 5998 const newVisMode = statusData.visibilityMode || 'public'; 5999 6000 if (typeof window._ccWriteWebcamStatus === 'function') { 6001 window._ccWriteWebcamStatus(data.username, statusData); 6002 } else { 6003 localStorage.setItem('webcam_status_' + data.username, JSON.stringify(statusData)); 6004 } 6005 _chatLog(`ð¹ Updated localStorage for ${data.username}`); 6006 6007 if (typeof window._ccSetRemoteWebcamBroadcasterOnly === 'function') { 6008 try { window._ccSetRemoteWebcamBroadcasterOnly(data.username, !!statusData.broadcasterOnly); } catch (_) {} 6009 } 6010 6011 if (effectiveOn && !isSelf && prevVisMode !== newVisMode && typeof window._ccHandleWebcamVisibilityUpdate === 'function') { 6012 try { 6013 window._ccHandleWebcamVisibilityUpdate({ 6014 username: data.username, 6015 isOn: effectiveOn, 6016 visibilityMode: newVisMode, 6017 visibilityChanged: data.visibilityChanged === true, 6018 ppvResetViewers: data.ppvResetViewers === true, 6019 friendsOnlyResetViewers: data.friendsOnlyResetViewers === true 6020 }, prevVisMode); 6021 } catch (visErr) { console.warn('visibility update handler:', visErr); } 6022 } 6023 6024 if (effectiveOn && hasVideoOffField && window.chatCrackWebcam && typeof window.chatCrackWebcam.setRemoteCameraOffOverlay === 'function') { 6025 // Don't let a stale self-echo clear an intentional local Cam Off. 6026 if (isSelf && window.chatCrackWebcam.isCameraOff && !data.videoOff) { 6027 window.chatCrackWebcam.setRemoteCameraOffOverlay( 6028 data.username, 6029 true, 6030 window.chatCrackWebcam.cameraOffMessage || statusData.videoOffMsg || '' 6031 ); 6032 } else { 6033 window.chatCrackWebcam.setRemoteCameraOffOverlay(data.username, !!data.videoOff, statusData.videoOffMsg || ''); 6034 // Cam back on: nudge decode so BRBâlive does not stick black/frozen. 6035 if (!data.videoOff) { 6036 try { 6037 const tile = typeof window._ccFindWebcamDockTile === 'function' 6038 ? window._ccFindWebcamDockTile(data.username) 6039 : null; 6040 const v = tile && tile.querySelector('video'); 6041 if (v) v.play().catch(() => {}); 6042 const viewer = window.remoteWebcamViewers && ( 6043 window.remoteWebcamViewers[data.username]
6044 || Object.keys(window.remoteWebcamViewers || {}).reduce((hit, k) => { 6045 if (hit) return hit; 6046 return (typeof window._ccSameWebcamUser === 'function' && window._ccSameWebcamUser(k, data.username)) 6047 ? window.remoteWebcamViewers[k] 6048 : null; 6049 }, null) 6050 ); 6051 if (viewer && viewer.socket && viewer.socket.connected && viewer.roomId && viewer.consumers) { 6052 viewer.consumers.forEach((consumer) => { 6053 if (!consumer || consumer.closed || consumer.kind !== 'video') return; 6054 try { 6055 viewer.socket.emit('chatcrack:request-keyframe', { 6056 roomId: viewer.roomId, 6057 consumerId: consumer.id, 6058 producerUserId: data.username, 6059 }); 6060 } catch (_) {} 6061 }); 6062 } 6063 } catch (_) {} 6064 } 6065 } 6066 } 6067 6068 // Immediately update webcam icon for this user, using the publisher's 6069 // exact casing (data.username). dockUserWebcam() needs that casing 6070 // to join the correct `chatcrack_webcam_<username>` mediasoup room â 6071 // canonicalizing to roster casing here would create a casing 6072 // mismatch and the viewer would join an empty room (black stream). 6073 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.updateWebcamIconForUser === 'function') { 6074 window.chatCrackWebcam.updateWebcamIconForUser(data.username, effectiveOn, effectiveOn ? isLocked : false, 'webcam_update'); 6075 } 6076 6077 // Favourite cams: auto-open when unlocked/live; access request only on setting change. 6078 if (data.isOn && !isSelf && newVisMode !== 'ppv' && typeof _ccIsFavouriteCam === 'function' && _ccIsFavouriteCam(data.username)) { 6079 let previousWasOn = false; 6080 if (previousStatus) { 6081 try { previousWasOn = !!JSON.parse(previousStatus).isOn; } catch (_) {} 6082 } 6083 const wentLive = !previousWasOn; 6084 const visModeChanged = prevVisModeEarly !== newVisMode 6085 && (data.visibilityChanged === true || typeof data.visibilityMode === 'string'); 6086 const favDocked = _ccViewerHasDockedWebcam(data.username); 6087 6088 if (wasUnlockedNowLocked) { 6089 // Invite-lock: do not auto-request. Viewer must click the blue icon. 6090 } else if (visModeChanged && newVisMode !== 'friends_only' && newVisMode !== 'ppv' 6091 && (newVisMode !== 'public' || isLocked)) { 6092 _ccTryFavouriteLockedAccessRequest(data.username, { settingChange: true, reason: 'vis_mode_change' }); 6093 } 6094 6095 // Also reopen when already live (viewer joined late / boot raced). 6096 if (!favDocked) { 6097 const canOpenPublic = newVisMode === 'public' && !isLocked; 6098 const canOpenFriendsOnly = newVisMode === 'friends_only' && !isLocked; 6099 let shouldOpen = wentLive || wasLockedNowUnlocked; 6100 if (!shouldOpen && data.isOn && (canOpenPublic || canOpenFriendsOnly)) { 6101 // Throttle already-live recovery â heartbeats must not spam docks. 6102 if (!window._ccFavAlreadyLiveOpenAt) window._ccFavAlreadyLiveOpenAt = new Map(); 6103 const favLc = String(data.username || '').toLowerCase(); 6104 const nowFav = Date.now(); 6105 const lastFav = window._ccFavAlreadyLiveOpenAt.get(favLc) || 0; 6106 if (nowFav - lastFav >= 2500) { 6107 window._ccFavAlreadyLiveOpenAt.set(favLc, nowFav); 6108 shouldOpen = true; 6109 } 6110 } 6111 if (shouldOpen && (canOpenPublic || canOpenFriendsOnly)) { 6112 if (typeof _ccScheduleFavouriteAutoOpenWhenInRoom === 'function') { 6113 _ccScheduleFavouriteAutoOpenWhenInRoom(data.username, { 6114 aggressive: wentLive || wasLockedNowUnlocked,
6115 isLocked: false, 6116 recam: !!wentLive 6117 }); 6118 } 6119 } 6120 } 6121 if (typeof window._ccApplyFavouriteDockPlacementScheduled === 'function') { 6122 window._ccApplyFavouriteDockPlacementScheduled(); 6123 } 6124 } 6125 6126 // Featured never auto-opens. Favourite auto-open is handled above. 6127 6128 // Undock webcam immediately when the owner turns it off. 6129 // The old code had a 2-second grace period + stream-aliveness checks that 6130 // could leave stale docks. Watchdog restarts never send isOn:false, so 6131 // there is no risk of mistakenly undocking during a watchdog restart. 6132 if (!data.isOn && data.username !== window.currentUsername && window.chatCrackWebcam && typeof window.chatCrackWebcam.removeDockedWebcam === 'function') { 6133 const reason = (data.force_closed_by || data.forceClosedBy) ? 'force_closed' : 'owner_off'; 6134 if (reason !== 'force_closed') { 6135 // A soft isOn=false is not a cam stop. webcam:stopped closes the dock. 6136 } else { 6137 console.log(`ð webcam:update isOn=false for ${data.username} â undocking (${reason})`); 6138 // BUG FIX (stale webcam icon after CCAPP stop): 6139 // The earlier updateWebcamIconForUser(false) call above is short-circuited 6140 // by the "isDocked"/"isStreamActive" guard inside that function while the 6141 // dock is still mounted. As a result, the dock visibly closes here but 6142 // the camera icon next to the username stays lit indefinitely (TTL sweep 6143 // can't help â localStorage was already set to isOn:false by the time 6144 // it runs). Tear down the dock first, then force-clear the icon now that 6145 // the user is no longer in dockedWebcams and the <video> is gone. 6146 // 6147 // ALSO: register this user in _recentlyStoppedWebcams so the centralized 6148 // STOP GUARD inside updateWebcamIconForUser blocks any re-light attempts 6149 // from heartbeats / baseline polls / dual-socket echoes for the next 30s. 6150 try { 6151 if (!window._recentlyStoppedWebcams) window._recentlyStoppedWebcams = new Map(); 6152 window._recentlyStoppedWebcams.set(String(data.username).toLowerCase(), Date.now()); 6153 } catch (_) {} 6154 const finalizeIconClear = () => { 6155 try { 6156 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.updateWebcamIconForUser === 'function') { 6157 console.log(`ð§¹ Post-undock icon cleanup for ${data.username} (reason=${reason})`); 6158 window.chatCrackWebcam.updateWebcamIconForUser(data.username, false, false, 'webcam_update'); 6159 } 6160 } catch (e) { 6161 console.warn('Post-undock icon cleanup failed:', e); 6162 } 6163 }; 6164 try { 6165 const result = window.chatCrackWebcam.removeDockedWebcam(data.username, reason); 6166 if (result && typeof result.then === 'function') { 6167 result.then(finalizeIconClear, finalizeIconClear); 6168 } else { 6169 finalizeIconClear(); 6170 } 6171 } catch (e) { 6172 console.warn('removeDockedWebcam threw, still clearing icon:', e); 6173 finalizeIconClear(); 6174 } 6175 } 6176 } else if (!data.isOn && data.username === window.currentUsername) { 6177 _chatLog(`ð Webcam turned OFF for ${data.username} (current user)`); 6178 // CRITICAL FIX: If this is a force close (indicated by force_closed_by field), stop the webcam immediately 6179 const isForceClose = !!(data.force_closed_by || data.forceClosedBy); 6180 const wasForceClosed = window._lastForceCloseUsername === (window.currentUsername || window.me?.username); 6181 6182 // Only stop our own webcam on explicit force close (not "server says off") to avoid false stops from echoed/stale data 6183 if (isForceClose || wasForceClosed) { 6184 _chatLog(`ð Force close via webcam:update - stopping webcam`); 6185 window._lastForceCloseUsername = window.currentUsername || window.me?.username; 6186 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.stopWebcam === 'function') { 6187 window.chatCrackWebcam.stopWebcam(); 6188 setTimeout(() => { 6189 if (window.chatCrackWebcam.isWebcamOn) { 6190 console.error('â Force close: Webcam did not stop! Retrying...'); 6191 window.chatCrackWebcam.stopWebcam(); 6192 } 6193 }, 500); 6194 } 6195 } 6196 // Don't remove docked webcams I'm viewing when my own webcam is turned off 6197 } 6198 6199 // Update global persistent data 6200 if (data.isOn) { 6201 if (!window.persistentWebcamIconData) window.persistentWebcamIconData = []; 6202 const existingIndex = window.persistentWebcamIconData.findIndex(item => item.username === data.username); 6203 if (existingIndex >= 0) { 6204 window.persistentWebcamIconData[existingIndex] = { username: data.username, isLocked: isLocked }; 6205 } else { 6206 window.persistentWebcamIconData.push({ username: data.username, isLocked: isLocked }); 6207 } 6208 } else { 6209 if (window.persistentWebcamIconData) { 6210 window.persistentWebcamIconData = window.persistentWebcamIconData.filter(item => item.username !== data.username); 6211 } 6212 } 6213 }); 6214 6215 // Handle force close webcam events (backup - webcam:force_stop_command is the primary handler) 6216 // This just sets the flag - the primary handler does the actual stop 6217 _ws.on("webcam:force_closed", (data) => { 6218 if (!data || !data.target_username) { 6219 console.error("â Invalid force close webcam data received:", data); 6220 return; 6221 } 6222 6223 // If this is the target user, set flag for other handlers and show notification 6224 if (data.target_username === (window.currentUsername || window.me?.username)) { 6225 console.log(`ð¹ Force close notification received from ${data.closed_by || 'an administrator'}`); 6226 window._lastForceCloseUsername = data.target_username; 6227 6228 // Show notification 6229 if (typeof showToast === 'function') { 6230 showToast(`Your webcam was force closed by ${data.closed_by || 'an administrator'}
6230`, 'warning'); 6231 } 6232 } 6233 }); 6234 6235 // Webcam heartbeat to keep status fresh 6236 _ws.on("webcam:beat", (data) => { 6237 // Heartbeat received - no logging to reduce console clutter 6238 6239 // CRITICAL FIX: Validate data and prevent cross-contamination 6240 if (!data || !data.username) { 6241 console.error("â Invalid heartbeat data received:", data); 6242 return; 6243 } 6244 6245 // A heartbeat is proof of life â keep the authoritative active-set 6246 // mirror up to date in case we missed the original webcam:started. 6247 try { if (typeof window._ccActiveWebcamAdd === 'function') window._ccActiveWebcamAdd(data.username); } catch (_) {} 6248 6249 // CRITICAL FIX: Only ignore heartbeat when it's our own session (same username AND we're actually streaming). 6250 // When same username is in two sessions (e.g. Dolaf with webcam + Dolaf as viewer), the viewer must 6251 // process heartbeats from the streamer so the icon stays visible and isn't cleared as "stale". 6252 const isCurrentUser = data.username === (window.currentUsername || 'Dolaf'); 6253 const weAreStreaming = !!(window.chatCrackWebcam && window.chatCrackWebcam.isLocalStreamActive && window.chatCrackWebcam.isLocalStreamActive()); 6254 if (isCurrentUser && weAreStreaming) { 6255 console.log(`ð Ignoring heartbeat for current user ${data.username} (handled locally)`); 6256 return; 6257 } 6258 6259 // console.log(`ð Processing heartbeat for OTHER user: ${data.username}`); 6260 6261 // Update timestamp to keep status fresh (or create status if this is first beat from other session). 6262 // Read via the case-insensitive helper so we don't accidentally create a NEW 6263 // duplicate-cased key when an existing entry already exists with different casing. 6264 const readKey = (typeof window._ccWebcamStatusStorageKey === 'function') 6265 ? window._ccWebcamStatusStorageKey(data.username) 6266 : ('webcam_status_' + data.username); 6267 const existingStatus = localStorage.getItem(readKey); 6268 let status; 6269 try { 6270 status = existingStatus ? JSON.parse(existingStatus) : { isOn: true, isLocked: false, timestamp: 0 }; 6271 if (!status.isOn) status = { ...status, isOn: true }; 6272 status.timestamp = Date.now(); 6273 status.serverReceivedAt = Date.now(); 6274 if (data.isLocked === true && typeof window._ccSetRemoteWebcamLock === 'function') { 6275 window._ccSetRemoteWebcamLock(data.username, true, true);
6276 status.isLocked = true; 6277 } else if (data.isLocked === false && typeof window._ccSetRemoteWebcamLock === 'function') { 6278 window._ccSetRemoteWebcamLock(data.username, false, true); 6279 status.isLocked = false; 6280 } else { 6281 status.isLocked = typeof window._ccGetRemoteWebcamLock === 'function' 6282 ? window._ccGetRemoteWebcamLock(data.username) 6283 : !!(status.isLocked); 6284 } 6285 if (typeof window._ccWriteWebcamStatus === 'function') { 6286 window._ccWriteWebcamStatus(data.username, status); 6287 } else { 6288 localStorage.setItem('webcam_status_' + data.username, JSON.stringify(status)); 6289 } 6290 6291 if (window.chatCrackWebcam && window.chatCrackWebcam.updateWebcamIconForUser) { 6292 requestAnimationFrame(() => { 6293 window.chatCrackWebcam.updateWebcamIconForUser(data.username, true, status.isLocked, 'webcam_beat'); 6294 }); 6295 } 6296 } catch (e) { 6297 console.error(`â Error updating heartbeat for ${data.username}:`, e); 6298 } 6299 }); 6300 6301 _ws.on("webcam:request", (data) => { 6302 const myUsername = (window.currentUsername || window.me?.username || '').toLowerCase(); 6303 6304 // Validate data structure 6305 if (!data || !data.requester || !data.target) { 6306 return; 6307 } 6308 6309 // Only process if this user is the target (someone is requesting access to YOUR webcam). 6310 // The server now broadcasts webcam:request to every client (robust against stale/ 6311 // background sockets), so bail out silently for everyone who isn't the owner â no 6312 // console spam for the whole room on each request. 6313 const targetLower = (data.target || '').toLowerCase(); 6314 if (targetLower !== myUsername) { 6315 return; 6316 } 6317 6318 if (!data.status) data.status = 'pending'; 6319 console.log("ð [WEBCAM] webcam:request received:", { requester: data?.requester, target: data?.target, me: myUsername }); 6320 6321 // Permission prompts are locked-cam (orange) only. The MEDIA SERVER 6322 // already enforces this: it ONLY broadcasts webcam:request for cams it 6323 // knows are LOCKED â public/green cams get a request_rejected and never 6324 // reach this handler. So if a request addressed to us got here, our cam 6325 // is locked per the authoritative server state. 6326 // 6327 // Therefore we must NOT re-gate on the local `isWebcamLocked` flag: on 6328 // throttled/background tabs (Edge Tracking Prevention, mobile Safari) 6329 // that flag can be stale/false, which previously swallowed the prompt 6330 // so the owner never saw it. Require only that our cam is actually 6331 // live; trust the server for the orange-vs-green decision. This is the 6332 // "show the prompt regardless of their browser" fix. 6333 const wcEarly = window.chatCrackWebcam; 6334 const myCamLive = !!(wcEarly && (wcEarly.isWebcamOn || 6335 (typeof window._ccIsSelfWebcamLive === 'function' && window._ccIsSelfWebcamLive()))); 6336 if (!myCamLive) { 6337 console.log(`ð [WEBCAM] Ignoring access request from ${data.requester} â my cam is not live`); 6338 if (wcEarly && typeof wcEarly._clearWebcamRequestsBetween === 'function') { 6339 wcEarly._clearWebcamRequestsBetween(data.requester, data.target); 6340 } 6341 if (wcEarly && typeof wcEarly.updateEyeballIconForUser === 'function') { 6342 wcEarly.updateEyeballIconForUser(data.requester); 6343 } else if (wcEarly && typeof wcEarly.refreshEyeballIcons === 'function') { 6344 wcEarly.refreshEyeballIcons(false); 6345 } 6346 return; 6347 } 6348 6349 console.log(`ð Processing access request: ${data.requester} is requesting access to ${data.target}'s webcam`); 6350 6351 if (typeof window._ccRecordWebcamAccessPending === 'function') { 6352 window._ccRecordWebcamAccessPending(data.requester, data.target); 6353 } 6354 if (typeof window._ccRefreshPendingFriendRequestsBadge === 'function') { 6355 window._ccRefreshPendingFriendRequestsBadge(); 6356 } 6357 6358 // Store the pending request in the webcam system. Dedupe first: 6359 // a single requester can fire `webcam:request` multiple times 6360 // (rate-limit retries, reconnects re-broadcasting, etc.), and
6361 // leaving multiple pending entries makes the grant/deny path 6362 // brittle (it has to know to clear every one of them, and any 6363 // missed entry keeps the yellow eyeball lit). Lowercase 6364 // comparison handles roster/session casing drift consistently 6365 // with the rest of the eyeball system. 6366 const wc = window.chatCrackWebcam; 6367 if (wc && wc.webcamRequests) { 6368 const reqLc = wc._lcUser(data.requester); 6369 const tgtLc = wc._lcUser(data.target); 6370 let alreadyPending = false; 6371 for (const [, existing] of wc.webcamRequests) { 6372 if (!existing || existing.status !== 'pending') continue; 6373 if (wc._lcUser(existing.from) === reqLc && wc._lcUser(existing.to) === tgtLc) { 6374 alreadyPending = true; 6375 break; 6376 } 6377 } 6378 if (alreadyPending) { 6379 console.log(`ð Duplicate access request from ${data.requester} ignored â pending entry already exists`); 6380 if (typeof wc.updateEyeballIconForUser === 'function') { 6381 wc.updateEyeballIconForUser(data.requester); 6382 } else { 6383 wc.refreshEyeballIcons(false); 6384 } 6385 } else { 6386 const requestId = `webcam_request_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; 6387 const requestData = { 6388 id: requestId, 6389 from: data.requester, 6390 to: data.target, 6391 timestamp: data.timestamp, 6392 status: data.status 6393 }; 6394 wc.webcamRequests.set(requestId, requestData); 6395 console.log(`ð Stored pending request:`, { requestId, requestData, totalRequests: wc.webcamRequests.size }); 6396 if (typeof wc.updateEyeballIconForUser === 'function') { 6397 wc.updateEyeballIconForUser(data.requester); 6398 } else { 6399 wc.refreshEyeballIcons(false); 6400 } 6401 } 6402 } else { 6403 console.error("â Webcam system not available for access request storage"); 6404 } 6405 6406 // Proactive chat announcement with green Accept / red Deny. 6407 // Invite-lock must NEVER auto-grant friends â they were just kicked 6408 // and have to wait like everyone else. 6409 const myName = window.currentUsername || (window.me && window.me.username) || data.target; 6410 const showPrompt = () => { 6411 if (typeof window._ccAppendWebcamAccessChatPrompt === 'function') { 6412 window._ccAppendWebcamAccessChatPrompt(data.requester, myName); 6413 } 6414 }; 6415 showPrompt(); 6416 }); 6417 6418 if (typeof window._ccWireWebcamViewInviteSocket === 'function') { 6419 try { window._ccWireWebcamViewInviteSocket(_ws); } catch (_) {} 6420 } 6421 6422 // Server rejected access request because target cam is not manually locked. 6423 _ws.on('webcam:request_rejected', (data) => { 6424 if (!data || !data.target) return; 6425 const me = (window.me?.username || window.currentUsername || '').toLowerCase(); 6426 if (!data.requester || String(data.requester).toLowerCase() !== me) return; 6427 if (data.reason === 'not_locked') {
6428 console.log(`ð¹ Access request rejected (not locked) â routing ${data.target} through visibility gate`); 6429 if (typeof window._ccIsInviteLockActive === 'function' && window._ccIsInviteLockActive(data.target)) { 6430 return; 6431 } 6432 if (typeof window._ccOpenWebcamWithVisibility === 'function' && window.chatCrackWebcam) { 6433 window._ccOpenWebcamWithVisibility(data.target, window.chatCrackWebcam); 6434 } else if (window.chatCrackWebcam && typeof window.chatCrackWebcam.viewUserWebcam === 'function') { 6435 window.chatCrackWebcam.viewUserWebcam(data.target, false, { userInitiated: true }); 6436 } 6437 } else if (data.reason === 'broadcaster_only') { 6438 if (typeof window._ccGuardBroadcasterOnlyView === 'function') { 6439 window._ccGuardBroadcasterOnlyView(data.target); 6440 } else if (typeof showToast === 'function') { 6441 showToast('Turn your cam on to view this cam.', 'error'); 6442 } 6443 } 6444 }); 6445 6446 _ws.on('webcam:viewer_added_rejected', (data) => { 6447 if (!data || !data.target) return; 6448 const me = (window.me?.username || window.currentUsername || '').toLowerCase(); 6449 if (!data.viewer || String(data.viewer).toLowerCase() !== me) return; 6450 if (data.reason === 'broadcaster_only') { 6451 if (typeof window._ccCloseRemoteWebcamView === 'function') { 6452 window._ccCloseRemoteWebcamView(data.target, 'broadcaster_only'); 6453 } 6454 if (typeof window._ccGuardBroadcasterOnlyView === 'function') { 6455 window._ccGuardBroadcasterOnlyView(data.target); 6456 } 6457 } 6458 }); 6459 6460 // Webcam access responses 6461 _ws.on("webcam:response", (data) => { 6462 console.log("ð¡ WebSocket webcam response received:", data); 6463 // TODO: Handle webcam access responses 6464 }); 6465 6466 // CRITICAL FIX: Access updates are now handled by webcam:update events 6467 // This ensures proper backend broadcasting to all users 6468 6469 _chatLog("â Webcam WebSocket event listeners set up"); 6470 6471 // Apply server-initiated ICE restarts to the correct transport 6472 _ws?.on?.('server:ice-restart', async ({ transportId, roomId, iceParameters }) => { 6473 try { 6474 const w = window.chatCrackWebRTC; 6475 if (!w) return; 6476 6477 const t = 6478 (w.sendTransport && w.sendTransport.id === transportId && w.sendTransport) || 6479 (w.recvTransport && w.recvTransport.id === transportId && w.recvTransport) || 6480 null; 6481 6482 if (t && typeof t.restartIce === 'function') { 6483 await t.restartIce({ iceParameters }); 6484 _chatLog('[WebRTC] ð Applied ICE restart for transport', transportId); 6485 } else { 6486 console.warn('[WebRTC] ICE restart: transport not found', transportId); 6487 } 6488 } catch (e) { 6489 console.error('[WebRTC] ICE restart apply failed:', e); 6490 } 6491 }); 6492 6493 }, 300); 6494 } 6495 6496 window._ccBindWebcamWhenReady = function () { 6497 var s = window._ccPendingWebcamSocket || window.socket; 6498 if (!s || !window.chatCrackWebcam) return; 6499 window.chatCrackWebcam.socket = s; 6500 }; 6501 6502 // After WS "connected" - Initialize YouTube Controller 6503 YouTubeCtrl.connectWS(); 6504 6505 // Socket.IO already has reconnectionAttempts: Infinity â do NOT stack a second 6506 // reconnect loop on every disconnect (that caused connect storms / random flaps). 6507 try { 6508 socket.io.on('reconnect_failed', () => { 6509 try { 6510 if (socket && !socket.connected && typeof socket.connect === 'function') socket.connect(); 6511 } catch (_) {} 6512 }); 6513 } catch (_) {} 6514 6515 socket.on("disconnect", (reason) => { 6516 if (window.socket !== socket) return; 6517 if (reason === 'io client disconnect') window._wsUserLoggedOut = true; 6518 ccSyncWsStatusIndicator(); 6519 _chatLog('ð´ WebSocket disconnected:', reason); 6520 window._messagePollIntervalMs = 8000; 6521 if (typeof window.restartMessagePollInterval === 'function') window.restartMessagePollInterval(); 6522 if (typeof window._scheduleClientHeartbeat === 'function') window._scheduleClientHeartbeat(); 6523 if (typeof window._ccRefreshPollIntervals === 'function') window._ccRefreshPollIntervals(); 6524 try { if (typeof poll === 'function') poll(); } catch (_) {} 6525 try { window.chatCrackWebRTC?.stopAudioBitrateMonitoring?.(); } catch (_) {} 6526 if (window._producerPollTimer) { 6527 clearInterval(window._producerPollTimer); 6528 window._producerPollTimer = null; 6529 _chatLog('[ProducerPoll] Stopped producer list polling on disconnect'); 6530 } 6531 // Let Socket.IO's built-in reconnection handle recovery. 6532 }); 6533 6534 socket.on("connect", () => { 6535 if (window.socket === socket) updateWsStatusIndicator(true); 6536 }); 6537 6538 // Example downstream events 6539 socket.on("webcam:status", (msg) => { 6540 console.log("ð¡ WS webcam:status", msg); 6541 try { 6542 if (typeof window.ccNudgeShekelLeaderboard === 'function') window.ccNudgeShekelLeaderboard(false); 6543 } catch (_) {} 6544 }); 6545 socket.on("webcam:started", () => { 6546 try { 6547 if (typeof window.ccNudgeShekelLeaderboard === 'function') window.ccNudgeShekelLeaderboard(false); 6548 } catch (_) {} 6549 });
6550 socket.on("webcam:stopped", () => { 6551 try { 6552 if (typeof window.ccNudgeShekelLeaderboard === 'function') window.ccNudgeShekelLeaderboard(false); 6553 } catch (_) {} 6554 }); 6555 socket.on("openmic:started", () => { 6556 try { 6557 if (typeof window.ccNudgeShekelLeaderboard === 'function') window.ccNudgeShekelLeaderboard(false); 6558 } catch (_) {} 6559 }); 6560 socket.on("openmic:stopped", () => { 6561 try { 6562 if (typeof window.ccNudgeShekelLeaderboard === 'function') window.ccNudgeShekelLeaderboard(false); 6563 } catch (_) {} 6564 }); 6565 6566 // NEW: Handle webcam viewer tracking for eyeball icons (available to ALL users). 6567 // 6568 // Casing rule: everything that goes into / comes out of `webcamViewers` is 6569 // funneled through `.toLowerCase()`. The wire format preserves the 6570 // publisher's session casing for display, but the Set itself is a single 6571 // case-normalised namespace so a roster/publisher casing drift can't make 6572 // `webcamViewers.has(u.username)` silently return false at render time 6573 // (the long-standing "no green eyeball even though they're watching" bug). 6574 socket.on("webcam:viewer_added", (data) => { 6575 if (!data || !data.viewer || !data.target) return; 6576 if (typeof window._ccIsAiBotUsername === 'function' && window._ccIsAiBotUsername(data.viewer)) return; 6577 const me = (window.me && window.me.username) || window.currentUsername || ''; 6578 if (String(data.target).toLowerCase() !== String(me).toLowerCase()) return; 6579 6580 const wc = window.chatCrackWebcam; 6581 const camLive = !!(wc && wc.isWebcamOn) 6582 || (typeof window._ccIsSelfWebcamLive === 'function' && window._ccIsSelfWebcamLive()) 6583 || !!(wc && typeof wc._isMyWebcamLiveForEyeballs === 'function' && wc._isMyWebcamLiveForEyeballs()); 6584 if (!camLive) { 6585 // Stash now; startWebcam paints these the instant we go live. 6586 if (wc) { 6587 if (!wc.webcamViewers) wc.webcamViewers = new Set(); 6588 wc.webcamViewers.add(String(data.viewer).toLowerCase()); 6589 wc._pendingEyeballPaint = true; 6590 } 6591 return; 6592 } 6593 6594 _chatLog(`ðï¸ Viewer added: ${data.viewer} is now viewing your webcam`); 6595 if (typeof window._ccNudgePublisherKeyframes === 'function') { 6596 window._ccNudgePublisherKeyframes(); 6597 } 6598 if (wc) { 6599 if (typeof wc.addWebcamViewer === 'function') { 6600 wc.addWebcamViewer(data.viewer); 6601 } else if (wc.webcamViewers) { 6602 const viewerLc = String(data.viewer).toLowerCase(); 6603 wc.webcamViewers.add(viewerLc); 6604 if (wc.declinedRequests) wc.declinedRequests.delete(viewerLc); 6605 if (typeof wc.updateEyeballIconForUser === 'function') { 6606 wc.updateEyeballIconForUser(data.viewer); 6607 } else { 6608 wc.refreshEyeballIcons(false); 6609 } 6610 } 6611 // Extra immediate paint â don't wait for schedule retries. 6612 try { 6613 if (typeof wc.updateEyeballIconForUser === 'function') { 6614 wc.updateEyeballIconForUser(data.viewer); 6615 } 6616 } catch (_) {} 6617 if (typeof wc._clearWebcamRequestsBetween === 'function') { 6618 wc._clearWebcamRequestsBetween(data.viewer, me); 6619 } 6620 } 6621 }); 6622 6623 socket.on("webcam:viewer_removed", (data) => { 6624 _chatLog("ðï¸ WebSocket webcam viewer removed received:", data); 6625 6626 if (!data || !data.viewer || !data.target) { 6627 console.error("â Invalid webcam:viewer_removed data received:", data); 6628 return; 6629 } 6630 if (typeof window._ccMediaHoldActive === 'function' && window._ccMediaHoldActive()) return; 6631 const me = (window.currentUsername || window.me?.username || '').toLowerCase(); 6632 if (String(data.target).toLowerCase() !== me) return; 6633 6634 _chatLog(`ðï¸ Viewer removed: ${data.viewer} stopped viewing ${data.target}`); 6635 6636 if (window.chatCrackWebcam) { 6637 const wc = window.chatCrackWebcam; 6638 if (typeof wc.removeWebcamViewer === 'function') { 6639 wc.removeWebcamViewer(data.viewer); 6640 } else if (wc.webcamViewers) { 6641 window.chatCrackWebcam.webcamViewers.delete(String(data.viewer).toLowerCase()); 6642 if (typeof wc.updateEyeballIconForUser === 'function') { 6643 wc.updateEyeballIconForUser(data.viewer); 6644 } else { 6645 wc.refreshEyeballIcons(false); 6646 } 6647 } 6648 // Immediate second paint in case roster raced the first remove.
6649 try { 6650 if (typeof wc.updateEyeballIconForUser === 'function') { 6651 wc.updateEyeballIconForUser(data.viewer); 6652 } 6653 } catch (_) {} 6654 } 6655 }); 6656 6657 // webcam:closure event listener already set up in main WebSocket connection 6658 6659 socket.on('webcam:ppv_boot', (data) => { 6660 if (!data || !data.broadcaster) return; 6661 const me = window.currentUsername || (window.me && window.me.username); 6662 if (me && String(data.broadcaster).toLowerCase() === String(me).toLowerCase()) return; 6663 if (typeof window._ccPromptPpvKeepWatching === 'function') { 6664 window._ccPromptPpvKeepWatching(data.broadcaster); 6665 } else if (typeof window._ccCloseRemoteWebcamView === 'function') { 6666 window._ccCloseRemoteWebcamView(data.broadcaster, 'ppv_mode_switch'); 6667 } 6668 }); 6669 6670 socket.on('webcam:invite_lock_boot', (data) => { 6671 if (!data || !data.broadcaster) return; 6672 const me = window.currentUsername || (window.me && window.me.username); 6673 if (me && String(data.broadcaster).toLowerCase() === String(me).toLowerCase()) return; 6674 if (typeof window._ccCancelWebcamAutoOpenForUser === 'function') { 6675 try { window._ccCancelWebcamAutoOpenForUser(data.broadcaster); } catch (_) {} 6676 } 6677 if (typeof window._ccCloseRemoteWebcamView === 'function') { 6678 window._ccCloseRemoteWebcamView(data.broadcaster, 'invite_lock'); 6679 } 6680 }); 6681 6682 socket.on('webcam:friends_only_boot', (data) => { 6683 if (!data || !data.broadcaster) return; 6684 const me = window.currentUsername || (window.me && window.me.username); 6685 if (me && String(data.broadcaster).toLowerCase() === String(me).toLowerCase()) return; 6686 if (typeof window._ccCancelWebcamAutoOpenForUser === 'function') { 6687 try { window._ccCancelWebcamAutoOpenForUser(data.broadcaster); } catch (_) {} 6688 } 6689 if (typeof window._ccCloseRemoteWebcamView === 'function') { 6690 window._ccCloseRemoteWebcamView(data.broadcaster, 'friends_only_switch'); 6691 } 6692 }); 6693 6694 socket.on('webcam:broadcaster_only_boot', (data) => { 6695 if (!data || !data.broadcaster) return; 6696 const me = window.currentUsername || (window.me && window.me.username); 6697 if (me && String(data.broadcaster).toLowerCase() === String(me).toLowerCase()) return; 6698 if (typeof window._ccCloseRemoteWebcamView === 'function') { 6699 window._ccCloseRemoteWebcamView(data.broadcaster, 'broadcaster_only'); 6700 } 6701 if (typeof showToast === 'function') { 6702 const name = (typeof window.ccRosterDisplayName === 'function') 6703 ? window.ccRosterDisplayName(data.broadcaster) : data.broadcaster; 6704 showToast(name + ' only allows other broadcasters to view. Turn your cam on first.', 'error'); 6705 } 6706 }); 6707 6708 // ââ Eyeball: server sends full viewer list on connect (initial sync) ââ 6709 socket.on('webcam:my_viewers', (data) => { 6710 if (!data || !Array.isArray(data.viewers) || !window.chatCrackWebcam) return; 6711 if (typeof window._ccMediaHoldActive === 'function' && window._ccMediaHoldActive() 6712 && data.exact === true && data.viewers.length === 0) { 6713 data = Object.assign({}, data, { exact: false }); 6714 } 6715 const wc = window.chatCrackWebcam; 6716 const camLive = !!(wc.isWebcamOn) 6717 || (typeof window._ccIsSelfWebcamLive === 'function' && window._ccIsSelfWebcamLive()); 6718 if (!camLive) { 6719 if (!window._eyeballNotLiveSince) window._eyeballNotLiveSince = Date.now(); 6720 // Only hard-clear on exact sync when the cam has been off long enough. 6721 // Immediate exact clears during a 1-tick live-check blip wiped every 6722 // green eyeball mid-watch (blink). 6723 const offMs = Date.now() - (window._eyeballNotLiveSince || Date.now()); 6724 if (data.exact === true && offMs >= 800) { 6725 try { 6726 if (wc.webcamViewers) { 6727 Array.from(wc.webcamViewers).forEach((vlc) => { 6728 if (typeof wc.removeWebcamViewer === 'function') wc.removeWebcamViewer(vlc); 6729 }); 6730 } 6731 } catch (_) {} 6732 if (typeof wc._clearWebcamViewerStateIfOffline === 'function') { 6733 wc._clearWebcamViewerStateIfOffline({ immediate: true }); 6734 } else if (typeof wc.refreshEyeballIcons === 'function') { 6735 wc.refreshEyeballIcons(false); 6736 } 6737 } else if (typeof wc._clearWebcamViewerStateIfOffline === 'function') { 6738 wc._clearWebcamViewerStateIfOffline(); 6739 } 6740 // If server still lists viewers and we only blipped offline, keep merging. 6741 if (data.viewers.length && offMs < 800 && typeof wc.mergeWebcamViewersFromServer === 'function') { 6742 wc.mergeWebcamViewersFromServer(data.viewers, { exact: false }); 6743 } 6744 return; 6745 } 6746 window._eyeballNotLiveSince = 0; 6747 if (typeof wc.mergeWebcamViewersFromServer === 'function') { 6748 // exact:true after add/remove â drop closed viewers instantly. 6749 wc.mergeWebcamViewersFromServer(data.viewers, { exact: data.exact === true }); 6750 } else if (wc.webcamViewers) {
6751 data.viewers.forEach(v => { 6752 if (v == null) return; 6753 if (typeof window._ccIsAiBotUsername === 'function' && window._ccIsAiBotUsername(v)) return; 6754 wc.webcamViewers.add(String(v).toLowerCase()); 6755 }); 6756 wc.refreshEyeballIcons(); 6757 } 6758 _chatLog(`ðï¸ Initial viewer sync: ${data.viewers.length} viewer(s)`); 6759 }); 6760 6761 socket.on('favourite:cams_sync_request', () => { 6762 if (typeof window._ccOnFavouriteCamsSyncRequest === 'function') { 6763 window._ccOnFavouriteCamsSyncRequest(); 6764 } 6765 }); 6766 6767 socket.on('favourite:cams_updated', () => { 6768 if (typeof window._ccRefreshFavCamsStaffIfOpen === 'function') { 6769 window._ccRefreshFavCamsStaffIfOpen(); 6770 } 6771 }); 6772 6773 // ââ Eyeball: you got blocked from viewing someone's webcam ââââââââââââ 6774 socket.on('webcam:viewer_blocked', (data) => { 6775 if (!data || !data.blocker) return; 6776 _chatLog(`ð« ${data.blocker} blocked you from viewing their webcam for 30 minutes`); 6777 const wc = window.chatCrackWebcam; 6778 const blocker = data.blocker; 6779 const lc = String(blocker).toLowerCase(); 6780 if (wc) { 6781 let dockedKey = null; 6782 const dw = wc.dockedWebcams; 6783 if (dw) { 6784 if (dw.has(blocker)) dockedKey = blocker; 6785 else { 6786 for (const k of dw) { if (String(k).toLowerCase() === lc) { dockedKey = k; break; } } 6787 } 6788 } 6789 if (dockedKey && typeof wc.removeDockedWebcam === 'function') { 6790 wc.removeDockedWebcam(dockedKey, 'viewer_blocked'); 6791 } else if (dockedKey && typeof wc.undockUserWebcam === 'function') { 6792 wc.undockUserWebcam(dockedKey); 6793 if (wc.dockedWebcams) wc.dockedWebcams.delete(dockedKey); 6794 } 6795 if (typeof wc.refreshEyeballIcons === 'function') wc.refreshEyeballIcons(); 6796 } 6797 try { 6798 if (!window.webcamBlockedByUntil) window.webcamBlockedByUntil = new Map(); 6799 const expires = Date.now() + (Number(data.remaining_ms) || 30 * 60 * 1000); 6800 window.webcamBlockedByUntil.set(lc, expires); 6801 } catch (_) {} 6802 const left = ccWebcamBlockRemainingMs(blocker); 6803 const msg = `${blocker} blocked you from viewing their webcam (${formatWebcamBlockRemaining(left)} left)`; 6804 if (typeof showToast === 'function') showToast(msg, 'warning'); 6805 else alert(msg); 6806 }); 6807 6808 // ââ Live unblock: the publisher removed their block on you ââââââââââââ 6809 // Clear the local block so clicking their cam works again immediately (no 6810 // refresh needed) and pop a top-right notice telling them they can re-click. 6811 socket.on('webcam:viewer_unblocked', (data) => { 6812 if (!data || !data.blocker) return; 6813 const blocker = data.blocker; 6814 const lc = String(blocker).toLowerCase(); 6815 // 1) Drop the view-block so ccIsBlockedFromViewingWebcam() is false again. 6816 try { 6817 if (window.webcamBlockedByUntil) window.webcamBlockedByUntil.delete(lc); 6818 if (window.webcamViewBlockUntil) window.webcamViewBlockUntil.delete(lc); 6819 } catch (_) {} 6820 const wc = window.chatCrackWebcam; 6821 // 2) Clear leftover request gates so the very NEXT click goes through with 6822 // zero delay. Without this, an old 'pending'/'declined' request entry 6823 // (still inside the 120s prompt TTL â e.g. publisher unblocks 30s after 6824 // declining) makes the click say "already pending", and the per-user 15s 6825 // request rate-limit can swallow it too. Neither should apply after an 6826 // explicit unblock â they asked to let this person back in instantly. 6827 try { 6828 if (wc && wc.webcamRequests) { 6829 for (const [id, req] of wc.webcamRequests) { 6830 if (req && String(req.to || '').toLowerCase() === lc) wc.webcamRequests.delete(id); 6831 } 6832 } 6833 } catch (_) {} 6834 try { 6835 const prefix = ('webcam_request_rate_limit_' + blocker).toLowerCase(); 6836 for (let i = localStorage.length - 1; i >= 0; i--) { 6837 const k = localStorage.key(i); 6838 if (k && k.toLowerCase() === prefix) localStorage.removeItem(k); 6839 } 6840 } catch (_) {} 6841 // 3) Refresh icons so the publisher's cam icon is immediately clickable. 6842 if (wc && typeof wc.refreshEyeballIcons === 'function') {
6843 try { wc.refreshEyeballIcons(); } catch (_) {} 6844 } 6845 if (wc && typeof wc.updateEyeballIconForUser === 'function') { 6846 try { wc.updateEyeballIconForUser(blocker); } catch (_) {} 6847 } 6848 _chatLog(`â ${blocker} unblocked you from their webcam`); 6849 const msg = `${blocker} has now unblocked you from their cam â you can click again`; 6850 if (typeof showToast === 'function') showToast(msg, 'success'); 6851 else alert(msg); 6852 }); 6853 6854 // ââ Eyeball: server confirms your block went through ââââââââââââââââââ 6855 socket.on('webcam:block_confirmed', (data) => { 6856 if (!data || !data.blocked) return; 6857 if (!window.webcamMyBlockedViewersUntil) window.webcamMyBlockedViewersUntil = new Map(); 6858 const until = data.expires_at ? Number(data.expires_at) : (Date.now() + (data.duration_ms || 30 * 60 * 1000)); 6859 window.webcamMyBlockedViewersUntil.set(String(data.blocked).toLowerCase(), until); 6860 if (window.chatCrackWebcam) { 6861 if (typeof window.chatCrackWebcam.removeWebcamViewer === 'function') { 6862 window.chatCrackWebcam.removeWebcamViewer(data.blocked); 6863 } else if (window.chatCrackWebcam.webcamViewers) { 6864 window.chatCrackWebcam.webcamViewers.delete(String(data.blocked).toLowerCase()); 6865 if (typeof window.chatCrackWebcam.refreshEyeballIcons === 'function') { 6866 window.chatCrackWebcam.refreshEyeballIcons(); 6867 } 6868 } 6869 } 6870 const pop = document.getElementById('pop'); 6871 if (pop && pop.classList.contains('open') && typeof _renderMutedBlockedModal === 'function') { 6872 _renderMutedBlockedModal(); 6873 } 6874 _chatLog(`ð« Block confirmed: ${data.blocked} blocked for ${Math.round((data.duration_ms || 30 * 60 * 1000) / 60000)} min`); 6875 }); 6876 6877 socket.on('webcam:unblock_confirmed', (data) => { 6878 if (!data || !data.blocked) return; 6879 if (window.webcamMyBlockedViewersUntil) window.webcamMyBlockedViewersUntil.delete(String(data.blocked).toLowerCase()); 6880 }); 6881 6882 console.log("ð Clean WebSocket connection initialized"); 6883 ccSyncWsStatusIndicator(); 6884 socket._ccHandlersWired = true; 6885 socket._ccHandlersWiring = false; 6886 try { window._cleanWsCreating = false; } catch (_) {} 6887 try { window._ccWireHandlerTries = 0; } catch (_) {} 6888 try { 6889 window.dispatchEvent(new CustomEvent('cc:realtime-ready')); 6890 } catch (_) {} 6891 6892 // Early head connect may have finished before handlers existed â run join/bootstrap now. 6893 if (socket.connected) { 6894 try { 6895 updateWsStatusIndicator(true); 6896 window._messagePollIntervalMs = 60000; 6897 if (typeof window.restartMessagePollInterval === 'function') window.restartMessagePollInterval(); 6898 if (typeof window._scheduleClientHeartbeat === 'function') window._scheduleClientHeartbeat(); 6899 if (typeof window._ccRefreshPollIntervals === 'function') window._ccRefreshPollIntervals(); 6900 const chatRoomId = window.currentRoomId != null ? window.currentRoomId : 1; 6901 if (typeof window.ccWsJoinOpenChatRooms === 'function') { 6902 try { window.ccWsJoinOpenChatRooms(socket); } catch (_) { 6903 socket.emit('chat:join_room', { roomId: chatRoomId, keepOthers: chatRoomId !== 1 }); 6904 } 6905 } else { 6906 socket.emit('chat:join_room', { roomId: chatRoomId, keepOthers: chatRoomId !== 1 }); 6907 } 6908 if (typeof window.chatCrackOpenMic?.onRoomReady === 'function') { 6909 window.chatCrackOpenMic.onRoomReady(); 6910 } 6911 const regUser = window.currentUsername || (window.me && window.me.username); 6912 if (regUser) { 6913 socket.emit('webcam:register', typeof window._ccWebcamRegisterPayload === 'function' 6914 ? window._ccWebcamRegisterPayload(regUser) 6915 : { username: regUser }); 6916 window._eyeballRegistered = true; 6917 if (typeof fetchMyWebcamViewBlocks === 'function') fetchMyWebcamViewBlocks(); 6918 } 6919 if (typeof poll === 'function') setTimeout(function () { try { poll(); } catch (_) {} }, 80); 6920 } catch (_) {} 6921 } 6922} 6923window.initCleanWebSocket = initCleanWebSocket; 6924 6925/** Wire chat:new_message and room join on an early-connected socket (must run once initCleanWeb
6925Socket exists). */ 6926window.ccEnsureChatWsHandlers = function ccEnsureChatWsHandlers() { 6927 if (typeof window.ccTryWireChatHandlers === 'function') { 6928 window.ccTryWireChatHandlers(); 6929 } else if (window.socket && (window.socket.connected || window.socket.active) && !window.socket._ccHandlersWired) { 6930 if (typeof initCleanWebSocket === 'function') { 6931 try { initCleanWebSocket(); } catch (e) { console.warn('[WS] ccEnsureChatWsHandlers:', e); } 6932 } 6933 } else if (!(window.socket && window.socket.connected) && typeof ccScheduleWsConnect === 'function') { 6934 ccScheduleWsConnect(); 6935 } 6936 return !!(window.socket && window.socket._ccHandlersWired); 6937}; 6938 6939ccEnsureChatWsHandlers(); 6940if (!window.socket || !window.socket._ccHandlersWired) { 6941 if (typeof window.ccWhenWsConnectSafe === 'function') { 6942 window.ccWhenWsConnectSafe(function () { 6943 if (typeof initCleanWebSocket === 'function') { 6944 try { initCleanWebSocket(); } catch (e) { console.warn('[WS] post-parse init failed:', e); } 6945 } 6946 if (typeof ccEnsureChatWsHandlers === 'function') ccEnsureChatWsHandlers(); 6947 }); 6948 } 6949 var _ccWsHandlerWatch = setInterval(function () { 6950 if (typeof ccEnsureChatWsHandlers === 'function') ccEnsureChatWsHandlers(); 6951 if (window.socket && window.socket._ccHandlersWired) clearInterval(_ccWsHandlerWatch); 6952 }, 250); 6953 setTimeout(function () { clearInterval(_ccWsHandlerWatch); }, 20000); 6954} 6955if (window._wsConnectPending && typeof ccScheduleWsConnect === 'function') { 6956 ccScheduleWsConnect(); 6957} 6958 6959 6960 6961 6962 6963 6964 6965 6966 6967 6968 6969 6970 6971 6972 6973 6974 6975 6976// ---- helpers (client <-> mediasoup server) ---- 6977async function ccRequestCreateTransport(socket, { roomId, userId, direction }) { 6978 return new Promise((resolve, reject) => { 6979 socket.emit('chatcrack:create-transport', { roomId, userId, direction }, (res) => { 6980 if (!res || res.ok === false) return reject(new Error(res?.message || 'create-transport failed')); 6981 resolve(res); 6982 }); 6983 }); 6984} 6985 6986 6987async function ccRequestProduce(socket, { roomId, transportId, kind, rtpParameters, userId }) { 6988 return new Promise((resolve, reject) => { 6989 socket.emit('chatcrack:produce', { roomId, transportId, kind, rtpParameters, userId, keepPrevious: !!(typeof window._ccMediaRolloverOverlapActive === 'function' && window._ccMediaRolloverOverlapActive()) }, (res) => { 6990 if (!res || res.ok === false) return reject(new Error(res?.message || 'produce failed')); 6991 resolve(res); 6992 }); 6993 }); 6994} 6995 6996async function ccRequestConsume(socket, { roomId, rtpCapabilities, producerId, producerUserId, transportId }) { 6997 return new Promise((resolve, reject) => { 6998 // Timed: without this a missed ack left the consume promise pending forever, 6999 // wedging retry pipelines (open mic listeners had to refresh the page). 7000 let settled = false; 7001 const to = setTimeout(() => { 7002 if (settled) return; 7003 settled = true; 7004 reject(new Error('consume ack timeout')); 7005 }, 10000); 7006 socket.emit('chatcrack:consume', { roomId, rtpCapabilities, producerId, producerUserId, transportId }, (res) => { 7007 if (settled) return; 7008 settled = true; 7009 clearTimeout(to); 7010 if (!res || res.ok === false) return reject(new Error(res?.message || 'consume failed')); 7011 resolve(res); 7012 }); 7013 }); 7014} 7015 7016// ð¥ ChatCrack WebRTC Module - Real mediasoup integration 7017// ---- mediasoup Device helper (UMD or ESM) ---- 7018async function getMediasoupDeviceCtor() { 7019 // If already present (UMD bundle exposes window.mediasoupClient.Device) 7020 if (window.mediasoupClient && window.mediasoupClient.Device) { 7021 return window.mediasoupClient.Device; 7022 } 7023 // Some builds attach Device directly (rare) 7024 if (window.Device && typeof window.Device === 'function') { 7025 return window.Device; 7026 } 7027 // Lazy-load the bundle if not present (adjust src if your path differs) 7028 await new Promise((resolve, reject) => { 7029 const s = document.createElement('script'); 7030 s.src = '/mediasoup-bundle.js'; 7031 s.async = true; 7032 s.onload = resolve; 7033 s.onerror = () => reject(new Error('Failed to load mediasoup-bundle.js')); 7034 document.head.appendChild(s); 7035 }); 7036 if (window.mediasoupClient && window.mediasoupClient.Device) { 7037 return window.mediasoupClient.Device; 7038 } 7039 throw new Error('mediasoup-client Device not available after bundle load'); 7040} 7041 7042function getDeviceSendRtpCapabilities(device) { 7043 if (!device?.loaded) return null; 7044 return device.sendRtpCapabilities || device.rtpCapabilities || null; 7045} 7046 7047function getDeviceRecvRtpCapabilities(device) { 7048 if (!device?.loaded) return null; 7049 return device.recvRtpCapabilities || device.rtpCapabilities || null; 7050} 7051 7052// =========[WB_TELEM_V1] Ultra-safe bitrate telemetry helper ========= 7053// Renders tiny bitrate pills on each webcam tile (â¼ downlink / â² own uplink). 7054// Re-resolves the live dock every tick so pills survive tile rebuilds / mini-dock moves. 7055(function initWebcamBitrateHelper(){ 7056 if (window.WebcamBitrate && window.WebcamBitrate.__v3) return; 7057 if (window.WebcamBitrate && typeof window.WebcamBitrate.stopAll === 'function') { 7058 try { window.WebcamBitrate.stopAll(); } catch (_) {} 7059 } 7060 const _intervals = new Map(); // key -> intervalId 7061 const _state = new Map(); // key -> { lastBytes, lastTs, lastKbps, direction, username } 7062 7063 function _me() { 7064 return window.currentUsername || (window.me && window.me.username) || ''; 7065 } 7066 7067 function _findTileByUsername(username) { 7068 if (!username) return null; 7069 if (typeof window._ccFindWebcamDockTile === 'function') { 7070 const t = window._ccFindWebcamDockTile(username); 7071 if (t) return t; 7072 } 7073 const nl = String(username).toLowerCase(); 7074 const tiles = document.querySelectorAll('.webcam-dock[data-username], .floating-webcam[data-username]'); 7075 for (let i = 0; i < tiles.length; i++) { 7076 const u = tiles[i].getAttribute('data-username') || ''; 7077 if (String(u).toLowerCase() === nl) { 7078 return tiles[i].classList.contains('floating-webcam') 7079 ? (tiles[i].querySelector('.webcam-dock') || tiles[i]) 7080 : tiles[i]; 7081 } 7082 } 7083 return null; 7084 } 7085 7086 function _findDockTile(videoEl, usernameHint) { 7087 if (usernameHint) { 7088 const byUser = _findTileByUsername(usernameHint); 7089 if (byUser) return byUser; 7090 } 7091 let tile = videoEl?.closest?.('.webcam-dock'); 7092 if (!tile) tile = videoEl?.closest?.('.floating-webcam'); 7093 return tile || videoEl?.parentElement || null; 7094 } 7095 7096 function _ensurePill(tileEl, key, direction) { 7097 if (!tileEl) return null; 7098 try { 7099 const cs = window.getComputedStyle(tileEl); 7100 if (cs && cs.position === 'static') tileEl.style.position = 'relative'; 7101 } catch (_) {} 7102 // Drop orphan pills for this key on other tiles (after dock moves). 7103 try { 7104 document.querySelectorAll(`.wb-pill[data-key="${key}"]`).forEach((p) => { 7105 if (!tileEl.contains(p)) { 7106 try { p.remove(); } catch (_) {} 7107 } 7108 }); 7109 } catch (_) {} 7110 let pill = tileEl.querySelector?.(`.wb-pill[data-key="${key}"]`); 7111 if (!pill) { 7112 pill = document.createElement('div'); 7113 pill.className = 'wb-pill'; 7114 pill.dataset.key = key; 7115 pill.dataset.direction = direction; 7116 Object.assign(pill.style, { 7117 position: 'absolute',
7118 right: '6px', 7119 bottom: '6px', 7120 padding: '1px 6px', 7121 borderRadius: '10px', 7122 fontSize: '11px', 7123 background: 'rgba(0,0,0,0.55)', 7124 color: '#fff', 7125 lineHeight: '14px', 7126 pointerEvents: 'none', 7127 zIndex: '12', 7128 fontFamily: 'system-ui, sans-serif', 7129 whiteSpace: 'nowrap', 7130 display: 'block', 7131 opacity: '1', 7132 visibility: 'visible' 7133 }); 7134 tileEl.appendChild(pill); 7135 } else { 7136 pill.style.display = 'block'; 7137 pill.style.opacity = '1'; 7138 pill.style.visibility = 'visible'; 7139 pill.style.zIndex = '12'; 7140 } 7141 return pill; 7142 } 7143 7144 function _stopTicker(key) { 7145 const id = _intervals.get(key); 7146 if (id) { clearInterval(id); _intervals.delete(key); } 7147 } 7148 7149 function _startTicker(key, fn) { 7150 _stopTicker(key); 7151 const ff = typeof window._ccIsFirefox === 'function' && window._ccIsFirefox(); 7152 // Keep composer-busy skip so getStats does not freeze typing; refresh pills ~2s. 7153 const interval = ff ? 3000 : 2000; 7154 const wrapped = async function () { 7155 if (document.hidden) return; 7156 // Still paint last known kbps while typing; only skip the getStats call. 7157 const busy = typeof window._ccComposerBusy === 'function' && window._ccComposerBusy(); 7158 return fn({ busy: !!busy }); 7159 }; 7160 // Immediate first sample so the pill appears right away. 7161 try { wrapped(); } catch (_) {} 7162 const id = setInterval(wrapped, interval); 7163 _intervals.set(key, id); 7164 } 7165 7166 async function _sampleKbpsFromStats(getStats, pick) { 7167 try { 7168 const rep = await getStats(); 7169 const stat = [...rep.values()].find(pick); 7170 return stat || null; 7171 } catch (error) { 7172 if (error && error.name === 'InvalidStateError') return null; 7173 return null; 7174 } 7175 } 7176 7177 function _updateKbps(state, bytes, now) { 7178 const dt = Math.max(0.25, (now - (state.lastTs || now)) / 1000); 7179 let kbps = 0; 7180 if (state.lastBytes && bytes >= state.lastBytes) { 7181 kbps = Math.round(((bytes - state.lastBytes) * 8 / 1000) / dt); 7182 } else if (typeof state.lastKbps === 'number') { 7183 kbps = state.lastKbps; 7184 } 7185 state.lastBytes = bytes; 7186 state.lastTs = now; 7187 state.lastKbps = kbps; 7188 return kbps; 7189 } 7190 7191 function _paintPill(key, direction, username) { 7192 const state = _state.get(key) || { direction: direction, username: username }; 7193 _state.set(key, state); 7194 const tile = username 7195 ? _findTileByUsername(username) 7196 : _findTileByUsername(_me()); 7197 if (!tile) return null; 7198 const pill = _ensurePill(tile, key, direction || state.direction || 'down'); 7199 if (!pill) return null; 7200 const arrow = (direction || state.direction) === 'up' ? 'â²' : 'â¼'; 7201 if (typeof state.lastKbps === 'number') { 7202 pill.textContent = `${arrow} ${state.lastKbps} kbps`; 7203 } else { 7204 pill.textContent = `${arrow} â¦`; 7205 } 7206 return pill; 7207 } 7208 7209 window.WebcamBitrate = { 7210 __v3: true, 7211 getLastKbps(username) { 7212 try { 7213 const me = _me(); 7214 const isSelf = username && me && String(username).toLowerCase() === String(me).toLowerCase(); 7215 const key = isSelf || !username ? 'uplink_local' : ('downlink_' + String(username).toLowerCase()); 7216 const st = _state.get(key); 7217 return (st && typeof st.lastKbps === 'number') ? st.lastKbps : null; 7218 } catch (_) { 7219 return null; 7220 } 7221 }, 7222 7223 // Uplink: own producer bitrate on YOUR cam dock (and modal if present). 7224 observeLocalProducer(producer, videoEl) { 7225 const key = 'uplink_local'; 7226 const me = _me(); 7227 if (!_state.has(key)) _state.set(key, { direction: 'up', username: me, lastBytes: 0, lastTs: performance.now() }); 7228 else _state.get(key).username = me || _state.get(key).username; 7229 7230 _startTicker(key, async (opts) => { 7231 const state = _state.get(key); 7232 const rtc = window.chatCrackWebRTC; 7233 const prod = (producer && !producer.closed) 7234 ? producer 7235 : (rtc && rtc.cameraProducer && !rtc.cameraProducer.closed ? rtc.cameraProducer : null); 7236 const who = _me() || (state && state.username) || ''; 7237 if (state) state.username = who; 7238
7239 // Always keep pill mounted while we are on cam. 7240 const live = !!(window.chatCrackWebcam && window.chatCrackWebcam.isWebcamOn) 7241 || !!(prod && !prod.closed); 7242 if (!live) { 7243 try { 7244 document.querySelectorAll(`.wb-pill[data-key="${key}"]`).forEach((p) => p.remove()); 7245 } catch (_) {} 7246 return; 7247 } 7248 _paintPill(key, 'up', who); 7249 7250 if (opts && opts.busy) return; 7251 if (!prod || typeof prod.getStats !== 'function') return; 7252 7253 const s = await _sampleKbpsFromStats(prod.getStats.bind(prod), 7254 (x) => x.type === 'outbound-rtp' && !x.isRemote && (x.kind === 'video' || x.mediaType === 'video')); 7255 if (!s) return; 7256 const now = performance.now(); 7257 const kbps = _updateKbps(state, s.bytesSent || 0, now); 7258 const pill = _paintPill(key, 'up', who); 7259 if (pill) pill.textContent = `â² ${kbps} kbps`; 7260 }); 7261 }, 7262 7263 // Downlink: remote consumer bitrate on that user's dock tile. 7264 observeRemoteConsumer(username, consumer, videoEl) { 7265 if (!username) return; 7266 const key = 'downlink_' + String(username).toLowerCase(); 7267 if (!_state.has(key)) { 7268 _state.set(key, { direction: 'down', username: username, lastBytes: 0, lastTs: performance.now(), consumer: consumer }); 7269 } else { 7270 const st = _state.get(key); 7271 st.username = username; 7272 if (consumer) st.consumer = consumer; 7273 } 7274 7275 _startTicker(key, async (opts) => { 7276 const state = _state.get(key); 7277 const cons = (consumer && !consumer.closed) 7278 ? consumer 7279 : (state && state.consumer && !state.consumer.closed ? state.consumer : null); 7280 if (cons) state.consumer = cons; 7281 7282 const tile = _findTileByUsername(username) || _findDockTile(videoEl, username); 7283 if (!tile) return; 7284 _paintPill(key, 'down', username); 7285 7286 if (opts && opts.busy) return; 7287 if (!cons || typeof cons.getStats !== 'function') return; 7288 7289 let s = await _sampleKbpsFromStats(cons.getStats.bind(cons), 7290 (x) => x.type === 'inbound-rtp' && (x.kind === 'video' || x.mediaType === 'video')); 7291 if (!s) { 7292 s = await _sampleKbpsFromStats(cons.getStats.bind(cons), 7293 (x) => x.kind === 'video' && x.bytesReceived !== undefined); 7294 } 7295 if (!s) return; 7296 const now = performance.now(); 7297 const kbps = _updateKbps(state, s.bytesReceived || 0, now); 7298 const pill = _paintPill(key, 'down', username); 7299 if (pill) pill.textContent = `â¼ ${kbps} kbps`; 7300 }); 7301 }, 7302 7303 /** Re-bind local uplink after own dock is (re)created. */ 7304 refreshLocal() { 7305 try { 7306 const rtc = window.chatCrackWebRTC; 7307 const prod = rtc && rtc.cameraProducer; 7308 if (!prod || prod.closed) return; 7309 const me = _me(); 7310 const tile = _findTileByUsername(me); 7311 const vid = (tile && tile.querySelector('video')) 7312 || document.querySelector('#webcamModal video, .local-webcam video'); 7313 this.observeLocalProducer(prod, vid); 7314 } catch (_) {} 7315 }, 7316 7317 stopAll() { 7318 for (const k of Array.from(_intervals.keys())) { _stopTicker(k); } 7319 _state.clear(); 7320 try { 7321 document.querySelectorAll('.wb-pill').forEach((p) => p.remove()); 7322 } catch (_) {} 7323 } 7324 }; 7325 7326 // If an older stub already existed, keep the new API. 7327 try { window.WebcamBitrate.__v3 = true; } catch (_) {} 7328})(); 7329// =========[WB_TELEM_V1] END ========= 7330 7331// --------------------------------------------------------------------------- 7332// NEW: Lightweight viewer-only WebRTC class 7333// --------------------------------------------------------------------------- 7334/** Shared STUN for mediasoup-client transports (Firefox requires this or ICE fails). */ 7335window.ccMediasoupIceServers = function ccMediasoupIceServers() { 7336 if (Array.isArray(window.CC_ICE_SERVERS) && window.CC_ICE_SERVERS.length) { 7337 return window.CC_ICE_SERVERS; 7338 } 7339 return [ 7340 { urls: 'stun:stun.l.google.com:19302' }, 7341 { urls: 'stun:stun1.l.google.com:19302' } 7342 ]; 7343}; 7344 7345/** Merge server transport payload with client iceServers. */ 7346window.ccMediasoupTransportOpts = function ccMediasoupTransportOpts(payload) { 7347 const p = payload || {}; 7348 const ice = (Array.isArray(p.iceServers) && p.iceServers.length) 7349 ? p.iceServers 7350 : window.ccMediasoupIceServers(); 7351 return { 7352 id: p.id, 7353 iceParameters: p.iceParameters, 7354 iceCandidates: p.iceCandidates, 7355 dtlsParameters: p.dtlsParameters, 7356 sctpParameters: p.sctpParameters, 7357 iceServers: ice 7358 }; 7359}; 7360 7361/** 7362 * Viewer sockets are created on a click, so the polling handshake is felt as a 7363 * black tile. Only skip straight to websocket when the chat socket already 7364 * proved this network upgrades cleanly; otherwise keep the polling-first path 7365 * that survives blocked wss:// (socket.io v4 does not fall back on its own). 7366 */ 7367window.ccMediaSocketTransportOrder = function () { 7368 try { 7369 const up = window._ccWsUpgrade; 7370 if (up && up.transport === 'websocket' && !up.failed) return ['websocket', 'polling']; 7371 const eng = window.socket && window.socket.io && window.socket.io.engine; 7372 if (eng && eng.transport && eng.transport.name === 'websocket') return ['websocket', 'polling']; 7373 } catch (_) {} 7374 return ['polling', 'websocket']; 7375}; 7376 7377class ChatCrackWebRTCViewer { 7378 constructor(username) { 7379 this.username = username; // distinct viewer identity (not the publisher's username) 7380 // https:// only â converting to wss:// skips polling and hits Firefox 7381 // "can't establish a connection ⦠transport=websocket" with no fallback. 7382 this.serverUrl = (typeof window.ccNormalizeMediaOrigin === 'function') 7383 ? window.ccNormalizeMediaOrigin(window.CC_MEDIA_ORIGIN) 7384 : (window.CC_MEDIA_ORIGIN || 'https://media.chatcrack.us'); 7385 this.socket = null; 7386 this.device = null; 7387 this.deviceLoaded = false;
7388 this.recvTransport = null; 7389 this.consumers = new Map(); 7390 this.roomName = null; // remember which room we joined 7391 // Age is what tells the orphan reaper "this one is still being built". 7392 this._ccCreatedAt = Date.now(); 7393 } 7394 7395 async init() { 7396 if (typeof window.ccAwaitWebcamMediaReady === 'function') { 7397 await window.ccAwaitWebcamMediaReady(30000); 7398 } 7399 // Isolated viewer socket. Keep https:// so polling can establish first; 7400 // Engine.IO upgrades to websocket when the network allows it. 7401 const mediaUrl = (typeof window.ccNormalizeMediaOrigin === 'function') 7402 ? window.ccNormalizeMediaOrigin(this.serverUrl) 7403 : String(this.serverUrl || 'https://media.chatcrack.us').replace(/^wss:/i, 'https:').replace(/^ws:/i, 'http:'); 7404 this.serverUrl = mediaUrl; 7405 if (this.socket) { 7406 try { 7407 if (typeof window._ccKillSocketIo === 'function') window._ccKillSocketIo(this.socket); 7408 else this.socket.disconnect(); 7409 } catch (_) {} 7410 this.socket = null; 7411 } 7412 this.socket = io(mediaUrl, { 7413 transports: (typeof window.ccMediaSocketTransportOrder === 'function') 7414 ? window.ccMediaSocketTransportOrder() 7415 : ['polling', 'websocket'], 7416 tryAllTransports: true, 7417 forceNew: true, 7418 upgrade: true, 7419 rememberUpgrade: false, 7420 reconnection: true, 7421 reconnectionAttempts: Infinity, 7422 reconnectionDelay: 800, 7423 reconnectionDelayMax: 6000, 7424 timeout: 20000, 7425 path: '/socket.io/', 7426 // Pass a unique user id (this.username) that is NOT the publisher's id 7427 query: { role: 'viewer', user: this.username, ns: 'viewer' } 7428 }); 7429 await new Promise((resolve) => { 7430 if (this.socket.connected) return resolve(); 7431 const timer = setTimeout(resolve, 30000); 7432 this.socket.once('connect', () => { clearTimeout(timer); resolve(); }); 7433 this.socket.once('connect_error', () => { clearTimeout(timer); resolve(); }); 7434 }); 7435 if (!this.device) this.device = new mediasoupClient.Device(); 7436 if (window.WEBCAM_DEBUG) { 7437 console.log(`[Viewer:${this.username}] Socket created (connected=${!!this.socket.connected})`); 7438 } 7439 if (typeof window._ccWireViewerMediaSocket === 'function') { 7440 const self = this; 7441 window._ccWireViewerMediaSocket(this.socket, function () { 7442 if (self.publisherUsername) return self.publisherUsername; 7443 const id = String(self.username || ''); 7444 return id.indexOf('#viewer#') !== -1 ? id.split('#viewer#').pop() : ''; 7445 }, self); 7446 } 7447 if (this.socket && !this.socket._ccMediaRestartWired) { 7448 this.socket._ccMediaRestartWired = true; 7449 this.socket.on('chatcrack:media-restarting', (payload) => { 7450 if (typeof window._ccBeginMediaRolloverOverlap === 'function') { 7451 window._ccBeginMediaRolloverOverlap(payload || { overlap: true, keepLive: true }); 7452 } 7453 }); 7454 this.socket.on('chatcrack:media-ready', (payload) => { 7455 if (payload && (payload.overlap || payload.keepLive) 7456 && typeof window._ccBeginMediaRolloverOverlap === 'function') { 7457 window._ccBeginMediaRolloverOverlap(payload); 7458 } 7459 }); 7460 } 7461 } 7462 7463 async connect(roomName) { 7464 const maxAttempts = 6; 7465 for (let attempt = 0; attempt < maxAttempts; attempt++) { 7466 const ok = await this._connectRoomOnce(roomName); 7467 if (ok) return true; 7468 if (attempt < maxAttempts - 1) { 7469 await new Promise(r => setTimeout(r, 400 * (attempt + 1))); 7470 } 7471 } 7472 return false; 7473 } 7474 7475 _connectRoomOnce(roomName) { 7476 return new Promise((resolve) => { 7477 if (!this.socket) { 7478 resolve(false); 7479 return; 7480 } 7481 const emitJoin = () => { 7482 const joinTimer = setTimeout(() => resolve(false), 15000); 7483 this.socket.emit('chatcrack:join-room', { roomId: roomName, userId: this.username }, (res) => { 7484 clearTimeout(joinTimer); 7485 if (!res || !res.ok) { 7486 console.warn(`[Viewer:${this.username}] Failed to join`, res); 7487 resolve(false); 7488 return; 7489 }
7490 if (window.WEBCAM_DEBUG) console.log(`[Viewer:${this.username}] Joined ${roomName}`); 7491 this.roomName = roomName; 7492 this.lastJoinResponse = res; 7493 // Back in the mediasoup room, so new-producer reaches us again. 7494 // The recv transport stays flagged dead and is rebuilt on consume. 7495 this._ccNeedsRejoin = false; 7496 try { 7497 if (!this.device) this.device = new mediasoupClient.Device(); 7498 const alreadyLoaded = !!(this.deviceLoaded || this.device.loaded); 7499 if (!alreadyLoaded) { 7500 this.device.load({ routerRtpCapabilities: res.routerRtpCapabilities }).then(() => { 7501 this.deviceLoaded = true; 7502 resolve(true); 7503 }).catch((e) => { 7504 console.error(`[Viewer:${this.username}] Device load error`, e); 7505 resolve(false); 7506 }); 7507 } else { 7508 console.log(`[Viewer:${this.username}] Device already loaded, skipping load()`); 7509 this.deviceLoaded = true; 7510 resolve(true); 7511 } 7512 } catch (e) { 7513 console.error(`[Viewer:${this.username}] Device load error`, e); 7514 resolve(false); 7515 } 7516 }); 7517 }; 7518 if (this.socket.connected) { 7519 emitJoin(); 7520 } else { 7521 const waitTimer = setTimeout(() => resolve(false), 15000); 7522 this.socket.once('connect', () => { clearTimeout(waitTimer); emitJoin(); }); 7523 } 7524 }); 7525 } 7526 7527 async createRecvTransport(params) { 7528 const { id, iceParameters, iceCandidates, dtlsParameters } = params; 7529 this.recvTransport = this.device.createRecvTransport( 7530 window.ccMediasoupTransportOpts ? window.ccMediasoupTransportOpts(params) : { id, iceParameters, iceCandidates, dtlsParameters } 7531 ); 7532 const transportRef = this.recvTransport; 7533 if (!transportRef._ccConnectWired) { 7534 transportRef._ccConnectWired = true; 7535 transportRef.on('connect', ({ dtlsParameters }, callback, errorback) => { 7536 try { 7537 if (!this.socket) { 7538 errorback(new Error('no socket')); 7539 return; 7540 } 7541 this.socket.emit('chatcrack:connect-transport', { 7542 roomId: this.roomName, 7543 transportId: transportRef.id, 7544 dtlsParameters 7545 }, (response) => { 7546 if (response && response.ok) callback(); 7547 else errorback(new Error((response && response.message) || 'recv connect failed')); 7548 }); 7549 } catch (e) { errorback(e); } 7550 }); 7551 } 7552 this.recvTransport.on('connectionstatechange', (state) => { 7553 if (state === 'connected') { 7554 // Transport healed on its own â cancel any pending recovery. 7555 if (this._recvRecoverTimer) { clearTimeout(this._recvRecoverTimer); this._recvRecoverTimer = null; } 7556 this._recvRecoverAttempts = 0; 7557 return; 7558 } 7559 // A docked cam's video AND audio both ride this one recv transport, so
7560 // when ICE fails/disconnects the whole tile (and its VU meter) freezes. 7561 if (state === 'failed' || state === 'disconnected' || state === 'closed') { 7562 console.warn(`[Viewer:${this.username}] recvTransport ${state}`); 7563 const pub = this._getPublisherUsername(); 7564 if (pub && typeof window._ccRecoverDockAfterIceFail === 'function') { 7565 window._ccRecoverDockAfterIceFail(pub, state, this); 7566 } else { 7567 this._scheduleRecvRecovery(state); 7568 } 7569 } 7570 }); 7571 } 7572 7573 /** Resolve the publisher this viewer is watching (for self-recovery). */ 7574 _getPublisherUsername() { 7575 if (this.publisherUsername) return this.publisherUsername; 7576 try { 7577 if (typeof this.username === 'string' && this.username.indexOf('#viewer#') !== -1) { 7578 return this.username.split('#viewer#')[1] || null; 7579 } 7580 } catch (_) {} 7581 try { 7582 if (this.roomName && this.roomName.indexOf('chatcrack_webcam_') === 0) { 7583 return this.roomName.slice('chatcrack_webcam_'.length) || null; 7584 } 7585 } catch (_) {} 7586 return null; 7587 } 7588 7589 /** 7590 * Debounced recv-transport recovery. 'disconnected' is often a transient ICE 7591 * blip that self-heals, so we wait longer before acting; 'failed'/'closed' 7592 * won't recover on their own. Re-check state + still-docked at fire time so we 7593 * never tear down a cam that already healed or was intentionally closed. 7594 */ 7595 _scheduleRecvRecovery(state) { 7596 if (this._closing) return; 7597 const publisher = this._getPublisherUsername(); 7598 if (!publisher) return; 7599 const cam = window.chatCrackWebcam; 7600 if (!cam || typeof cam._refreshDockedWebcamStream !== 'function') return; 7601 7602 const stillDocked = () => { 7603 try { 7604 if (cam.dockedWebcams && cam.dockedWebcams.has && cam.dockedWebcams.has(publisher)) return true; 7605 if (window.dockedWebcams && window.dockedWebcams.has && window.dockedWebcams.has(publisher)) return true; 7606 if (typeof window._ccFindWebcamDockTile === 'function' && window._ccFindWebcamDockTile(publisher)) return true; 7607 } catch (_) {} 7608 return false; 7609 }; 7610 const publisherLive = () => (typeof window._ccIsUserAuthoritativelyLive !== 'function') 7611 || window._ccIsUserAuthoritativelyLive(publisher); 7612 7613 if (!stillDocked() || !publisherLive()) return; 7614 // Last decoded frame can linger after ICE dies â still rebuild so mic 7615 // and live picture come back together. 7616 if (this._recvRecoverTimer) return; // already scheduled 7617 this._recvRecoverAttempts = this._recvRecoverAttempts || 0; 7618 if (this._recvRecoverAttempts >= 5) return; // safety cap for this viewer instance 7619 7620 const delay = (state === 'disconnected') ? 2500 : 800; 7621 this._recvRecoverTimer = setTimeout(() => { 7622 this._recvRecoverTimer = null; 7623 if (this._closing) return; 7624 const t = this.recvTransport; 7625 if (t && t.connectionState === 'connected') return; // healed while we waited 7626 if (!stillDocked() || !publisherLive()) return; 7627 this._recvRecoverAttempts++; 7628 console.warn(`[Viewer:${this.username}] recvTransport ${state} â re-consuming ${publisher} (attempt ${this._recvRecoverAttempts})`); 7629 try { 7630 cam._refreshDockedWebcamStream(publisher, { 7631 locked: false, 7632 forceReconnect: true, 7633 instantPaint: true, 7634 forceLive: true, 7635 keepLastFrame: false 7636 }); 7637 } catch (_) {} 7638 }, delay); 7639 } 7640 7641 close() { 7642 // Mark closing so an intentional teardown (recvTransport.close fires a 7643 // 'closed' state event) can't schedule a bogus re-consume. 7644 this._closing = true; 7645 if (this._recvRecoverTimer) { try { clearTimeout(this._recvRecoverTimer); } catch (_) {} this._recvRecoverTimer = null; } 7646 const dbg = !!window.WEBCAM_DEBUG; 7647 if (dbg) { 7648 console.log(`ð§ [DEBUG] ChatCrackWebRTCViewer.close() called for ${this.username}`);
7649 console.log(`ð§ [DEBUG] Viewer close() - this instance:`, { 7650 username: this.username, 7651 hasSocket: !!this.socket, 7652 socketId: this.socket?.id, 7653 socketConnected: this.socket?.connected, 7654 hasRecvTransport: !!this.recvTransport, 7655 recvTransportId: this.recvTransport?.id, 7656 recvTransportClosed: this.recvTransport?.closed, 7657 consumersCount: this.consumers?.size || 0 7658 }); 7659 } 7660 7661 try { 7662 this.consumers.forEach((c) => { 7663 if (dbg) console.log(`ð§ [DEBUG] Closing consumer:`, c.id, c.kind); 7664 c.close(); 7665 }); 7666 this.consumers.clear(); 7667 7668 if (this.recvTransport) { 7669 this.recvTransport.close(); 7670 } 7671 7672 if (this.socket) { 7673 this.socket.disconnect(); // isolated forceNew socket 7674 } 7675 7676 if (dbg) console.log(`[Viewer:${this.username}] Closed viewer context`); 7677 } catch (e) { 7678 console.warn(`[Viewer:${this.username}] Close error`, e); 7679 console.error(`ð§ [DEBUG] Exception in viewer.close(), publisher state:`, { 7680 hasCameraProducer: !!(window.chatCrackWebRTC?.cameraProducer), 7681 cameraProducerClosed: window.chatCrackWebRTC?.cameraProducer?.closed 7682 }); 7683 } 7684 } 7685} 7686 7687/** 7688 * Thresholds for the sound-device stall watchdog (_micClockStallTick). 7689 * 7690 * Real stalls measured in the wild lose 200-670ms per 5s window (4-13%) and 7691 * last minutes, so requiring three in a row rules out one-off scheduling 7692 * hiccups without making the user sit through the crackle. 7693 * 7694 * SLIP_MS sits well above normal hardware drift on purpose. A sound card whose 7695 * clock differs from the system clock by ~1% is ordinary and harmless, but at 7696 * the old 40ms it tripped this every window â noise in the log, and three 7697 * unlucky windows in a row would have reopened a perfectly healthy mic and 7698 * dropped audio for no reason. 7699 */ 7700const MicClockStall = { 7701 SAMPLE_MS: 5000, 7702 SLIP_MS: 175, 7703 BAD_WINDOWS_BEFORE_HEAL: 3, 7704 HEAL_COOLDOWN_MS: 90 * 1000, 7705 MAX_COOLDOWN_MULTIPLIER: 6, 7706 CLEAN_WINDOWS_TO_RESET: 12 7707}; 7708 7709/** True when this mediasoup userId is the local person. Consuming our own 7710 * audio/video plays a delayed copy of the mic into speakers (self-echo). */ 7711window._ccIsOwnMediaUser = function (userId) { 7712 if (userId == null || userId === '') return false; 7713 const a = String(userId).toLowerCase(); 7714 const names = [ 7715 window.currentUsername, 7716 window.me && window.me.username, 7717 window.chatCrackWebRTC && window.chatCrackWebRTC.userId 7718 ]; 7719 for (let i = 0; i < names.length; i++) { 7720 const n = names[i]; 7721 if (n && String(n).toLowerCase() === a) return true; 7722 } 7723 try { 7724 if (typeof window._ccSameWebcamUser === 'function' && window.currentUsername 7725 && window._ccSameWebcamUser(userId, window.currentUsername)) { 7726 return true; 7727 } 7728 } catch (_) {} 7729 return false; 7730}; 7731 7732window._ccAskDockKeyframe = function (sock, roomId, consumerId, producerUserId) { 7733 try { 7734 if (!sock || !sock.connected || !roomId) return; 7735 sock.emit('chatcrack:request-keyframe', { 7736 roomId: roomId, 7737 consumerId: consumerId, 7738 producerUserId: producerUserId 7739 }); 7740 } catch (_) {} 7741}; 7742window._ccAskDockKeyframeBurst = function (sock, roomId, consumerId, producerUserId) { 7743 if (typeof window._ccAskDockKeyframe !== 'function') return; 7744 window._ccAskDockKeyframe(sock, roomId, consumerId, producerUserId); 7745 setTimeout(function () { window._ccAskDockKeyframe(sock, roomId, consumerId, producerUserId); }, 80); 7746 setTimeout(function () { window._ccAskDockKeyframe(sock, roomId, consumerId, producerUserId); }, 180); 7747 setTimeout(function () { window._ccAskDockKeyframe(sock, roomId, consumerId, producerUserId); }, 450); 7748 setTimeout(function () { window._ccAskDockKeyframe(sock, roomId, consumerId, producerUserId); }, 900); 7749}; 7750 7751/** mediasoup-client consumers start paused â without resume(), RTP can flow (kbps) while the tile stays black. */ 7752window._ccResumeWebcamConsumer = function (consumer, socket, roomId, consumerId) { 7753 try { 7754 if (consumer && !consumer.closed && typeof consumer.resume === 'function') { 7755 const p = consumer.resume(); 7756 if (p && typeof p.catch === 'function') p.catch(function () {}); 7757 } 7758 } catch (_) {} 7759 try { 7760 if (socket && roomId && consumerId) { 7761 socket.emit('chatcrack:resume-consumer', { roomId: roomId, consumerId: consumerId }, function () {}); 7762 } 7763 } catch (_) {} 7764}; 7765 7766/** 7767 * A dropped viewer media socket is fatal on the SFU side: it closes every 7768 * transport belonging to that socket id straight away, and the reconnected 7769 * socket carries a new id, so it is no longer a member of the mediasoup room and 7770 * stops receiving new-producer. Nothing re-consumes by itself, which is why a 7771 * tile could sit black until the user refreshed the page. Mark the local view 7772 * state dead the moment the socket drops, then redial the dock as soon as it is 7773 * back. 7774 */ 7775window._ccWireViewerMediaSocket = function (socket, publisher, viewerRef) { 7776 if (!socket || socket._ccViewerLifecycleWired) return; 7777 socket._ccViewerLifecycleWired = true; 7778 7779 const pubName = function () { 7780 try { 7781 const n = (typeof publisher === 'function') ? publisher() : publisher; 7782 return n ? String(n) : ''; 7783 } catch (_) { return ''; } 7784 }; 7785 const viewerOf = function (name) { 7786 try { 7787 if (viewerRef) return (typeof viewerRef === 'function') ? viewerRef() : viewerRef; 7788 if (!name) return null; 7789 if (typeof window._ccGetRemoteWebcamViewer === 'function') { 7790 return window._ccGetRemoteWebcamViewer(name); 7791 } 7792 return window.remoteWebcamViewers 7793 && (window.remoteWebcamViewers[name] || window.remoteWebcamViewers[String(name).toLowerCase()]); 7794 } catch (_) { return null; } 7795 }; 7796 7797 socket.on('disconnect', function () { 7798 socket._ccViewerDropped = true; 7799 const name = pubName(); 7800 const v = viewerOf(name); 7801 try { if (v) v._ccNeedsRejoin = true; } catch (_) {} 7802 // Every transport on this socket is gone server-side. Flag it so the 7803 // "usable"/ICE checks stop offering it up for reuse. 7804 try { 7805 if (v && v.recvTransport) v.recvTransport._ccServerMissing = true; 7806 } catch (_) {} 7807 try { 7808 const rtc = window.chatCrackWebRTC; 7809 const conn = rtc && rtc.viewingConnections && name ? rtc.viewingConnections.get(name) : null; 7810 if (conn && conn.transport) conn.transport._ccServerMissing = true; 7811 } catch (_) {} 7812 // Consumers on a closed transport are dead; drop the dedupe keys so the 7813 // next attempt is allowed to consume the same producer again. 7814 try { 7815 const rtc = window.chatCrackWebRTC; 7816 if (rtc && rtc._consumedProducerKeys && name) { 7817 const prefix = `consumeUserWebcam-${name}-`;
7818 Array.from(rtc._consumedProducerKeys).forEach(function (k) { 7819 if (String(k).indexOf(prefix) === 0) rtc._consumedProducerKeys.delete(k); 7820 }); 7821 } 7822 } catch (_) {} 7823 }); 7824 7825 socket.on('connect', function () { 7826 if (!socket._ccViewerDropped) return; 7827 socket._ccViewerDropped = false; 7828 const name = pubName(); 7829 if (!name) return; 7830 // Still watching them? Then redial. This is a known-dead path rather than a 7831 // guess, so it skips the healer's rebuild backoff. 7832 const stillDocked = (function () { 7833 try { 7834 if (typeof window._ccFindWebcamDockTile === 'function' && window._ccFindWebcamDockTile(name)) return true; 7835 const cam = window.chatCrackWebcam; 7836 if (cam && cam.dockedWebcams && cam.dockedWebcams.has && cam.dockedWebcams.has(name)) return true; 7837 } catch (_) {} 7838 return false; 7839 })(); 7840 if (!stillDocked) return; 7841 // A flapping network would otherwise redial on every bounce. 7842 const nl = String(name).toLowerCase(); 7843 if (!window._ccViewerRejoinAt) window._ccViewerRejoinAt = Object.create(null); 7844 const last = window._ccViewerRejoinAt[nl] || 0; 7845 if (last && (Date.now() - last) < 5000) return; 7846 window._ccViewerRejoinAt[nl] = Date.now(); 7847 try { if (typeof window._ccCamRebuildSettled === 'function') window._ccCamRebuildSettled(name); } catch (_) {} 7848 setTimeout(function () { 7849 try { 7850 const cam = window.chatCrackWebcam; 7851 if (!cam || typeof cam._refreshDockedWebcamStream !== 'function') return; 7852 if (typeof window._ccDockTileHasFreshVideo === 'function' && window._ccDockTileHasFreshVideo(name)) return; 7853 const p = cam._refreshDockedWebcamStream(name, { 7854 forceReconnect: true, 7855 instantPaint: true, 7856 forceLive: true, 7857 keepLastFrame: false 7858 }); 7859 if (p && typeof p.catch === 'function') p.catch(function () {}); 7860 } catch (_) {} 7861 }, 250); 7862 }); 7863}; 7864 7865/** Black tile with bitrate: force a first decoded frame now, then rebuild once. */ 7866window._ccEnsureRemoteCamPaintsNow = function (username, videoElement, socket, roomId, consumerId) { 7867 if (!username || !videoElement) return; 7868 const key = String(username).toLowerCase(); 7869 if (!window._ccPaintRescueAt) window._ccPaintRescueAt = Object.create(null); 7870 const now = Date.now(); 7871 const alreadyPainted = (function () { 7872 try { return (videoElement.videoWidth | 0) > 0; } catch (_) { return false; } 7873 })(); 7874 if (alreadyPainted) return; 7875 if (window._ccPaintRescueAt[key] && (now - window._ccPaintRescueAt[key]) < 350) return; 7876 window._ccPaintRescueAt[key] = now; 7877 try { videoElement._ccHoldLastFrame = false; } catch (_) {} 7878 const painted = function () { 7879 try { return (videoElement.videoWidth | 0) > 0; } catch (_) { return false; } 7880 }; 7881 const kick = function (deep) { 7882 if (painted()) return true; 7883 try { 7884 if (typeof window._ccResumeWebcamConsumer === 'function') { 7885 window._ccResumeWebcamConsumer( 7886 videoElement._ccVideoConsumer, 7887 socket || videoElement._ccVideoSocket, 7888 roomId || videoElement._ccVideoRoomId, 7889 consumerId || videoElement._ccVideoConsumerId 7890 ); 7891 } 7892 } catch (_) {} 7893 if (typeof window._ccAskDockKeyframeBurst === 'function') { 7894 window._ccAskDockKeyframeBurst(socket, roomId, consumerId, username); 7895 } 7896 try { 7897 if (typeof window._ccSafeMediaPlay === 'function') window._ccSafeMediaPlay(videoElement); 7898 else if (videoElement && videoElement.play) videoElement.play().catch(function () {}); 7899 } catch (_) {} 7900 if (deep && window.chatCrackWebcam && typeof window.chatCrackWebcam._refreshDockedWebcamStream === 'function') { 7901 try { 7902 const nl = String(username).toLowerCase(); 7903 const inFlight = !!(window._ccDockInFlight && window._ccDockInFlight.has(nl)); 7904 const at = window._ccDockInFlightAt && window._ccDockInFlightAt[nl]; 7905 if (inFlight && at && (Date.now() - at) < 1500) { 7906 return false; 7907 } 7908 window.chatCrackWebcam._refreshDockedWebcamStream(username, { 7909 forceReconnect: true, 7910 userInitiated: true, 7911 instantPaint: true, 7912 keepLastFrame: false 7913 }); 7914 } catch (_) {} 7915 } 7916 return false;
7917 }; 7918 if (kick(false)) return; 7919 setTimeout(function () { kick(false); }, 120); 7920 setTimeout(function () { kick(false); }, 280); 7921 setTimeout(function () { kick(true); }, 800); 7922}; 7923 7924/** 7925 * Hidden-tab only: pause remote webcam *video* (keep audio) so a background 7926 * lobby does not keep decoding every dock. Open docks in a visible tab must 7927 * ALWAYS paint â capping "decode slots" by ping/loop-lag was the black-cam 7928 * bug (3rd/4th tile never got a keyframe, games hitching triggered the cap). 7929 */ 7930(function ccWebcamDecodeGuard() { 7931 if (window._ccWebcamDecodeGuard) return; 7932 window._ccWebcamDecodeGuard = true; 7933 var observed = new Set(); 7934 7935 function isOwn(video) { 7936 try { 7937 var u = video && video._ccVideoUsername; 7938 return !!(u && typeof window._ccIsOwnMediaUser === 'function' && window._ccIsOwnMediaUser(u)); 7939 } catch (_) { return false; } 7940 } 7941 7942 function askKeyframe(video) { 7943 try { 7944 if (typeof window._ccAskDockKeyframeBurst === 'function') { 7945 window._ccAskDockKeyframeBurst( 7946 video._ccVideoSocket, 7947 video._ccVideoRoomId, 7948 video._ccVideoConsumerId, 7949 video._ccVideoUsername 7950 ); 7951 } 7952 } catch (_) {} 7953 } 7954 7955 function pauseVideo(video) { 7956 if (!video || isOwn(video) || video._ccDecodePaused) return; 7957 video._ccDecodePaused = true; 7958 try { 7959 var c = video._ccVideoConsumer; 7960 if (c && !c.closed && typeof c.pause === 'function') c.pause().catch(function () {}); 7961 } catch (_) {} 7962 try { 7963 var sock = video._ccVideoSocket; 7964 var cid = video._ccVideoConsumerId; 7965 var rid = video._ccVideoRoomId; 7966 if (sock && sock.connected && cid && rid) { 7967 sock.emit('chatcrack:pause-consumer', { roomId: rid, consumerId: cid }, function () {}); 7968 } 7969 } catch (_) {} 7970 } 7971 7972 function resumeVideo(video) { 7973 if (!video || isOwn(video)) return; 7974 var wasPaused = !!video._ccDecodePaused; 7975 video._ccDecodePaused = false; 7976 try { 7977 var c = video._ccVideoConsumer; 7978 if (c && !c.closed && typeof c.resume === 'function') c.resume().catch(function () {}); 7979 } catch (_) {} 7980 try { 7981 var sock = video._ccVideoSocket; 7982 var cid = video._ccVideoConsumerId; 7983 var rid = video._ccVideoRoomId; 7984 if (sock && sock.connected && cid && rid) { 7985 sock.emit('chatcrack:resume-consumer', { roomId: rid, consumerId: cid }, function () {}); 7986 } 7987 } catch (_) {} 7988 try { video.play().catch(function () {}); } catch (_) {} 7989 if (wasPaused) askKeyframe(video); 7990 } 7991 7992 function apply() { 7993 var videos = []; 7994 observed.forEach(function (v) { 7995 if (!v || !v.isConnected) { 7996 observed.delete(v); 7997 return; 7998 } 7999 if (isOwn(v)) return; 8000 videos.push(v); 8001 }); 8002 if (document.hidden) { 8003 videos.forEach(pauseVideo); 8004 return; 8005 } 8006 videos.forEach(resumeVideo); 8007 } 8008 8009 window._ccNoteRemoteCamVideo = function (video) { 8010 if (!video || isOwn(video)) return; 8011 observed.add(video); 8012 video._ccDockVisible = true; 8013 if (!document.hidden) resumeVideo(video); 8014 else apply(); 8015 }; 8016 8017 document.addEventListener('visibilitychange', apply); 8018})(); 8019 8020class ChatCrackWebRTC { 8021 constructor(opts = {}) { 8022 const rawMedia = opts.serverUrl || window.CC_MEDIA_ORIGIN || 'https://media.chatcrack.us'; 8023 this.serverUrl = (typeof window.ccNormalizeMediaOrigin === 'function') 8024 ? window.ccNormalizeMediaOrigin(rawMedia) 8025 : String(rawMedia).replace(/^wss:/i, 'https:').replace(/^ws:/i, 'http:'); 8026 this.roomId = null; 8027 this.userId = null; 8028 this.socket = null; 8029 this.device = null; 8030 this.sendTransport = null; 8031 this.recvTransport = null; 8032 this.producers = new Map(); 8033 this.consumers = new Map(); 8034 this._routerRtpCapabilities = null; 8035 this.localStream = null; 8036 8037 // Log compatibility mode instructions 8038 this.logCompatibilityInstructions(); 8039 8040 // ð NEW: Webcam stability improvements 8041 this.cameraProducer = null; 8042 this.sender = null; 8043 this.currentQuality = 'low'; // default to low (90kbps) â reduces main-room lag 8044 this.watchdogTimer = null; 8045 this.lastBytesSent = 0; 8046 this.stillCount = 0; 8047 8048 // ð NEW: Auto quality mode
8049 this.qualityMode = 'auto'; // 'auto' | 'manual' 8050 this.lastQualityChange = 0; 8051 this.qualityChangeCooldown = 20000; // 20s cooldown between changes 8052 this.healthyFramesCount = 0; 8053 this.healthyFramesThreshold = 20; // 20s of healthy frames for upshift 8054 8055 // ðï¸ NEW: Microphone mute state tracking 8056 this.isMicMuted = true; // Default to muted 8057 8058 // Bumped on manual stop so in-flight watchdog restarts abort 8059 this._webcamBroadcastGen = 0; 8060 8061 this._useSharedMediaSocket = !!opts.shareSocket; 8062 this._borrowsMediaSocket = false; 8063 8064 // Logging helpers 8065 this.log = console.log.bind(console); 8066 this.warn = console.warn.bind(console); 8067 this.error = console.error.bind(console); 8068 8069 if (!opts.skipInit) { 8070 this.init(); 8071 } 8072 } 8073 8074 // Ensure minimal DOM for local/remote media preview 8075 ensureMediaContainers() { 8076 try { 8077 if (!document.getElementById('remoteContainer')) { 8078 var style = document.createElement('style'); 8079 style.textContent = '#remoteContainer{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:8px;margin:8px}#remoteContainer video,#remoteContainer audio{width:100%;background:#000;border-radius:10px}#localCam{width:240px;height:180px;border-radius:10px;display:block;margin:8px 0;background:#000;object-fit:cover;position:relative;overflow:hidden}'; 8080 document.head.appendChild(style); 8081 8082 var div = document.createElement('div'); 8083 div.id = 'remoteContainer'; 8084 document.body.appendChild(div); 8085 } 8086 // DISABLED: localCam element creation to prevent overlap with webcamPreview 8087 // The webcamPreview element in the modal is sufficient for local preview 8088 // if (!document.getElementById('localCam')) { 8089 // var v = document.createElement('video'); 8090 // v.id = 'localCam'; 8091 // v.autoplay = true; 8092 // v.playsInline = true; 8093 // v.muted = true; 8094 // document.body.appendChild(v); 8095 // } 8096 } catch (_) {} 8097 } 8098 8099 // Throttled health beacons (emit only while producing) 8100 startHealthBeacons(params) { 8101 try { this.stopHealthBeacons(); } catch (_) {} 8102 this._healthTimer = setInterval(() => { 8103 try { 8104 var isProducing = !!this.cameraProducer; 8105 if (!isProducing) return; 8106 var user = (params && params.user) || this.userId || (window.me && window.me.username) || 'anon'; 8107 var roomId = (params && params.roomId) || this.roomId; 8108 if (!this.socket || !this.socket.connected) return; 8109 var wdState = 'OK'; 8110 var stalls = 0; 8111 if (this._wd) { 8112 stalls = this._wd.consecutiveStalls || 0; 8113 if (stalls >= 3) wdState = 'RESTARTING'; 8114 else if (stalls >= 1) wdState = 'RECOVERING'; 8115 } 8116 var iceState = this.sendTransport?.connectionState || 'unknown'; 8117 this.socket.emit('health:report', { 8118 user: user, 8119 roomId: roomId, 8120 prod: { ice: iceState, watchdogStatus: { status: wdState, consecutiveStalls: stalls } } 8121 }, function () {}); 8122 } catch (_) {} 8123 }, 15000); 8124 } 8125 8126 stopHealthBeacons() { 8127 if (this._healthTimer) { 8128 clearInterval(this._healthTimer); 8129 this._healthTimer = null; 8130 } 8131 } 8132 8133 // 8134 // âââ 1) Low-level Socket connect (must complete before any room op) âââ 8135 // 8136 async _ensureSocketConnected() { 8137 return this.ensureSocketConnected(); 8138 } 8139 8140 // 8141 // âââ 2) Join room and get router caps âââ 8142 // 8143 async _joinRoom(roomId, userId) { 8144 await this._ensureSocketConnected(); 8145 8146 const isScreenRoom = String(roomId || '').startsWith('chatcrack_screen_'); 8147 const isWebcamPublishRoom = String(roomId || '').startsWith('chatcrack_webcam_'); 8148 const isOpenMicRoom = String(roomId || '').startsWith('chatcrack_openmic_'); 8149 const isVcallRoom = String(roomId || '').startsWith('chatcrack_vcall_'); 8150 const isOpenMicPublish = isOpenMicRoom && !!(this.localStream && this.localStream.getAudioTracks().length && !this.localStream.getVideoTracks().length); 8151 const isVcallPublish = isVcallRoom && !!(this.localStream && (this.localStream.getAudioTracks().length || this.localStream.getVideoTracks().length)); 8152 const useMinimalCaps = isScreenRoom || isWebcamPublishRoom || isOpenMicPublish || isVcallPublish; 8153 _chatLog('[WebRTC] ð¡ Joining room:', roomId, 'as', userId, useMinimalCaps ? '(minimal caps)' : ''); 8154 const payload = await new Promise((resolve, reject) => { 8155 // Timed: a missed ack (socket churn mid-emit) must fail fast so 8156 // recovery paths can retry instead of hanging forever. 8157 let settled = false;
8158 const to = setTimeout(() => { 8159 if (settled) return; 8160 settled = true; 8161 reject(new Error('join-room ack timeout')); 8162 }, 12000); 8163 try { 8164 this.socket.emit('chatcrack:join-room', { roomId, userId, mobile: useMinimalCaps }, (resp) => { 8165 if (settled) return; 8166 settled = true; 8167 clearTimeout(to); 8168 if (!resp || resp.ok !== true) { 8169 return reject(new Error((resp && resp.message) || 'Failed to join room')); 8170 } 8171 return resolve(resp); 8172 }); 8173 } catch (e) { 8174 if (!settled) { settled = true; clearTimeout(to); reject(e); } 8175 } 8176 }); 8177 8178 if (!payload || !payload.routerRtpCapabilities) { 8179 throw new Error('join-room returned no routerRtpCapabilities'); 8180 } 8181 8182 // Remember WHICH socket session joined: socket.io room membership and 8183 // server transports die on reconnect (new socket.id), so health checks 8184 // compare this against the live socket.id to detect a stale session. 8185 this._joinedSocketId = this.socket && this.socket.id; 8186 8187 this._routerRtpCapabilities = payload.routerRtpCapabilities; 8188 _chatLog('[WebRTC] ð¾ Router capabilities stored for device load'); 8189 } 8190 8191 // 8192 // âââ 3) Create Device with router caps âââ 8193 // 8194 async _ensureDeviceLoaded() { 8195 const rid = String(this.roomId || ''); 8196 const isScreenRoom = rid.startsWith('chatcrack_screen_'); 8197 const isWebcamPublishRoom = rid.startsWith('chatcrack_webcam_'); 8198 const isOpenMicRoom = rid.startsWith('chatcrack_openmic_'); 8199 const isVcallRoom = rid.startsWith('chatcrack_vcall_'); 8200 const isOpenMicPublish = isOpenMicRoom && !!(this.localStream && this.localStream.getAudioTracks().length && !this.localStream.getVideoTracks().length); 8201 const isVcallPublish = isVcallRoom && !!(this.localStream && (this.localStream.getAudioTracks().length || this.localStream.getVideoTracks().length)); 8202 const needsPublisherCaps = isScreenRoom || isWebcamPublishRoom || isOpenMicPublish || isVcallPublish; 8203 if (this.device && this.device.loaded) { 8204 if (!needsPublisherCaps || this._publisherCapsLoaded) return; 8205 // Was loaded as a viewer (full caps) â recreate for publish to avoid RTP ext collisions. 8206 this.device = null; 8207 } 8208 if (!this._routerRtpCapabilities) { 8209 throw new Error('Device load requested before router caps are set'); 8210 } 8211 let caps = this._routerRtpCapabilities; 8212 if (needsPublisherCaps) { 8213 const publishingWithAudio = !!(this.localStream && this.localStream.getAudioTracks().length); 8214 // Screen rooms always need Opus in router caps â viewers must decode screen/tab audio, 8215 // not only publishers who already have a local audio track loaded. 8216 caps = this._stripPublisherRouterCaps(caps, { 8217 screenOnly: isScreenRoom, 8218 includeAudio: isWebcamPublishRoom || isScreenRoom || isOpenMicPublish || isVcallPublish || publishingWithAudio 8219 }); 8220 } 8221 // Debug messages removed for cleaner console 8222 const DeviceCtor = await getMediasoupDeviceCtor(); // â robust getter 8223 this.device = new DeviceCtor(); // â¬ ï¸ was: new mediasoupClient.Device() 8224 await this.device.load({ routerRtpCapabilities: caps }); 8225 this._publisherCapsLoaded = needsPublisherCaps; 8226 _chatLog('[WebRTC] â Device.load() complete'); 8227 _chatLog('[codec] Device codecs:', this.device.rtpCapabilities.codecs.map(c => c.mimeType)); 8228 } 8229 8230 // 8231 // âââ 4) Create server transports and bind client transports âââ 8232 // 8233 async _createTransports() { 8234 const roomId = this.roomId; 8235 const userId = this.userId; 8236 _chatLog('[WebRTC] ð§ Creating transports...'); 8237 8238 const create = async (direction) => {
8239 const overlap = typeof window._ccMediaRolloverOverlapActive === 'function' 8240 && window._ccMediaRolloverOverlapActive(); 8241 const payload = await new Promise((resolve, reject) => { 8242 try { 8243 this.socket.emit('chatcrack:create-transport', 8244 { roomId, userId, direction, rollover: !!overlap }, 8245 (resp) => resp && resp.ok ? resolve(resp) : reject(new Error((resp && resp.message) || 'create-transport failed')) 8246 ); 8247 } catch (e) { reject(e); } 8248 }); 8249 8250 const { 8251 id, iceParameters, iceCandidates, dtlsParameters, sctpParameters 8252 } = payload; 8253 8254 const opts = window.ccMediasoupTransportOpts 8255 ? window.ccMediasoupTransportOpts(payload) 8256 : { id, iceParameters, iceCandidates, dtlsParameters, sctpParameters }; 8257 8258 if (direction === 'send') { 8259 this.sendTransport = this.device.createSendTransport(opts); 8260 this.sendTransport.on('connect', async ({ dtlsParameters }, cb, errb) => { 8261 try { 8262 this.socket.emit('chatcrack:connect-transport', { roomId, transportId: id, dtlsParameters }, (resp) => { 8263 if (resp && resp.ok) cb(); else errb(new Error((resp && resp.message) || 'connect-transport failed')); 8264 }); 8265 } catch (e) { errb(e); } 8266 }); 8267 this.sendTransport.on('produce', ({ kind, rtpParameters, appData }, cb, errb) => { 8268 _chatLog('[WATCHDOG] 𧪠Produce callback triggered, kind:', kind); 8269 this.socket.emit('chatcrack:produce', { roomId, transportId: id, kind, rtpParameters, userId: this.userId, keepPrevious: !!(typeof window._ccMediaRolloverOverlapActive === 'function' && window._ccMediaRolloverOverlapActive()) }, (resp) => { 8270 _chatLog('[WATCHDOG] 𧪠Produce server response FULL:', JSON.stringify(resp)); 8271 if (resp && resp.ok) { 8272 const producerId = resp.producerId || resp.id || undefined; 8273 _chatLog('[WATCHDOG] 𧪠Producer ID from server:', producerId); 8274 if (producerId) { 8275 cb({ id: producerId }); 8276 } else { 8277 console.warn('[WATCHDOG] 𧪠Server returned ok=true but no producer ID - this is a server bug. Generating fallback ID.'); 8278 // Generate a temporary ID to prevent undefined producer.id 8279 const fallbackId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; 8280 cb({ id: fallbackId }); 8281 } 8282 } else { 8283 console.error('[WATCHDOG] 𧪠Produce failed:', resp?.message || 'unknown error'); 8284 errb(new Error((resp && resp.message) || 'produce failed')); 8285 } 8286 }); 8287 }); 8288 this.sendTransport.on('close', () => { 8289 if (this._suppressTransportCloseRecovery) return; 8290 console.warn('[WebRTC] Send transport closed â watchdog will restart producer'); 8291 this._onProducerTransportClose(); 8292 }); 8293 } else { 8294 this.recvTransport = this.device.createRecvTransport(opts); 8295 this.recvTransport.on('connect', async ({ dtlsParameters }, cb, errb) => { 8296 try { 8297 this.socket.emit('chatcrack:connect-transport', { roomId, transportId: id, dtlsParameters }, (resp) => { 8298 if (resp && resp.ok) cb(); else errb(new Error((resp && resp.message) || 'connect-transport failed')); 8299 }); 8300 } catch (e) { errb(e); } 8301 }); 8302 } 8303 }; 8304 8305 await create('send'); 8306 await create('recv'); 8307 _chatLog('[WebRTC] Transports created successfully'); 8308 } 8309 8310 /** Screen publisher: send transport only (no recv PC; avoids extra SDP sections). */ 8311 async _createSendTransportOnly({ mobile = false, screen = false } = {}) { 8312 const roomId = this.roomId; 8313 const userId = this.userId; 8314 _chatLog('[WebRTC] ð§ Creating send-only transport...');
8315 const overlap = typeof window._ccMediaRolloverOverlapActive === 'function' 8316 && window._ccMediaRolloverOverlapActive(); 8317 const payload = await new Promise((resolve, reject) => { 8318 try { 8319 this.socket.emit('chatcrack:create-transport', 8320 { roomId, userId, direction: 'send', mobile: !!mobile, screen: !!screen, rollover: !!overlap }, 8321 (resp) => resp && resp.ok ? resolve(resp) : reject(new Error((resp && resp.message) || 'create-transport failed')) 8322 ); 8323 } catch (e) { reject(e); } 8324 }); 8325 const { id, iceParameters, iceCandidates, dtlsParameters, sctpParameters } = payload; 8326 const opts = window.ccMediasoupTransportOpts 8327 ? window.ccMediasoupTransportOpts(payload) 8328 : { id, iceParameters, iceCandidates, dtlsParameters, sctpParameters }; 8329 this.sendTransport = this.device.createSendTransport(opts); 8330 this.sendTransport.on('connect', async ({ dtlsParameters }, cb, errb) => { 8331 try { 8332 this.socket.emit('chatcrack:connect-transport', { roomId, transportId: id, dtlsParameters }, (resp) => { 8333 if (resp && resp.ok) cb(); else errb(new Error((resp && resp.message) || 'connect-transport failed')); 8334 }); 8335 } catch (e) { errb(e); } 8336 }); 8337 this.sendTransport.on('produce', ({ kind, rtpParameters, appData }, cb, errb) => { 8338 this.socket.emit('chatcrack:produce', { roomId, transportId: id, kind, rtpParameters, userId: this.userId, keepPrevious: !!(typeof window._ccMediaRolloverOverlapActive === 'function' && window._ccMediaRolloverOverlapActive()) }, (resp) => { 8339 if (resp && resp.ok) { 8340 const producerId = resp.producerId || resp.id; 8341 if (producerId) cb({ id: producerId }); 8342 else errb(new Error('produce ok but no producer id')); 8343 } else { 8344 errb(new Error((resp && resp.message) || 'produce failed')); 8345 } 8346 }); 8347 }); 8348 this.sendTransport.on('close', () => { 8349 if (this._suppressTransportCloseRecovery) return; 8350 console.warn('[WebRTC] Send transport closed'); 8351 }); 8352 _chatLog('[WebRTC] Send-only transport created'); 8353 } 8354 8355 /** Open-mic / viewer listener: recv transport only (one consumer PC per socket per room). */ 8356 async _createRecvTransportOnly() { 8357 const roomId = this.roomId; 8358 const userId = this.userId; 8359 _chatLog('[WebRTC] ð§ Creating recv-only transport...'); 8360 const payload = await new Promise((resolve, reject) => { 8361 try { 8362 this.socket.emit('chatcrack:create-transport', 8363 { roomId, userId, direction: 'recv', rollover: !!(typeof window._ccMediaRolloverOverlapActive === 'function' && window._ccMediaRolloverOverlapActive()) }, 8364 (resp) => resp && resp.ok ? resolve(resp) : reject(new Error((resp && resp.message) || 'create-transport failed')) 8365 ); 8366 } catch (e) { reject(e); } 8367 }); 8368 const { id, iceParameters, iceCandidates, dtlsParameters, sctpParameters } = payload; 8369 const opts = window.ccMediasoupTransportOpts 8370 ? window.ccMediasoupTransportOpts(payload) 8371 : { id, iceParameters, iceCandidates, dtlsParameters, sctpParameters }; 8372 this.recvTransport = this.device.createRecvTransport(opts); 8373 this.recvTransport.on('connect', async ({ dtlsParameters }, cb, errb) => { 8374 try { 8375 this.socket.emit('chatcrack:connect-transport', { roomId, transportId: id, dtlsParameters }, (resp) => { 8376 if (resp && resp.ok) cb(); else errb(new Error((resp && resp.message) || 'connect-transport failed')); 8377 }); 8378 } catch (e) { errb(e); } 8379 }); 8380 this._recvTransportConnected = false; 8381 try { 8382 this.recvTransport.on('connectionstatechange', () => { 8383 const st = this.recvTransport && this.recvTransport.connectionState; 8384 if (st === 'connected') this._recvTransportConnected = true; 8385 }); 8386 } catch (_) {} 8387 _chatLog('[WebRTC] Recv-only transport created'); 8388 } 8389 8390 /** Resolves once the recv DTLS/ICE path is up, or after timeout. Consume triggers connect. */ 8391 _waitRecvTransportConnected(ms) { 8392 const t = this.recvTransport; 8393 if (!t) return Promise.resolve(false); 8394 if (this._recvTransportConnected || t.connectionState === 'connected') return Promise.resolve(true); 8395 const limit = typeof ms === 'number' ? ms : 4000; 8396 return new Promise((resolve) => { 8397 let done = false;
8398 const finish = (ok) => { 8399 if (done) return; 8400 done = true; 8401 try { t.removeListener('connectionstatechange', onState); } catch (_) {} 8402 clearTimeout(to); 8403 resolve(!!ok); 8404 }; 8405 const onState = () => { 8406 const st = t.connectionState; 8407 if (st === 'connected') finish(true); 8408 else if (st === 'failed' || st === 'closed') finish(false); 8409 }; 8410 const to = setTimeout(() => finish(t.connectionState === 'connected'), limit); 8411 try { t.on('connectionstatechange', onState); } catch (_) { finish(false); } 8412 }); 8413 } 8414 8415 async _createTransport(direction) { 8416 const roomId = this.roomId; 8417 const userId = this.userId; 8418 const payload = await new Promise((resolve, reject) => { 8419 try { 8420 this.socket.emit('chatcrack:create-transport', { roomId, userId, direction }, (resp) => resp && resp.ok ? resolve(resp) : reject(new Error((resp && resp.message) || 'create-transport failed'))); 8421 } catch (e) { reject(e); } 8422 }); 8423 8424 const { id, iceParameters, iceCandidates, dtlsParameters, sctpParameters } = payload; 8425 const opts = window.ccMediasoupTransportOpts 8426 ? window.ccMediasoupTransportOpts(payload) 8427 : { id, iceParameters, iceCandidates, dtlsParameters, sctpParameters }; 8428 if (direction === 'send') { 8429 this.sendTransport = this.device.createSendTransport(opts); 8430 this.sendTransport.on('connect', ({ dtlsParameters }, cb, errb) => { 8431 this.socket.emit('chatcrack:connect-transport', { roomId, transportId: id, dtlsParameters }, (res) => res && res.ok ? cb() : errb(new Error((res && res.message) || 'connect-transport failed'))); 8432 }); 8433 this.sendTransport.on('produce', ({ kind, rtpParameters, appData }, cb, errb) => { 8434 this.socket.emit('chatcrack:produce', { roomId, transportId: id, kind, rtpParameters, userId: this.userId, keepPrevious: !!(typeof window._ccMediaRolloverOverlapActive === 'function' && window._ccMediaRolloverOverlapActive()) }, (res) => res && res.ok ? cb({ id: res.producerId }) : errb(new Error((res && res.message) || 'produce failed'))); 8435 }); 8436 this.sendTransport.on('close', () => { 8437 if (this._suppressTransportCloseRecovery) return; 8438 console.warn('[WebRTC] Send transport closed â watchdog will restart producer'); 8439 this._onProducerTransportClose(); 8440 }); 8441 } else { 8442 this.recvTransport = this.device.createRecvTransport(opts); 8443 this.recvTransport.on('connect', ({ dtlsParameters }, cb, errb) => { 8444 this.socket.emit('chatcrack:connect-transport', { roomId, transportId: id, dtlsParameters }, (res) => res && res.ok ? cb() : errb(new Error((res && res.message) || 'connect-transport failed'))); 8445 }); 8446 } 8447 } 8448 8449 // 8450 // âââ 5) Produce camera (VP8, single encoding, no RTX/simulcast) âââ 8451 // 8452 async _produceCamera(localStream, maxBitrate = 600_000) { 8453 if (!this.sendTransport || typeof this.sendTransport.produce !== 'function') { 8454 throw new Error('sendTransport not ready'); 8455 } 8456 const videoTrack = localStream.getVideoTracks()[0]; 8457 const audioTrack = (localStream.getAudioTracks && localStream.getAudioTracks()[0]) || null; 8458 if (!videoTrack && !audioTrack) throw new Error('no media tracks'); 8459 8460 // Minimal parameters; let mediasoup-client choose codec/encodings to match Router 8461 let lastProducer = null; 8462 // Produce audio FIRST to align m-section ordering (mid=0 often audio) 8463 if (audioTrack && this.device?.canProduce('audio')) { 8464 console.log('[PRODUCE] audio minimal produce'); 8465 const keepUnmuted = this.isMicMuted === false; 8466 audioTrack.enabled = keepUnmuted; 8467 const isVcallProduce = String(this.roomId || '').startsWith('chatcrack_vcall_'); 8468 const produceOpts = { track: audioTrack, paused: !keepUnmuted, stopTracks: false }; 8469 if (isVcallProduce) { 8470 produceOpts.codecOptions = { 8471 opusDtx: false, 8472 opusFec: true, 8473 opusMaxAverageBitrate: 64000 8474 }; 8475 } 8476 const aProd = await this.sendTransport.produce(produceOpts); 8477 this.producers.set('mic', aProd); 8478 this.audioProducer = aProd; 8479 this.isMicMuted = !keepUnmuted; 8480 this._bindMicTrackDeviceRecovery(audioTrack); 8481 this.startMicHealthWatchdog(); 8482 if (keepUnmuted && typeof this._serverMicProducerOp === 'function') { 8483 try { this._serverMicProducerOp('resume', aProd); } catch (_) {} 8484 } 8485 8486 _chatLog('[WebRTC] ðï¸ Mic producer created (muted=' + !keepUnmuted + '):', aProd.id); 8487 lastProducer = aProd; 8488 } 8489 if (videoTrack && this.device?.canProduce('video')) { 8490 console.log('[PRODUCE] video produce with encodings');
8491 const enc = this.getSendEncodingFor(this.currentQuality || 'low'); 8492 let vProd; 8493 try { 8494 vProd = await this.sendTransport.produce({ 8495 track: videoTrack, 8496 encodings: enc, 8497 codecOptions: { videoGoogleStartBitrate: 120 }, 8498 stopTracks: false 8499 }); 8500 } catch (_) { 8501 vProd = await this.sendTransport.produce({ track: videoTrack, stopTracks: false }); 8502 } 8503 vProd.on('trackended', () => console.warn('[WebRTC] producer track ended')); 8504 vProd.on('transportclose', () => { 8505 if (this._suppressTransportCloseRecovery) return; 8506 if (typeof this._onProducerTransportClose === 'function') this._onProducerTransportClose(); 8507 }); 8508 this.producers.set('camera', vProd); 8509 this.cameraProducer = vProd; 8510 _chatLog('[WebRTC] ð¥ Camera video producer created:', vProd.id); 8511 lastProducer = vProd; 8512 try { 8513 const selfRtc = this; 8514 ;[0, 180, 500, 1200, 2500].forEach(function (ms) { 8515 setTimeout(function () { 8516 try { 8517 if (!selfRtc.socket || !selfRtc.socket.connected || !selfRtc.roomId) return; 8518 if (!selfRtc.cameraProducer || selfRtc.cameraProducer.closed) return; 8519 if (typeof window._ccNudgePublisherKeyframes === 'function') { 8520 window._ccNudgePublisherKeyframes(); 8521 } else { 8522 selfRtc.socket.emit('chatcrack:force-keyframes', { 8523 roomId: selfRtc.roomId, 8524 userId: selfRtc.userId 8525 }); 8526 } 8527 } catch (_) {} 8528 }, ms); 8529 }); 8530 } catch (_) {} 8531 // Start bitrate pill on own dock (â² uplink) 8532 try { 8533 if (window.WebcamBitrate && typeof window.WebcamBitrate.refreshLocal === 'function') { 8534 window.WebcamBitrate.refreshLocal(); 8535 } else { 8536 const me = window.currentUsername || (window.me && window.me.username); 8537 const _lv = (me && typeof window._ccFindWebcamDockTile === 'function' 8538 && window._ccFindWebcamDockTile(me) 8539 && window._ccFindWebcamDockTile(me).querySelector('video')) 8540 || document.querySelector('#webcamModal video, .local-webcam video, .webcam-dock video'); 8541 if (window.WebcamBitrate && _lv) window.WebcamBitrate.observeLocalProducer(this.cameraProducer, _lv); 8542 } 8543 } catch (e) {} 8544 // Watchdog will be started after isWebcamOn is set to true 8545 } 8546 return lastProducer; 8547 } 8548 8549 // 8550 // âââ Public entrypoint used by your webcam start button âââ 8551 // 8552 async connectAndStart({ roomId, userId, localStream }) { 8553 this.roomId = roomId; 8554 this.userId = userId; 8555 8556 // 1) socket â 2) join â 3) device â 4) transports â 5) produce 8557 await this._ensureSocketConnected(); 8558 await this._joinRoom(roomId, userId); 8559 await this._ensureDeviceLoaded(); 8560 await this._createTransports(); 8561 await this._produceCamera(localStream, 600_000); 8562 } 8563 8564 /** Router caps safe for Chrome screen-capture publish (no extension id collisions). */ 8565 _stripPublisherRouterCaps(caps, { screenOnly = true, includeAudio = false } = {}) { 8566 const out = JSON.parse(JSON.stringify(caps)); 8567 if (Array.isArray(out.codecs)) { 8568 out.codecs = out.codecs.filter((c) => { 8569 const m = (c.mimeType || '').toLowerCase(); 8570 if (screenOnly) { 8571 if (includeAudio) return m === 'video/vp8' || m === 'audio/opus' || m === 'video/rtx'; 8572 return m === 'video/vp8' || m === 'video/rtx'; 8573 } 8574 return m === 'video/vp8' || m === 'audio/opus' || m === 'video/rtx'; 8575 }); 8576 out.codecs.forEach((c) => { 8577 if (!c.rtcpFeedback) return; 8578 c.rtcpFeedback = c.rtcpFeedback.filter((fb) => {
8579 const k = fb.type + (fb.parameter ? ' ' + fb.parameter : ''); 8580 return k === 'nack' || k === 'nack pli' || k === 'ccm fir'; 8581 }); 8582 }); 8583 } 8584 if (Array.isArray(out.headerExtensions)) { 8585 const allow = new Set([ 8586 'urn:ietf:params:rtp-hdrext:sdes:mid', 8587 'http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time', 8588 'urn:ietf:params:rtp-hdrext:toffset' 8589 ]); 8590 out.headerExtensions = out.headerExtensions.filter((e) => allow.has(e.uri)); 8591 } 8592 return out; 8593 } 8594 8595 /** 8596 * Screen-share publisher: VP8 video + optional Opus tab/system audio. 8597 * Room: chatcrack_screen_<username> 8598 */ 8599 async startScreenStreaming(mediaStream, username) { 8600 const user = username || window.currentUsername || (window.me && window.me.username); 8601 if (!user) throw new Error('username required'); 8602 if (!mediaStream) throw new Error('no stream'); 8603 const videoTrack = mediaStream.getVideoTracks()[0]; 8604 const audioTrack = mediaStream.getAudioTracks()[0] || null; 8605 if (!videoTrack || videoTrack.readyState !== 'live') { 8606 throw new Error('Screen capture ended â pick your screen again'); 8607 } 8608 8609 this._suppressTransportCloseRecovery = true; 8610 if (typeof this._abortWatchdogRestarts === 'function') this._abortWatchdogRestarts(); 8611 this.userId = user; 8612 this.roomId = 'chatcrack_screen_' + user; 8613 this.localStream = mediaStream; 8614 8615 await this._ensureSocketConnected(); 8616 await this._joinRoom(this.roomId, this.userId); 8617 8618 const hasScreenAudio = !!(audioTrack && audioTrack.readyState === 'live'); 8619 const caps = this._stripPublisherRouterCaps(this._routerRtpCapabilities, { screenOnly: true, includeAudio: hasScreenAudio }); 8620 8621 const DeviceCtor = await getMediasoupDeviceCtor(); 8622 this.device = new DeviceCtor(); 8623 await this.device.load({ routerRtpCapabilities: caps }); 8624 8625 await this._createSendTransportOnly({ screen: true }); 8626 8627 if (!this.sendTransport || typeof this.sendTransport.produce !== 'function') { 8628 throw new Error('sendTransport not ready'); 8629 } 8630 8631 if (!this.producers) this.producers = new Map(); 8632 8633 const videoCandidates = []; 8634 try { 8635 if (typeof videoTrack.clone === 'function') videoCandidates.push(videoTrack.clone()); 8636 } catch (_) {} 8637 videoCandidates.push(videoTrack); 8638 8639 let producer = null; 8640 let videoErr = null; 8641 for (let i = 0; i < videoCandidates.length; i++) { 8642 const publishTrack = videoCandidates[i]; 8643 if (!publishTrack || publishTrack.readyState === 'ended') continue; 8644 try { 8645 _chatLog('[PRODUCE] screen video produce'); 8646 producer = await this.sendTransport.produce({ 8647 track: publishTrack, 8648 appData: { userId: this.userId, roomId: this.roomId, kind: 'video', source: 'screen' } 8649 }); 8650 break; 8651 } catch (e) { 8652 videoErr = e; 8653 console.warn('[WebRTC] screen video produce failed', e); 8654 } 8655 } 8656 if (!producer) throw videoErr || new Error('screen video produce failed'); 8657 8658 producer.on('trackended', () => { 8659 if (window.chatCrackScreenShare && window.chatCrackScreenShare.isSharing && !window.chatCrackScreenShare._ccWpShare) { 8660 window.chatCrackScreenShare.stopSharing(); 8661 } 8662 }); 8663 producer.on('transportclose', () => {}); 8664 8665 this.screenProducer = producer; 8666 this.cameraProducer = producer; 8667 this.producers.set('screen-video', producer); 8668 8669 let audioOk = false; 8670 if (hasScreenAudio && this.device?.canProduce('audio')) { 8671 try { 8672 _chatLog('[PRODUCE] screen audio produce'); 8673 const audioProducer = await this.sendTransport.produce({ 8674 track: audioTrack, 8675 appData: { userId: this.userId, roomId: this.roomId, kind: 'audio', source: 'screen' } 8676 }); 8677 this.producers.set('screen-audio', audioProducer); 8678 this.audioProducer = audioProducer; 8679 audioOk = true; 8680 } catch (e) { 8681 console.warn('[WebRTC] screen audio produce failed; continuing with video', e); 8682 } 8683 } 8684
8685 _chatLog('[WebRTC] Screen share publishing:', producer.id, audioOk ? '+ audio' : '(video only)'); 8686 return producer; 8687 } 8688 8689 async ensureSocketConnected() { 8690 if (this.socket && this.socket.connected) { 8691 return this.socket; 8692 } 8693 8694 const main = window.chatCrackWebRTC; 8695 8696 if (this._useSharedMediaSocket && main) { 8697 if (!main._mediaConnectPromise) { 8698 main._mediaConnectPromise = main._openMediaSocket().finally(() => { 8699 main._mediaConnectPromise = null; 8700 }); 8701 } 8702 await main._mediaConnectPromise; 8703 if (main.socket && main.socket.connected) { 8704 this.socket = main.socket; 8705 this._borrowsMediaSocket = true; 8706 return this.socket; 8707 } 8708 throw new Error('socket_connect_timeout'); 8709 } 8710 8711 if (this === main || !main) { 8712 if (!this._mediaConnectPromise) { 8713 this._mediaConnectPromise = this._openMediaSocket().finally(() => { 8714 this._mediaConnectPromise = null; 8715 }); 8716 } 8717 await this._mediaConnectPromise; 8718 if (this.socket && this.socket.connected) return this.socket; 8719 throw new Error('socket_connect_timeout'); 8720 } 8721 8722 if (!this._mediaConnectPromise) { 8723 this._mediaConnectPromise = this._openMediaSocket().finally(() => { 8724 this._mediaConnectPromise = null; 8725 }); 8726 } 8727 await this._mediaConnectPromise; 8728 if (this.socket && this.socket.connected) return this.socket; 8729 throw new Error('socket_connect_timeout'); 8730 } 8731 8732 async _openMediaSocket() { 8733 if (this.socket && this.socket.connected) { 8734 return this.socket; 8735 } 8736 8737 if (this.socket) { 8738 try { this.socket.off(); this.socket.disconnect(); } catch (_) {} 8739 this.socket = null; 8740 } 8741 8742 _chatLog('[WebRTC] ð¡ Connecting Socket.IO to', this.serverUrl); 8743 8744 const opts = { 8745 // Connect with POLLING FIRST, then upgrade to WebSocket. Listing 8746 // 'websocket' first meant that when a browser/network couldn't 8747 // establish the wss:// upgrade (e.g. Firefox "can't establish a 8748 // connection ... transport=websocket"), socket.io v4 did NOT fall 8749 // back to polling and the whole connection timed out â killing 8750 // open-mic/webcam/screen even though plain polling works fine. 8751 // Polling is the proven-reliable path (HTTP 200); WS upgrade still 8752 // happens automatically right after when the network allows it. 8753 transports: ['polling', 'websocket'], 8754 // Belt-and-braces for socket.io builds that support it: if the first 8755 // transport ever fails, try the others before giving up. 8756 tryAllTransports: true, 8757 reconnection: true, 8758 // NEVER give up reconnecting: a media-server reboot (PM2 restart, 8759 // deploy) can take longer than 5 attempts allowed. With a finite 8760 // cap the socket went permanently dead after ~15s and cams/mics 8761 // only came back on a full page refresh. Infinite retries with a 8762 // capped backoff means the 'connect' handler below always fires 8763 // when the server returns, which triggers automatic recovery. 8764 reconnectionAttempts: Infinity, 8765 reconnectionDelay: 500, 8766 reconnectionDelayMax: 5000, 8767 timeout: 20000, 8768 forceNew: true, 8769 withCredentials: false 8770 }; 8771 8772 const sock = io(this.serverUrl, opts); 8773 this.socket = sock; 8774 8775 sock.on('connect', () => { 8776 _chatLog('[WebRTC] ð Socket connected with ID:', sock.id); 8777 if (this._hadMediaSocketConnected) { 8778 // Any media-socket RECONNECT means the server may have rebooted 8779 // and lost all producers/transports â but the client-side 8780 // producer objects still look "open" (mediasoup-client doesn't 8781 // close them on socket loss), so the old needs* pre-checks were 8782 // false negatives and recovery never ran. Always run the 8783 // verified recovery: it checks server-side producer state first 8784 // and only restarts what is actually missing. 8785 if (typeof window.ccRecoverWebcamsAfterMediaRestart === 'function') { 8786 // Long disconnect / media-restarting â hard dock refresh. 8787 // Short blip â verify + refresh only broken/dead viewers. 8788 const downFor = window._ccMediaSocketDisconnectedAt 8789 ? (Date.now() - window._ccMediaSocketDisconnectedAt) : 0; 8790 // 2s was far too aggressive: Engine.IO upgrades and 3G blips 8791 // routinely last 2â5s. Treating those as a media-server reboot 8792 // tore every docked cam (and the local preview) down. 8793 const expectRestart = !!(window._ccExpectMediaRestart 8794 && (Date.now() - window._ccExpectMediaRestart) < 120000); 8795 const longOutage = downFor >= 8000; 8796 const force = expectRestart || longOutage; 8797 setTimeout(() => window.ccRecoverWebcamsAfterMediaRestart({ 8798 verify: true, 8799 force 8800 }), 800); 8801 } 8802 } 8803 this._hadMediaSocketConnected = true; 8804 window._ccMediaSocketDisconnectedAt = 0; 8805 }); 8806 8807 if (!sock._ccMediaRestartWired) { 8808 sock._ccMediaRestartWired = true; 8809 sock.on('chatcrack:media-restarting', (payload) => { 8810 _chatLog('[WebRTC] Media server restarting â holding live cams/mics'); 8811 if (payload && (payload.hardReset || payload.camsOnly)) { 8812 window._ccExpectMediaRestart = Date.now(); 8813 window._ccHoldLiveMedia = false;
8814 return; 8815 } 8816 if (payload && (payload.overlap || payload.keepLive) 8817 && typeof window._ccBeginMediaRolloverOverlap === 'function') { 8818 window._ccBeginMediaRolloverOverlap(payload); 8819 } else if (typeof window._ccHoldLiveMediaAcrossRestart === 'function') { 8820 window._ccHoldLiveMediaAcrossRestart(); 8821 } else { 8822 window._ccExpectMediaRestart = Date.now(); 8823 } 8824 }); 8825 sock.on('chatcrack:media-ready', (payload) => { 8826 const hardReset = !!(payload && (payload.hardReset || payload.camsOnly)); 8827 if (!hardReset && payload && (payload.overlap || payload.keepLive) 8828 && typeof window._ccBeginMediaRolloverOverlap === 'function') { 8829 window._ccBeginMediaRolloverOverlap(payload); 8830 } 8831 if (typeof window.ccRecoverWebcamsAfterMediaRestart === 'function') { 8832 setTimeout(() => window.ccRecoverWebcamsAfterMediaRestart({ 8833 verify: true, 8834 force: hardReset, 8835 overlap: !hardReset && !!(payload && (payload.overlap || payload.keepLive)), 8836 silent: true 8837 }), 80); 8838 } 8839 if (hardReset && typeof window._ccBootFavouriteAutoOpen === 'function') { 8840 setTimeout(() => window._ccBootFavouriteAutoOpen('mediasoup_reset'), 300); 8841 } 8842 }); 8843 } 8844 8845 sock.on('disconnect', (r) => { 8846 console.warn('[WebRTC] ð´ Socket disconnected:', r); 8847 window._ccMediaSocketDisconnectedAt = Date.now(); 8848 }); 8849 8850 sock.on('connect_error', (e) => { 8851 console.warn('[WebRTC] â ï¸ Socket connect_error:', e?.message || e); 8852 }); 8853 8854 await new Promise((resolve, reject) => { 8855 let settled = false; 8856 // Give socket.io room to use its OWN reconnection (reconnectionAttempts:5, 8857 // per-attempt timeout 12s). A single transient connect_error must NOT 8858 // fail the whole open-mic/webcam/screen connect â only an overall 8859 // timeout or a final reconnect failure should. Previously this rejected 8860 // at 5s and on the FIRST connect_error, so any network/transport blip 8861 // surfaced as "Media server unavailable" even though a retry would work. 8862 const to = setTimeout(() => { 8863 if (settled) return; settled = true; 8864 reject(new Error('socket_connect_timeout')); 8865 }, 15000); 8866 8867 sock.once('connect', () => { 8868 if (settled) return; settled = true; 8869 clearTimeout(to); 8870 resolve(); 8871 }); 8872 8873 // Log transient errors but keep waiting â socket.io will retry. 8874 sock.on('connect_error', (e) => { 8875 console.warn('[WebRTC] â ï¸ Socket connect_error (will retry):', e?.message || e); 8876 }); 8877 8878 // Only give up early if socket.io exhausts all its reconnection attempts. 8879 try { 8880 sock.io.once('reconnect_failed', () => { 8881 if (settled) return; settled = true; 8882 clearTimeout(to); 8883 reject(new Error('socket_connect_timeout')); 8884 }); 8885 } catch (_) {} 8886 }); 8887 8888 return sock; 8889 } 8890 8891 // ð ENHANCED: Stability-first quality-based encoding configuration 8892 getSendEncodingFor(quality) { 8893 const isFirefox = typeof window._ccIsFirefox === 'function' 8894 ? window._ccIsFirefox() 8895 : (typeof InstallTrigger !== 'undefined' || /firefox/i.test(navigator.userAgent || '')); 8896 const QUALITY = { 8897 low: { maxBitrate: 90_000, maxFramerate: isFirefox ? 12 : 15, scaleResolutionDownBy: 1.0 }, 8898 medium: { maxBitrate: 120_000, maxFramerate: isFirefox ? 12 : 20, scaleResolutionDownBy: 1.0 }, 8899 high: { maxBitrate: 256_000, maxFramerate: isFirefox ? 20 : 30, scaleResolutionDownBy: 1.0 }, 8900 hd: { maxBitrate: 320_000, maxFramerate: isFirefox ? 20 : 30, scaleResolutionDownBy: 1.0 }, 8901 }; 8902 8903 const q = QUALITY[quality] || QUALITY.low; 8904 return [{ maxBitrate: q.maxBitrate, maxFramerate: q.maxFramerate, scaleResolutionDownBy: q.scaleResolutionDownBy }]; 8905 } 8906 8907 // ð Runtime bitrate/FPS/scale switching (and recover sender if missing) 8908 async updateBitrate(newQuality) { 8909 const producer = this.cameraProducer; 8910 if (!producer || producer.closed) { 8911 console.warn('[WATCHDOG] Cannot update bitrate - camera producer not available'); 8912 return false;
8913 } 8914 8915 let sender = this.sender; 8916 if (!sender || (sender.track && sender.track.readyState === 'ended')) { 8917 try { 8918 const pc = this.sendTransport && this.sendTransport._handler && this.sendTransport._handler._pc; 8919 const senders = pc && typeof pc.getSenders === 'function' ? pc.getSenders() : []; 8920 sender = senders.find(s => s.track && s.track.kind === 'video') || null; 8921 this.sender = sender; 8922 } catch (_) { 8923 sender = null; 8924 } 8925 } 8926 if (!sender) { 8927 console.warn('[WATCHDOG] Cannot update bitrate - video sender not available'); 8928 return false; 8929 } 8930 8931 const isFirefox = typeof window._ccIsFirefox === 'function' 8932 ? window._ccIsFirefox() 8933 : (typeof InstallTrigger !== 'undefined' || /firefox/i.test(navigator.userAgent || '')); 8934 const quality = String(newQuality || 'low').toLowerCase(); 8935 if (!['low', 'medium', 'high', 'hd'].includes(quality)) { 8936 console.warn('[BITRATE] Unknown quality:', newQuality); 8937 return false; 8938 } 8939 8940 try { 8941 const enc = this.getSendEncodingFor(quality)[0]; 8942 console.log(`[BITRATE] ð¯ Applying ${quality} quality settings:`, { 8943 maxBitrate: `${(enc.maxBitrate / 1000).toFixed(0)}kbps`, 8944 maxFramerate: `${enc.maxFramerate}fps`, 8945 scaleResolutionDownBy: enc.scaleResolutionDownBy 8946 }); 8947 8948 // Prefer mediasoup helper when available (keeps SFU encodings in sync) 8949 if (typeof producer.setRtpEncodingParameters === 'function') { 8950 try { 8951 await producer.setRtpEncodingParameters({ 8952 maxBitrate: enc.maxBitrate, 8953 maxFramerate: enc.maxFramerate, 8954 scaleResolutionDownBy: enc.scaleResolutionDownBy 8955 }); 8956 } catch (eSet) { 8957 console.warn('[BITRATE] setRtpEncodingParameters failed, falling back to sender:', eSet?.message || eSet); 8958 } 8959 } 8960 8961 const params = sender.getParameters(); 8962 params.encodings = params.encodings && params.encodings.length ? params.encodings : [{}]; 8963 params.encodings[0].maxBitrate = enc.maxBitrate; 8964 params.encodings[0].maxFramerate = enc.maxFramerate; 8965 params.encodings[0].scaleResolutionDownBy = enc.scaleResolutionDownBy; 8966 8967 if (!params.degradationPreference) { 8968 params.degradationPreference = isFirefox ? 'maintain-resolution' : 'maintain-framerate'; 8969 } 8970 8971 _chatLog('[BITRATE] ð¡ Sending parameters to RTCRtpSender:', { 8972 maxBitrate: `${(params.encodings[0].maxBitrate / 1000).toFixed(0)}kbps`, 8973 maxFramerate: `${params.encodings[0].maxFramerate}fps`, 8974 scaleResolutionDownBy: params.encodings[0].scaleResolutionDownBy, 8975 degradationPreference: params.degradationPreference 8976 }); 8977 8978 await sender.setParameters(params); 8979 8980 this.currentQuality = quality; 8981 // Preferenced quality is set by the user (dropdown / save), not by every encode tweak. 8982 if (!this._ccPreferredQuality) this._ccPreferredQuality = quality; 8983 console.log(`[BITRATE] â Successfully applied ${quality}
8983 quality (${(enc.maxBitrate / 1000).toFixed(0)}kbps @ ${enc.maxFramerate}fps, scale ${enc.scaleResolutionDownBy})`); 8984 8985 window.__chatcrack = window.__chatcrack || {}; 8986 window.__chatcrack.currentQuality = quality; 8987 return true; 8988 } catch (error) { 8989 console.error('[WATCHDOG] Failed to update bitrate:', error); 8990 return false; 8991 } 8992 } 8993 8994 /** 8995 * Live quality switch without stopping the cam: 8996 * re-capture at the new resolution (replaceTrack) + apply encode params. 8997 */ 8998 async applyVideoQualityLive(newQuality) { 8999 const quality = String(newQuality || 'low').toLowerCase(); 9000 if (!['low', 'medium', 'high', 'hd'].includes(quality)) return false; 9001 9002 // Honor the user's manual pick â never rewrite the dropdown/prefs here. 9003 this._ccPreferredQuality = quality; 9004 this.currentQuality = quality; 9005 try { 9006 const sel = document.getElementById('videoQualitySelect'); 9007 if (sel && sel.value !== quality) sel.value = quality; 9008 if (window.chatCrackWebcam && window.chatCrackWebcam.webcamSettings) { 9009 window.chatCrackWebcam.webcamSettings.videoQuality = quality; 9010 } 9011 } catch (_) {} 9012 9013 if (!this.cameraProducer || this.cameraProducer.closed) { 9014 // Not live â preference is saved; next cam start will use it. 9015 return true; 9016 } 9017 9018 // Prefer same-device applyConstraints â forceFreshTrack blacks viewers. 9019 let videoOk = false; 9020 if (typeof this.applyVideoDeviceFromSettings === 'function') { 9021 videoOk = !!(await this.applyVideoDeviceFromSettings({ forceFreshTrack: false })); 9022 if (!videoOk) { 9023 videoOk = !!(await this.applyVideoDeviceFromSettings({ forceFreshTrack: true })); 9024 } 9025 } 9026 if (typeof this.updateBitrate === 'function') { 9027 await this.updateBitrate(quality); 9028 } 9029 if (typeof this._refreshLocalWebcamPreview === 'function') { 9030 this._refreshLocalWebcamPreview(); 9031 } 9032 return videoOk; 9033 } 9034 9035 // ðµ NEW: Monitor actual audio bitrate from streaming stats 9036 async checkAudioBitrate() { 9037 try { 9038 const audioProducer = this.producers?.get('mic') || this.audioProducer; 9039 if (!audioProducer || audioProducer.closed || typeof audioProducer.getStats !== 'function') { 9040 this.stopAudioBitrateMonitoring(); 9041 return null; 9042 } 9043 const stats = await audioProducer.getStats(); 9044 let audioStat = null; 9045 9046 // Find audio stats (outbound-rtp for producer) 9047 for (const stat of stats.values()) { 9048 if (stat.type === 'outbound-rtp' && stat.mediaType === 'audio') { 9049 audioStat = stat; 9050 break; 9051 } 9052 } 9053 9054 if (audioStat && audioStat.bytesSent !== undefined) { 9055 // Calculate bitrate from bytes sent 9056 const now = Date.now(); 9057 if (!this._lastAudioBytes || !this._lastAudioTime) { 9058 this._lastAudioBytes = audioStat.bytesSent; 9059 this._lastAudioTime = now; 9060 return null; // Need two samples 9061 } 9062 9063 const timeDiff = (now - this._lastAudioTime) / 1000; // seconds 9064 const bytesDiff = audioStat.bytesSent - this._lastAudioBytes; 9065 const bitrate = (bytesDiff * 8) / timeDiff; // bits per second 9066 9067 this._lastAudioBytes = audioStat.bytesSent; 9068 this._lastAudioTime = now; 9069 9070 return { 9071 bitrate: bitrate, 9072 bitrateKbps: (bitrate / 1000).toFixed(2), 9073 bytesSent: audioStat.bytesSent, 9074 packetsSent: audioStat.packetsSent 9075 }; 9076 } 9077 } catch (error) { 9078 const errName = error && error.name; 9079 const errMsg = String((error && error.message) || error || ''); 9080 if (errName === 'InvalidStateError' || /closed|disconnected|destroyed/i.test(errMsg)) { 9081 this.stopAudioBitrateMonitoring(); 9082 return null; 9083 } 9084 console.warn('[AUDIO BITRATE] Failed to check audio bitrate:', error); 9085 } 9086 return null; 9087 } 9088 9089 startAudioBitrateMonitoring(audioProducer) { 9090 if (this._audioBitrateInterval) { 9091 clearInterval(this._audioBitrateInterval); 9092 } 9093 // Skip on Firefox while publishing â getStats competes with compose. 9094 if (typeof window._ccIsFirefox === 'function' && window._ccIsFirefox()) return; 9095 9096 // Check audio bitrate every 30 seconds (was 5s) 9097 this._audioBitrateInterval = setInterval(async () => { 9098 if (typeof window._ccComposerBusy === 'function' && window._ccComposerBusy()) return; 9099 const bitrateInfo = await this.checkAudioBitrate(); 9100 if (bitrateInfo) { 9101 _chatLog(`[AUDIO BITRATE] ðµ Current streaming audio bitrate: ${bitrateInfo.bitrateKbps} kbps (${bitrateInfo.packetsSent} packets sent)`); 9102 } 9103 }, 30000); 9104 } 9105 9106 stopAudioBitrateMonitoring() { 9107 if (this._audioBitrateInterval) { 9108 clearInterval(this._audioBitrateInterval); 9109 this._audioBitrateInterval = null; 9110 } 9111 this._lastAudioBytes = null; 9112 this._lastAudioTime = null; 9113 } 9114 9115 // Auto quality disabled â only the user's dropdown/save changes quality. 9116 async autoDownshiftBitrate() { 9117 return; 9118 } 9119 9120 // Auto quality disabled â only the user's dropdown/save changes quality. 9121 async attemptQualityUpshift() { 9122 return; 9123 } 9124 9125 // ð NEW: Seamless recovery without producer close 9126 async attemptPublisherRecovery() { 9127 const producer = this.cameraProducer; 9128 const sender = this.sender; 9129 if (!producer) return; 9130 // Cam Off intentionally pauses video â do not resume it as a "stall". 9131 if (this._isCameraIntentionallyOff()) { 9132 this._reassertCameraOffState(); 9133 _chatLog('[WATCHDOG] Skipping publisher recovery â camera intentionally off'); 9134 return; 9135 } 9136 9137 _chatLog('[WATCHDOG] Attempting seamless recovery...'); 9138 9139 // 1) Nudge encoder via RTCRtpSender.setParameters 9140 try { 9141 if (sender) { 9142 const p = sender.getParameters(); 9143 await sender.setParameters(p); 9144 _chatLog('[WATCHDOG] Encoder nudged via setParameters'); 9145 } 9146 } catch (e) { 9147 console.warn('[WATCHDOG] Encoder nudge failed:', e); 9148 } 9149 9150 // 2) Soft restart (pause/resume) 9151 try { 9152 await producer.pause(); 9153 await producer.resume(); 9154 _chatLog('[WATCHDOG] Soft restart completed (pause/resume)'); 9155 return; 9156 } catch (e) { 9157 console.warn('[WATCHDOG] Soft restart failed:', e); 9158 } 9159 9160 // 3) Replace track (same track or new gUM if needed) 9161 try { 9162 const stream = this.localStream; 9163 const track = stream?.getVideoTracks?.()[0]; 9164 if (track) { 9165 await producer.replaceTrack({ track }); 9166 _chatLog('[WATCHDOG] Track replacement completed'); 9167 return; 9168 } 9169 } catch (e) { 9170 console.warn('[WATCHDOG] Track replacement failed:', e); 9171 } 9172 9173 _chatLog('[WATCHDOG] All recovery methods attempted'); 9174 } 9175 9176 // ð ENHANCED: Producer watchdog with frames/bytes stall detection 9177 startProducerWatchdog() { 9178 if (this.watchdogTimer) { 9179 clearInterval(this.watchdogTimer); 9180 } 9181 9182 // Initialize tracking variables
9183 let lastBytes = 0, lastFrames = 0, stallCount = 0, recoveryAttempted = false, bitrateDownshifted = false, iceRestartAttempted = false; 9184 // 1Hz getStats was freezing Firefox compose â sample every 5s (skip while typing). 9185 const CHECK_MS = (typeof window._ccIsFirefox === 'function' && window._ccIsFirefox()) ? 8000 : 5000; 9186 const STALL_THRESH = 2; // â¥2 consecutive ticks for stall detection 9187 const ICE_RESTART_THRESH = 5; // ~5s total or transport failed for >3s 9188 9189 // Initialize watchdog state 9190 this._wdState = { 9191 status: 'HEALTHY', 9192 stallCount: 0, 9193 lastEvent: null, 9194 lastChangeTs: Date.now() 9195 }; 9196 9197 this.watchdogTimer = setInterval(async () => { 9198 if (typeof window._ccComposerBusy === 'function' && window._ccComposerBusy()) return; 9199 // Cam Off pauses video â treat as healthy so we never auto-resume it. 9200 if (this._isCameraIntentionallyOff()) { 9201 stallCount = 0; 9202 recoveryAttempted = false; 9203 bitrateDownshifted = false; 9204 iceRestartAttempted = false; 9205 this._reassertCameraOffState(); 9206 return; 9207 } 9208 const producer = this.cameraProducer; 9209 if (!producer || producer.closed) { 9210 if (producer && producer.closed) _chatLog('[WATCHDOG] Producer closed â triggering restart so stream stays up'); 9211 this._onProducerTransportClose(); 9212 return; 9213 } 9214 9215 try { 9216 const stats = await producer.getStats(); 9217 const out = [...stats.values()].find(s => s.type === 'outbound-rtp' && !s.isRemote); 9218 if (!out) return; 9219 9220 const bytes = out.bytesSent || 0; 9221 const frames = (out.framesSent || out.framesEncoded || 0); 9222 9223 // Calculate deltas for both frames and bytes 9224 const framesDelta = frames - lastFrames; 9225 const bytesDelta = bytes - lastBytes; 9226 9227 lastBytes = bytes; 9228 lastFrames = frames; 9229 9230 // Enhanced stall detection: both frames and bytes must be 0 for hard stall 9231 const hardStall = framesDelta === 0 && bytesDelta === 0; 9232 const degradedState = framesDelta === 0 || bytesDelta === 0; 9233 9234 // Log stall detection with clear tags 9235 if (hardStall) { 9236 console.warn('[HEALTH] ENCODER_STALL', {framesDelta, bytesDelta, stallCount}); 9237 } else if (degradedState) { 9238 console.warn('[HEALTH] ENCODER_DEGRADED', {framesDelta, bytesDelta, stallCount}); 9239 } 9240 9241 const isStalled = hardStall; 9242 9243 if (isStalled) { 9244 stallCount++; 9245 this._wdState.stallCount = stallCount; 9246 9247 if (stallCount >= STALL_THRESH) { 9248 // First attempt: recovery 9249 if (!recoveryAttempted) { 9250 _chatLog('[WATCHDOG] Attempting publisher recovery...'); 9251 this._wdState.status = 'DEGRADED'; 9252 this._wdState.lastEvent = 'NUDGE'; 9253 this._wdState.lastChangeTs = Date.now(); 9254 console.info('[WATCHDOG]', this._wdState); 9255 9256 await this.attemptPublisherRecovery(); 9257 recoveryAttempted = true; 9258 } 9259 // Second attempt: bitrate downshift (only once per stall episode) 9260 else if (!bitrateDownshifted) { 9261 _chatLog('[WATCHDOG] Recovery failed, auto-downshifting bitrate...'); 9262 this._wdState.status = 'STALL'; 9263 this._wdState.lastEvent = 'DOWNSHIFT'; 9264 this._wdState.lastChangeTs = Date.now(); 9265 console.info('[WATCHDOG]', this._wdState); 9266 9267 await this.autoDownshiftBitrate(); 9268 bitrateDownshifted = true; 9269 } 9270 // Third attempt: ICE restart (last resort) 9271 else if (stallCount >= ICE_RESTART_THRESH || this.shouldRestartIce()) { 9272 if (!iceRestartAttempted) { 9273 _chatLog('[WATCHDOG] Bitrate downshift failed, attempting ICE restart...'); 9274 this._wdState.status = 'ICE_RESTARTING'; 9275 this._wdState.lastEvent = 'ICE_RESTART'; 9276 this._wdState.lastChangeTs = Date.now(); 9277 console.warn('[HEALTH] ICE_RESTART', {state: this.sendTransport?.connectionState}); 9278 console.info('[WATCHDOG]', this._wdState); 9279 9280 await this.restartIceSafely();
9281 iceRestartAttempted = true; 9282 } 9283 } 9284 } 9285 } else { 9286 // Healthy - reset all flags and counters 9287 if (stallCount > 0) { 9288 _chatLog('[WATCHDOG] Stall resolved, resetting counters'); 9289 this._wdState.status = 'HEALTHY'; 9290 this._wdState.stallCount = 0; 9291 this._wdState.lastEvent = null; 9292 this._wdState.lastChangeTs = Date.now(); 9293 console.info('[WATCHDOG]', this._wdState); 9294 } 9295 stallCount = 0; 9296 recoveryAttempted = false; 9297 bitrateDownshifted = false; 9298 iceRestartAttempted = false; 9299 9300 // ð NEW: Auto quality mode - check for upshift opportunity 9301 if (this.qualityMode === 'auto' && fps > 20 && !out.qualityLimitationReason) { 9302 this.healthyFramesCount++; 9303 if (this.healthyFramesCount >= this.healthyFramesThreshold) { 9304 this.attemptQualityUpshift(); 9305 this.healthyFramesCount = 0; // Reset counter 9306 } 9307 } else { 9308 this.healthyFramesCount = 0; // Reset if not healthy 9309 } 9310 } 9311 9312 // If bandwidth-limited, proactively downshift once 9313 if (out.qualityLimitationReason === 'bandwidth') { 9314 _chatLog('[WATCHDOG] Bandwidth limitation detected, auto-downshifting...'); 9315 await this.autoDownshiftBitrate(); 9316 } 9317 9318 } catch (e) { 9319 // Producer may be closed (e.g. transport closed) â trigger restart so stream comes back 9320 const producer = this.cameraProducer; 9321 if (!producer || producer.closed) { 9322 _chatLog('[WATCHDOG] Stats failed (producer likely closed) â triggering restart'); 9323 this._onProducerTransportClose(); 9324 } else { 9325 console.warn('[WATCHDOG] Stats check failed:', e); 9326 } 9327 } 9328 }, CHECK_MS); 9329 9330 _chatLog('[WATCHDOG] Producer watchdog started with decisive policy'); 9331 9332 // Store cleanup function 9333 this.stopWatchdog = () => { 9334 if (this.watchdogTimer) { 9335 clearInterval(this.watchdogTimer); 9336 this.watchdogTimer = null; 9337 _chatLog('[WATCHDOG] Producer watchdog stopped'); 9338 } 9339 }; 9340 } 9341 9342 // ð NEW: Stop watchdog 9343 stopProducerWatchdog() { 9344 if (this.stopWatchdog) { 9345 this.stopWatchdog(); 9346 } 9347 } 9348 9349 // Consumer frame watchdog: uses getStats() framesDecoded to detect stuck 9350 // viewer-side streams. Checks every 3s; stall threshold is 6s. 9351 startConsumerFrameWatchdog(consumer, producerId) { 9352 let lastFramesDecoded = null; 9353 let lastProgressTs = Date.now(); 9354 let timer = null; 9355 const STALL_MS = 6000; 9356 9357 const check = async () => { 9358 try { 9359 if (consumer.closed) { cleanup(); return; } 9360 const stats = await consumer.getStats(); 9361 let decoded = null;
9362 stats.forEach(r => { 9363 if (r.type === 'inbound-rtp' && r.kind === 'video') decoded = r.framesDecoded ?? decoded; 9364 }); 9365 if (decoded != null) { 9366 if (lastFramesDecoded == null || decoded > lastFramesDecoded) lastProgressTs = Date.now(); 9367 lastFramesDecoded = decoded; 9368 } 9369 if (Date.now() - lastProgressTs > STALL_MS) { 9370 _chatLog('[CONSUMER] Stuck for 6s â keyframe + pause/resume', { id: consumer.id, producerId }); 9371 lastProgressTs = Date.now(); 9372 try { consumer.requestKeyFrame?.(); } catch (e) {} 9373 try { await consumer.pause(); await new Promise(r => setTimeout(r, 150)); await consumer.resume(); } catch (e) {} 9374 } 9375 } catch (e) {} 9376 }; 9377 9378 timer = setInterval(check, 3000); 9379 const cleanup = () => { if (timer) { clearInterval(timer); timer = null; } }; 9380 consumer._frameWatchdogCleanup = cleanup; 9381 } 9382 9383 // ð NEW: Check if ICE restart should be performed 9384 shouldRestartIce() { 9385 if (!this.sendTransport) return false; 9386 const state = this.sendTransport.connectionState; 9387 return state === 'failed' || state === 'disconnected'; 9388 } 9389 9390 // ð NEW: ICE restart as last resort 9391 async restartIceSafely() { 9392 if (!this.sendTransport) { 9393 console.warn('[WATCHDOG] No send transport available for ICE restart'); 9394 return; 9395 } 9396 9397 try { 9398 _chatLog('[WATCHDOG] Requesting new ICE parameters from server...'); 9399 9400 // Request new ICE parameters from server 9401 const iceParams = await this.emitAsync('chatcrack:get-ice-parameters', { 9402 roomId: this.roomId, 9403 transportId: this.sendTransport.id 9404 }); 9405 9406 if (!iceParams || !iceParams.iceParameters || !iceParams.iceCandidates) { 9407 throw new Error('Invalid ICE parameters received from server'); 9408 } 9409 9410 _chatLog('[WATCHDOG] Restarting ICE with new parameters...'); 9411 await this.sendTransport.restartIce({ 9412 iceParameters: iceParams.iceParameters, 9413 iceCandidates: iceParams.iceCandidates 9414 }); 9415 9416 _chatLog('[WATCHDOG] ICE restart completed successfully'); 9417 9418 // Reset watchdog stall counters on success 9419 this.resetWatchdogCounters(); 9420 9421 } catch (error) { 9422 console.error('[WATCHDOG] ICE restart failed:', error); 9423 throw error; 9424 } 9425 } 9426 9427 // ð NEW: Reset watchdog counters (called on success or stop) 9428 resetWatchdogCounters() { 9429 // This will be called from restartIceSafely and when stopping the watchdog 9430 _chatLog('[WATCHDOG] Resetting all counters and flags'); 9431 } 9432 9433 // ð NEW: Frame watcher for additional stall detection 9434 attachFrameWatcher(videoEl) { 9435 if (!('requestVideoFrameCallback' in HTMLVideoElement.prototype)) return; 9436 9437 let lastTS = 0; 9438 const onFrame = (_now, metadata) => { 9439 const ts = metadata?.mediaTime || 0; 9440 if (ts === lastTS) { 9441 // If this persists alongside outbound stall, the watchdog will kick in 9442 _chatLog('[WATCHDOG] Frame timestamp unchanged, potential stall'); 9443 } 9444 lastTS = ts; 9445 videoEl.requestVideoFrameCallback(onFrame); 9446 }; 9447 videoEl.requestVideoFrameCallback(onFrame); 9448 9449 _chatLog('[WATCHDOG] Frame watcher attached'); 9450 } 9451 9452 // Helper function for acknowledgment-based Socket.IO communication 9453 emitAck(event, data) { 9454 return new Promise((resolve, reject) => { 9455 9456 9457 9458 this.socket.emit(event, data, (ack) => { 9459 9460 if (!ack || ack.error) { 9461 console.error("[WebRTC] â emitAck failed:", ack?.error || 'no-ack'); 9462 reject(ack?.error || new Error('no-ack')); 9463 } else { 9464 9465 resolve(ack); 9466 } 9467 }); 9468 }); 9469 } 9470
9471 async init() { 9472 // WebRTC module initialization 9473 9474 if (!window.mediasoupClient?.Device) { 9475 console.error('[WebRTC] mediasoup-client not loaded from /mediasoup-bundle.js'); 9476 throw new Error('mediasoup-client Device class not available - check that /mediasoup-bundle.js loaded successfully'); 9477 } 9478 9479 // Test basic connection to mediasoup server 9480 try { 9481 // Debug messages removed for cleaner console 9482 await this.testMediasoupConnection(); 9483 // Mediasoup server is reachable and ready for WebRTC streaming 9484 } catch (error) { 9485 console.error("[WebRTC] â Mediasoup connection failed:", error.message); 9486 throw error; 9487 } 9488 9489 this.bindEvents(); 9490 } 9491 9492 async testMediasoupConnection() { 9493 try { 9494 const socket = await this.ensureSocketConnected(); 9495 await new Promise((resolve) => { 9496 socket.timeout(1500).emit('health:ping', { id: 'preflight', ts: Date.now() }, () => resolve()); 9497 }); 9498 // Connection test ok 9499 } catch { 9500 console.warn('[WebRTC] â ï¸ Connection test skipped (non-fatal)'); 9501 } 9502 } 9503 9504 async testMultiplePorts() { 9505 // Debug messages removed for cleaner console 9506 this.updateMediasoupStatus("ð Testing multiple ports for mediasoup server..."); 9507 9508 // First, test if the domain is reachable at all 9509 try { 9510 this.updateMediasoupStatus("ð Testing basic domain reachability..."); 9511 9512 9513 // Try HTTPS first 9514 try { 9515 const httpsResponse = await fetch('https://media.chatcrack.us', { 9516 method: 'HEAD', 9517 mode: 'no-cors', 9518 cache: 'no-cache' 9519 }); 9520 9521 this.updateMediasoupStatus("â HTTPS domain is reachable"); 9522 } catch (httpsError) { 9523 9524 9525 // Try HTTP as fallback 9526 try { 9527 const httpResponse = await fetch('http://media.chatcrack.us', { 9528 method: 'HEAD', 9529 mode: 'no-cors', 9530 cache: 'no-cache' 9531 }); 9532 9533 this.updateMediasoupStatus("â HTTP domain is reachable"); 9534 } catch (httpError) { 9535 9536 this.updateMediasoupStatus("â Domain not reachable via HTTPS or HTTP"); 9537 } 9538 } 9539 } catch (error) { 9540 9541 this.updateMediasoupStatus("â Domain test failed: " + error.message); 9542 } 9543 9544 // Test without port (default ports) 9545 try { 9546 this.updateMediasoupStatus("ð Testing default HTTPS port (443)..."); 9547 9548 this.MEDIASOUP_SERVER = "https://media.chatcrack.us"; 9549 await this.testMediasoupConnection(); 9550 9551 this.updateMediasoupStatus("ð Success! Found mediasoup server at default HTTPS port"); 9552 return; 9553 } catch (error) { 9554 9555 this.updateMediasoupStatus("â Default HTTPS port failed: " + error.message); 9556 } 9557 9558 try { 9559 this.updateMediasoupStatus("ð Testing default HTTP port (80)..."); 9560 9561 this.MEDIASOUP_SERVER = "http://media.chatcrack.us"; 9562 await this.testMediasoupConnection(); 9563 9564 this.updateMediasoupStatus("ð Success! Found mediasoup server at default HTTP port"); 9565 return; 9566 } catch (error) { 9567 9568 this.updateMediasoupStatus("â Default HTTP port failed: " + error.message); 9569 } 9570 9571 // Test common mediasoup ports 9572 const commonPorts = [3001, 3000, 8080, 8000]; 9573 for (const port of commonPorts) { 9574 try { 9575 this.updateMediasoupStatus(`ð Testing port ${port}...`); 9576 9577 9578 // Try HTTPS first 9579 this.MEDIASOUP_SERVER = `https://media.chatcrack.us:${port}`; 9580 try { 9581 await this.testMediasoupConnection(); 9582 9583 this.updateMediasoupStatus(`ð Success! Found mediasoup server at https://media.chatcrack.us:${port}`); 9584 return; 9585 } catch (httpsError) { 9586 9587 } 9588 9589 // Try HTTP as fallback 9590 this.MEDIASOUP_SERVER = `http://media.chatcrack.us:${port}`; 9591 try { 9592 await this.testMediasoupConnection(); 9593 9594 this.updateMediasoupStatus(`ð Success! Found mediasoup server at http://media.chatcrack.us:${port}`); 9595 return; 9596 } catch (httpError) { 9597 9598 } 9599 } catch (error) { 9600 9601 continue; 9602 } 9603 } 9604 9605 // All tests completed, no working connection found 9606 9607 // Try some common subdomains 9608 const subdomains = ['mediasoup', 'media', 'webrtc', 'rtc']; 9609 for (const subdomain of subdomains) { 9610 try { 9611 this.updateMediasoupStatus(`ð Testing subdomain: ${subdomain}.chatcrack.us...`); 9612 9613 9614 // Try default ports on subdomain 9615 this.MEDIASOUP_SERVER = `https://${subdomain}.chatcrack.us`; 9616 try { 9617 await this.testMediasoupConnection(); 9618 9619 this.updateMediasoupStatus(`ð Success! Found mediasoup server at ${this.MEDIASOUP_SERVER}`); 9620 return; 9621 } catch (error) { 9622 9623 } 9624 } catch (error) { 9625 9626 continue; 9627 } 9628 } 9629 9630 console.error("[WebRTC] â All connection attempts failed. Mediasoup server may be:"); 9631 console.error("[WebRTC] 1. Not running"); 9632 console.error("[WebRTC] 2. On a different domain/subdomain"); 9633 console.error("[WebRTC] 3. Behind a firewall"); 9634 console.error("[WebRTC] 4. Using a different port"); 9635 console.error("[WebRTC] 5. Requiring authentication"); 9636 9637 this.updateMediasoupStatus("â All connection attempts failed. Check console for details."); 9638 } 9639 9640 updateMediasoupStatus(message, details = '') { 9641 const statusDiv = document.getElementById('mediasoupStatus'); 9642 const statusText = document.querySelector('.mediasoup-status-text'); 9643 const statusDetails = document.querySelector('.mediasoup-status-details'); 9644 9645 if (statusDiv && statusText) { 9646 statusDiv.style.display = 'block'; 9647 statusText.textContent = message; 9648 9649 if (statusDetails && details) { 9650 statusDetails.textContent = details; 9651 } 9652 } 9653 } 9654 9655 bindEvents() { 9656 // No separate video call button needed - integrate with existing webcam system 9657 // Events bound - ready to integrate with webcam system 9658 9659 // ð NEW: Connection state hooks for minimal monitoring + transport close recovery 9660 if (this.sendTransport) { 9661 this.sendTransport.on('connectionstatechange', (state) => { 9662 console.log('[ICE] Send transport state:', state); 9663 // If 'failed'/'disconnected' persists, the watchdog's stall logic will attempt recovery 9664 }); 9665 this.sendTransport.on('close', () => {
9666 console.warn('[WebRTC] Send transport closed â watchdog will restart producer'); 9667 this._onProducerTransportClose(); 9668 }); 9669 } 9670 } 9671 9672 // --- compatibility toggles (per-user) --- 9673 // OLD COMPATIBILITY SETTINGS REMOVED - Now using window.CC_WEBRTC_COMPAT 9674 9675 // --- UA helpers for Chromium 137-140 detection --- 9676 parseChromiumMajor() { 9677 const m = navigator.userAgent.match(/Chrom(e|ium)\/(\d+)/i); 9678 return m ? parseInt(m[2], 10) : null; 9679 } 9680 9681 isChromium137to140() { 9682 const v = this.parseChromiumMajor(); 9683 return v && v >= 137 && v <= 140; 9684 } 9685 9686 // Pick H.264 Constrained Baseline (if available in rtpCapabilities) 9687 pickH264CB(rtpCapabilities) { 9688 if (!rtpCapabilities?.codecs) return undefined; 9689 const idx = rtpCapabilities.codecs.findIndex( 9690 c => c.mimeType?.toLowerCase() === 'video/h264' && 9691 (c.parameters?.['profile-level-id']?.toLowerCase?.() === '42e01f' || // CB 9692 c.parameters?.['profile-level-id']?.toLowerCase?.() === '42001f') // Safari-ish 9693 ); 9694 return idx >= 0 ? { mimeType: 'video/h264' } : undefined; 9695 } 9696 9697 // Wire producer events for monitoring 9698 _wireProducerEvents(prod) { 9699 try { 9700 prod.on('trackended', () => this.warn && this.warn('ð´ producer track ended')); 9701 prod.on('transportclose', () => { 9702 if (this._suppressTransportCloseRecovery) return; 9703 this.warn && this.warn('ðª producer transport closed'); 9704 this._onProducerTransportClose(); 9705 }); 9706 prod.on('score', score => this.log && this.log('ð producer score', score)); 9707 } catch (_) {} 9708 } 9709 9710 // When send transport closes (e.g. server blip), restart producer so viewers don't lose stream. Debounced so audio+video close doesn't double-trigger. 9711 _onProducerTransportClose() { 9712 if (typeof window._ccMediaHoldActive === 'function' && window._ccMediaHoldActive()) return; 9713 if (this._suppressTransportCloseRecovery || this._audioReconnectInProgress 9714 || this._videoReconnectInProgress || this._restartInProgress 9715 || window.chatCrackWebcam?._settingsDeviceSwapInProgress) return; 9716 if (this._transportCloseRecoveryScheduled) return; 9717 if (!this._userWantsWebcamBroadcast()) return; 9718 this._transportCloseRecoveryScheduled = true; 9719 _chatLog('[WATCHDOG] Transport closed â scheduling producer restart so stream stays up'); 9720 const run = async () => { 9721 try { 9722 if (typeof this._autoRestartProducer === 'function') await this._autoRestartProducer(); 9723 } catch (e) { 9724 console.warn('[WATCHDOG] Transport-close recovery failed:', e); 9725 } finally { 9726 this._transportCloseRecoveryScheduled = false; 9727 } 9728 }; 9729 setTimeout(run, 2500); 9730 } 9731 9732 /** 9733 * Reacquire a local stream for freeze/transport recovery using the user's 9734 * SAVED device + quality (not a hardcoded 640x360 default camera). Uses 9735 * { ideal } deviceId so recovery never hard-fails if the exact device is 9736 * momentarily unavailable, and routes through the webcam module's 9737 * ccCompatGetUserMedia so the non-standard `bitrate` field is stripped. 9738 */ 9739 async _reacquireLocalStreamWithSavedSettings(includeAudio) { 9740 const wc = window.chatCrackWebcam; 9741 if (wc && typeof wc.getVideoConstraints === 'function') { 9742 const s = wc.webcamSettings || {}; 9743 const video = Object.assign({}, wc.getVideoConstraints() || {}); 9744 if (s.videoDeviceId) video.deviceId = { ideal: s.videoDeviceId }; 9745 const constraints = { video }; 9746 if (includeAudio) { 9747 const audio = Object.assign({}, (typeof wc.getAudioConstraints === 'function' ? wc.getAudioConstraints() : {}) || {}); 9748 if (s.audioDeviceId) audio.deviceId = { ideal: s.audioDeviceId }; 9749 constraints.audio = audio; 9750 } else { 9751 constraints.audio = false;
9752 } 9753 if (typeof wc.ccCompatGetUserMedia === 'function') { 9754 return await wc.ccCompatGetUserMedia(constraints, { audio: !!includeAudio }); 9755 } 9756 if (constraints.video) delete constraints.video.bitrate; 9757 if (constraints.audio && typeof constraints.audio === 'object') delete constraints.audio.bitrate; 9758 return await navigator.mediaDevices.getUserMedia(constraints); 9759 } 9760 // Webcam module unavailable â safe fallback. 9761 return await navigator.mediaDevices.getUserMedia({ 9762 video: { width: { ideal: 640 }, height: { ideal: 360 }, frameRate: { ideal: 30, max: 30 } }, 9763 audio: !!includeAudio 9764 }); 9765 } 9766 9767 // Ensure local stream is alive, reacquire if needed 9768 async _ensureLocalStreamAlive() { 9769 const t = this.localStream?.getVideoTracks?.()[0]; 9770 if (!t || t.readyState !== 'live') { 9771 this.log && this.log('[WebRTC] reacquiring gUM for live track (saved device/quality)â¦'); 9772 this.localStream = await this._reacquireLocalStreamWithSavedSettings(this.enableMic === true); 9773 } 9774 } 9775 9776 // RPC helper for server communication 9777 async _rpc(method, data) { 9778 return new Promise((resolve, reject) => { 9779 this.socket.emit(method, data, (response) => { 9780 if (response && response.ok) { 9781 resolve(response); 9782 } else { 9783 reject(new Error(response?.message || 'RPC call failed')); 9784 } 9785 }); 9786 }); 9787 } 9788 9789 // Helper: choose codec by UA 9790 pickPreferredVideoCodec(device) { 9791 const ua = navigator.userAgent || ''; 9792 const isSafari = /Safari/i.test(ua) && !/Chrome/i.test(ua); 9793 const isIOS = /iPad|iPhone|iPod/.test(ua); 9794 9795 const preferH264 = (window.CC_WEBRTC_COMPAT.safariUsesH264 && (isSafari || isIOS)); 9796 const preferMime = preferH264 ? 'video/H264' : 9797 (window.CC_WEBRTC_COMPAT.preferVp8 ? 'video/VP8' : 'video/H264'); 9798 9799 // IMPORTANT: pick a codec object from device.rtpCapabilities (not router) 9800 const candidates = (getDeviceSendRtpCapabilities(device)?.codecs || []).filter(c => c.kind === 'video'); 9801 const match = candidates.find(c => (c.mimeType || '').toLowerCase() === preferMime.toLowerCase()); 9802 9803 // Fallback to *any* supported video codec if preferred not present 9804 return match || candidates[0] || null; 9805 } 9806 9807 // New device-driven produce function 9808 async startProducingNew({ device, sendTransport, localStream, userId, roomId }) { 9809 console.log('[PRODUCE] ð begin'); 9810 9811 // Guard: track must be live 9812 const track = localStream?.getVideoTracks?.()[0]; 9813 if (!track || track.readyState !== 'live') { 9814 throw new Error('no live video track'); 9815 } 9816 9817 // Single encoding (no simulcast) 9818 const encodings = undefined; // let browser choose; avoid SDP param mismatches 9819 9820 // Attempt 1: Preferred codec (VP8 on Chrome, H264 on Safari/iOS) 9821 const preferredCodec = this.pickPreferredVideoCodec(device); 9822 console.log('[PRODUCE] attempt=1 codec=%s simulcast=%s', 9823 preferredCodec?.mimeType, !!(encodings && encodings.length > 1)); 9824 9825 try { 9826 const producer = await sendTransport.produce({ 9827 track, 9828 // PASS THE CAPABILITY OBJECT FROM device.rtpCapabilities 9829 codec: preferredCodec || undefined, 9830 encodings, 9831 appData: { userId, roomId, kind: 'video' }, 9832 stopTracks: false 9833 }); 9834 console.log('[PRODUCE] â success id=%s kind=%s', producer.id, producer.kind); 9835 return producer; 9836 } catch (err) { 9837 console.warn('[PRODUCE] attempt=1 failed name=%s message=%s', err?.name, err?.message); 9838 } 9839 9840 // Attempt 2: Minimal (let mediasoup pick the codec), smaller bitrate 9841 try { 9842 console.log('[PRODUCE] attempt=2 reason=generic minimal=true'); 9843 const producer = await sendTransport.produce({ 9844 track, 9845 appData: { userId, roomId, kind: 'video' }, 9846 stopTracks: false 9847 }); 9848 console.log('[PRODUCE] â success (fallback) id=%s', producer.id); 9849 return producer; 9850 } catch (err2) { 9851 console.warn('[PRODUCE] attempt=2 failed name=%s message=%s', err2?.name, err2?.message); 9852 } 9853 9854 // Attempt 3: If the track died during negotiation, reacquire and retry once (webcam only) 9855 const isScreenRoom = roomId && String(roomId).startsWith('chatcrack_screen_'); 9856 if (track.readyState !== 'live') { 9857 if (isScreenRoom) { 9858 throw new Error('Screen capture ended during publish â pick your screen again'); 9859 } 9860 console.warn('[PRODUCE] track ended during negotiation â reacquiring getUserMedia()â¦'); 9861 const fresh = await navigator.mediaDevices.getUserMedia({ 9862 video: { width: { ideal: 640 }, height: { ideal: 360 }, frameRate: { ideal: 30, max: 30 } }, 9863 audio: false 9864 }); 9865 const newTrack = fresh.getVideoTracks()[0]; 9866 try { 9867 const producer = await sendTransport.produce({ 9868 track: newTrack,
9869 appData: { userId, roomId, kind: 'video' }, 9870 stopTracks: false 9871 }); 9872 console.log('[PRODUCE] â success (reacquired) id=%s', producer.id); 9873 return producer; 9874 } catch (err3) { 9875 console.error('[PRODUCE] attempt=3 failed name=%s message=%s', err3?.name, err3?.message); 9876 throw err3; 9877 } 9878 } 9879 9880 throw new Error('produce failed after 2 attempts'); 9881 } 9882 9883 // Log compatibility mode instructions for users experiencing issues 9884 logCompatibilityInstructions() { 9885 // Compatibility mode logging disabled to reduce console clutter 9886 } 9887 9888 // OLD pickH264 method removed - Now using pickPreferredVideoCodec 9889 9890 // Socket.IO helper for async events 9891 async emitAsync(event, data) { 9892 return new Promise((resolve) => { 9893 // Timed: resolve undefined on missed ack instead of hanging forever. 9894 let settled = false; 9895 const to = setTimeout(() => { 9896 if (settled) return; 9897 settled = true; 9898 resolve(undefined); 9899 }, 8000); 9900 try { 9901 this.socket.emit(event, data, (ack) => { 9902 if (settled) return; 9903 settled = true; 9904 clearTimeout(to); 9905 resolve(ack); 9906 }); 9907 } catch (_) { 9908 if (!settled) { settled = true; clearTimeout(to); resolve(undefined); } 9909 } 9910 }); 9911 } 9912 9913 // Get video encoding parameters based on quality setting 9914 getVideoEncodingParams() { 9915 // Get quality from the webcam modal (if available) 9916 let quality = 'medium'; // default 9917 try { 9918 const qualitySelect = document.getElementById('videoQualitySelect'); 9919 if (qualitySelect) { 9920 quality = qualitySelect.value; 9921 } 9922 } catch (error) { 9923 console.warn("[WebRTC] Could not get quality setting, using default:", error); 9924 } 9925 9926 const encodingParams = { 9927 low: [ 9928 { 9929 maxBitrate: 90000, // 90 kbps (160x90, 15fps) 9930 maxFramerate: 15, 9931 scaleResolutionDownBy: 2.0, // Scale down for lower quality 9932 active: true 9933 } 9934 ], 9935 medium: [ 9936 { 9937 maxBitrate: 120000, // 120 kbps (320x180, 20fps) - DEFAULT 9938 maxFramerate: 20, 9939 scaleResolutionDownBy: 1.5, // Moderate scaling 9940 active: true 9941 } 9942 ], 9943 high: [ 9944 { 9945 maxBitrate: 256000, // 256 kbps (640x360, 30fps) 9946 maxFramerate: 30, 9947 scaleResolutionDownBy: 1.0, // No scaling 9948 active: true 9949 } 9950 ], 9951 hd: [ 9952 { 9953 maxBitrate: 320000, // 320 kbps (854x480, 30fps) â just above High 9954 maxFramerate: 30, 9955 scaleResolutionDownBy: 1.0, 9956 active: true 9957 } 9958 ] 9959 }; 9960 9961 9962 return encodingParams[quality] || encodingParams.medium; 9963 } 9964 9965 // Get audio encoding parameters based on quality setting 9966 getAudioEncodingParams() { 9967 const bps = this._micBitrateBps(); 9968 return [{ maxBitrate: bps, active: true }]; 9969 } 9970 9971 _micQualityKey() { 9972 let raw = 'medium'; 9973 try { 9974 const sel = document.getElementById('audioQualitySelect'); 9975 if (sel && sel.value) raw = String(sel.value).toLowerCase(); 9976 else { 9977 const q = window.chatCrackWebcam?.webcamSettings?.audioQuality; 9978 if (q) raw = String(q).toLowerCase(); 9979 } 9980 } catch (_) {} 9981 try { 9982 if (typeof window.chatCrackWebcam?._normalizeAudioQuality === 'function') { 9983 return window.chatCrackWebcam._normalizeAudioQuality(raw); 9984 } 9985 } catch (_) {} 9986 if (raw === 'hd' || raw === 'superhd') return 'high'; 9987 if (raw === 'low' || raw === 'medium' || raw === 'high') return raw; 9988 return 'medium'; 9989 } 9990 9991 _micBitrateBps() { 9992 try { 9993 if (typeof window.chatCrackWebcam?.getAudioBitrateForQuality === 'function') { 9994 return window.chatCrackWebcam.getAudioBitrateForQuality(this._micQualityKey()); 9995 } 9996 } catch (_) {} 9997 // d540d9c bitrate map (logging / legacy callers â produce is minimal). 9998 const map = { low: 8000, medium: 16000, high: 32000, hd: 40000 }; 9999 return map[this._micQualityKey()] || map.medium; 10000 } 10001 10002 /** 10003 * Receiver-side NetEQ headroom for remote mic audio. Brief congestion bursts 10004 * otherwise underrun into crackle/static; ~220ms absorbs those spikes with 10005 * only a small speech delay. Safe no-op when unsupported. 10006 */ 10007 _stabilizeRemoteAudioPlayout(transport, track) { 10008 if (!track || track.kind !== 'audio') return; 10009 try { 10010 const pc = transport && transport._handler && transport._handler._pc; 10011 if (!pc || typeof pc.getReceivers !== 'function') return; 10012 const receiver = pc.getReceivers().find((r) => r && r.track && r.track.id === track.id); 10013 if (!receiver) return; 10014 // Chromium: preferred jitter buffer depth in ms (typical clamp 0â4000). 10015 if ('jitterBufferTarget' in receiver) { 10016 try { receiver.jitterBufferTarget = 220; } catch (_) {} 10017 } 10018 // Older Chromium / some Safari: playout delay hint in seconds. 10019 if ('playoutDelayHint' in receiver) { 10020 try { receiver.playoutDelayHint = 0.22; } catch (_) {} 10021 } 10022 } catch (_) {} 10023 } 10024 10025 _stabilizeRemoteAudioPlayoutSoon(transport, track) { 10026 this._stabilizeRemoteAudioPlayout(transport, track); 10027 // Receiver may not be wired on the PC until the next tick after consume(). 10028 try { 10029 setTimeout(() => this._stabilizeRemoteAudioPlayout(transport, track), 50); 10030 setTimeout(() => this._stabilizeRemoteAudioPlayout(transport, track), 300); 10031 } catch (_) {} 10032 } 10033 10034 /** When publisher unmutes, RTP returns on an existing track â no addtrack. 10035 * Fav docks stayed silent until SettingsâSave re-produced. Nudge play. */ 10036 _bindRemoteMicUnmuteNudge(consumer, username) { 10037 if (!consumer || !username) return; 10038 const track = consumer.track; 10039 if (!track || track.kind !== 'audio') return; 10040 if (track._ccMicUnmuteNudgeBound) return; 10041 track._ccMicUnmuteNudgeBound = true; 10042 const nudge = () => { 10043 try { 10044 if (typeof window._ccEnsureDockWebcamAudioPlaying === 'function') { 10045 window._ccEnsureDockWebcamAudioPlaying(username, { skipIfNoTrack: false }); 10046 } 10047 } catch (_) {} 10048 try { 10049 if (window.chatCrackOpenMic && typeof window.chatCrackOpenMic._onRemoteProducerResumed === 'function') { 10050 window.chatCrackOpenMic._onRemoteProducerResumed(username); 10051 } 10052 } catch (_) {} 10053 // Rebind the viewer VU meter to the now-flowing track so the bar 10054 // moves the moment the publisher speaks. 10055 try { 10056 if (typeof window._ccRefreshDockAudioLevelForUser === 'function') { 10057 window._ccRefreshDockAudioLevelForUser(username); 10058 } 10059 } catch (_) {} 10060 }; 10061 try { track.addEventListener('unmute', nudge); } catch (_) {} 10062 try { consumer.on('producerresume', nudge); } catch (_) {} 10063 // Already receiving â still force dock audible (autoplay may have muted). 10064 if (track.muted === false) { 10065 try { setTimeout(nudge, 0); } catch (_) {} 10066 } 10067 } 10068 10069 async startWebcamStreaming(mediaStream) { 10070 try { 10071 10072 10073 // Check if mediasoupClient is available 10074 if (typeof window.mediasoupClient === 'undefined') { 10075 const error = new Error('mediasoup-client not available'); 10076 console.error('[WebRTC] Cannot start streaming - mediasoup-client not loaded:', error); 10077 throw error; 10078 } 10079 10080 const streamUser = window.currentUsername || window.me?.username; 10081 if (!streamUser || (typeof window._ccIsPhantomWebcamUsername === 'function' && window._ccIsPhantomWebcamUsername(streamUser))) { 10082 throw new Error('Chat identity not ready â reload the page and wait before starting your cam.'); 10083 } 10084 this.userId = streamUser;
10085 this.roomId = (typeof window._ccWebcamRoomId === 'function') 10086 ? window._ccWebcamRoomId(this.userId) 10087 : ('chatcrack_webcam_' + String(this.userId).toLowerCase()); 10088 10089 10090 10091 // Store the stream 10092 this.localStream = mediaStream; 10093 10094 // Connect to mediasoup server and start producing 10095 await this.connectAndStart(); 10096 10097 10098 10099 } catch (error) { 10100 console.error("[WebRTC] â Failed to start webcam streaming:", error); 10101 console.error("[WebRTC] Error details:", { 10102 name: error.name, 10103 message: error.message, 10104 stack: error.stack 10105 }); 10106 throw error; 10107 } 10108 } 10109 10110 /** User intent: only chatCrackWebcam.isWebcamOn (rtc.isWebcamOn can stay stale after stop). */ 10111 _userWantsWebcamBroadcast() { 10112 return !!(window.chatCrackWebcam && window.chatCrackWebcam.isWebcamOn); 10113 } 10114 10115 /** True while the publisher intentionally paused video (Cam Off â mic may still be live). */ 10116 _isCameraIntentionallyOff() { 10117 return !!(window.chatCrackWebcam && window.chatCrackWebcam.isCameraOff); 10118 } 10119 10120 /** Keep track/producer muted after recovery paths that would otherwise turn video back on. */ 10121 _reassertCameraOffState() { 10122 if (!this._isCameraIntentionallyOff()) return; 10123 try { 10124 const track = this.localStream?.getVideoTracks?.()[0]; 10125 if (track && track.enabled) track.enabled = false; 10126 } catch (_) {} 10127 try { 10128 const prod = this.cameraProducer; 10129 if (prod && !prod.closed && !prod.paused) { 10130 prod.pause().catch(function () {}); 10131 } 10132 } catch (_) {} 10133 try { 10134 const me = window.currentUsername || window.me?.username; 10135 const wc = window.chatCrackWebcam; 10136 if (me && wc && typeof wc.setRemoteCameraOffOverlay === 'function') { 10137 wc.setRemoteCameraOffOverlay(me, true, wc.cameraOffMessage || ''); 10138 } 10139 if (wc && typeof wc.updateCameraOffUI === 'function') wc.updateCameraOffUI(); 10140 } catch (_) {} 10141 } 10142 10143 _abortWatchdogRestarts() { 10144 this._webcamBroadcastGen = (this._webcamBroadcastGen || 0) + 1; 10145 this.isWebcamOn = false; 10146 this._suppressTransportCloseRecovery = true; 10147 this.stopProducerWatchdog(); 10148 if (this._wd?._restartTimer) { 10149 clearTimeout(this._wd._restartTimer); 10150 this._wd._restartTimer = null; 10151 } 10152 } 10153 10154 // ââ Webcam Producer Watchdog (producer-side auto-heal) ââââââââââââââââ 10155 // Lightweight state kept on `this` 10156 initProducerWatchdog() { 10157 if (this._wd) return; 10158 this._wd = { 10159 tick: null, 10160 lastStats: null, 10161 lastFramesEncoded: null, 10162 lastBytesSent: null, 10163 lastOkTs: 0, 10164 restartBackoffMs: 0, 10165 startedAt: 0 10166 }; 10167 } 10168 10169 resetProducerWatchdogState() { 10170 if (!this._wd) return; 10171 this._wd.lastStats = null; 10172 this._wd.lastFramesEncoded = null; 10173 this._wd.lastBytesSent = null; 10174 this._wd.lastOkTs = 0; 10175 this._wd.restartBackoffMs = 0; 10176 this._wd.startedAt = Date.now(); 10177 } 10178 10179 startProducerWatchdog() { 10180 // Only run if the user intends to be broadcasting 10181 if (!this._userWantsWebcamBroadcast()) { 10182 _chatLog('[WATCHDOG] Webcam not on, skipping watchdog start'); 10183 return; 10184 } 10185 this.initProducerWatchdog(); 10186 this.stopProducerWatchdog(); // idempotent 10187 this.resetProducerWatchdogState(); 10188 10189 const SAMPLE_MS = 15000; // how often to poll getStats (15 seconds) 10190 const STALL_GRACE_MS = 20000; // no RTP progress for 20s â unhealthy 10191 const MAX_BACKOFF_MS = 15000; // cap restart backoff at 15s 10192 10193 const getSender = () => { 10194 try { 10195 // Prefer the actual producer track sender if available 10196 const cameraProducer = this.cameraProducer; 10197 if (cameraProducer?._track && this.sendTransport?._handler?._pc) { 10198 const pc = this.sendTransport._handler._pc; 10199 const senders = pc.getSenders?.() || []; 10200 const videoSender = senders.find(s => s.track && s.track.kind === 'video'); 10201 return videoSender || null; 10202 } 10203 } catch (e) { 10204 _chatLog('[WATCHDOG] getSender error:', e); 10205 } 10206 return null; 10207 }; 10208 10209 const healthyProgress = (stats) => { 10210 const now = Date.now(); 10211 let frames = null, bytes = null, fps = null;
10212 stats.forEach(report => { 10213 if ((report.type === 'outbound-rtp' || report.type === 'outbound-rtp-video') && !report.isRemote) { 10214 frames = (report.framesEncoded ?? report.frames) ?? frames; 10215 bytes = (report.bytesSent ?? report.bytes) ?? bytes; 10216 } 10217 if (report.type === 'track' && (report.kind === 'video' || report.frameWidth)) { 10218 fps = report.framesPerSecond ?? fps; 10219 } 10220 }); 10221 10222 // If we cannot read anything yet, be lenient early on 10223 let progressed = false; 10224 if (frames != null) { 10225 if (this._wd.lastFramesEncoded == null || frames > this._wd.lastFramesEncoded) progressed = true; 10226 this._wd.lastFramesEncoded = frames; 10227 } 10228 if (bytes != null) { 10229 if (this._wd.lastBytesSent == null || bytes > this._wd.lastBytesSent) progressed = true; 10230 this._wd.lastBytesSent = bytes; 10231 } 10232 if (fps != null && fps > 0.5) progressed = true; // tiny fps still "progress" 10233 10234 if (progressed) this._wd.lastOkTs = now; 10235 10236 const stalled = (now - (this._wd.lastOkTs || this._wd.startedAt)) > STALL_GRACE_MS; 10237 return { progressed, stalled, fps, frames, bytes }; 10238 }; 10239 10240 const needsRestart = async () => { 10241 // Preconditions indicating "ON but not producing correctly" 10242 if (!this._userWantsWebcamBroadcast()) return false; 10243 const cameraProducer = this.cameraProducer; 10244 if (!cameraProducer || cameraProducer.closed) { 10245 console.log(`ð§ [DEBUG] WATCHDOG: Producer missing or closed:`, { 10246 hasProducer: !!cameraProducer, 10247 producerId: cameraProducer?.id, 10248 producerClosed: cameraProducer?.closed, 10249 stackTrace: new Error().stack 10250 }); 10251 _chatLog('[WATCHDOG] Producer missing or closed'); 10252 return true; 10253 } 10254 if (!this.sendTransport || this.sendTransport.closed) { 10255 _chatLog('[WATCHDOG] Send transport missing or closed'); 10256 return true; 10257 } 10258 10259 // connectionState can be: 'connecting'|'connected'|'disconnected'|'failed'|'closed' 10260 const state = this.sendTransport?.connectionState || this.sendTransport?._connectionState; 10261 if (state === 'connecting' || state === 'checking') { 10262 return false; // brief handshake â do not treat as stall 10263 } 10264 const ICE_GRACE_MS = 25000; 10265 if (state && state !== 'connected') { 10266 if (state === 'disconnected' || state === 'failed') { 10267 if (!this._wd.transportDisconnectedSince) this._wd.transportDisconnectedSince = Date.now(); 10268 if (Date.now() - this._wd.transportDisconnectedSince < ICE_GRACE_MS) { 10269 return false; 10270 } 10271 } else { 10272 return false; 10273 } 10274 } else { 10275 this._wd.transportDisconnectedSince = 0; 10276 } 10277 10278 // Prefer connectionState; iceConnectionState flaps on 3G even while RTP flows. 10279 try { 10280 if (this.sendTransport?._handler?._pc) { 10281 const pc = this.sendTransport._handler._pc; 10282 const pcState = pc.connectionState; 10283 if (pcState === 'connecting' || pcState === 'checking' || pcState === 'new') { 10284 return false; 10285 } 10286 if (pcState === 'disconnected' || pcState === 'failed') { 10287 if (!this._wd.pcDisconnectedSince) this._wd.pcDisconnectedSince = Date.now(); 10288 if (Date.now() - this._wd.pcDisconnectedSince < ICE_GRACE_MS) { 10289 return false; 10290 } 10291 } else if (pcState === 'connected') { 10292 this._wd.pcDisconnectedSince = 0; 10293 } 10294 } 10295 } catch (_) {} 10296 10297 const sender = getSender(); 10298 if (!sender || !sender.getStats) { 10299 const sinceOk = Date.now() - (this._wd.lastOkTs || this._wd.startedAt || Date.now()); 10300 return sinceOk > 30000; 10301 } 10302 let stats; 10303 try { 10304 stats = await sender.getStats(); 10305 } catch (_) { 10306 return false; 10307 } 10308 10309 const { stalled, frames, bytes } = healthyProgress(stats); 10310 if (frames != null || bytes != null) { 10311 this._wd.lastHealthCheck = { ts: Date.now(), frames, bytes }; 10312 } 10313 // Bytes still going out â keep the producer. Pause/restart is what 10314 // viewers see as the cam jumping on and off. 10315 if (!stalled) return false;
10316 if (this.sendTransport && this.sendTransport.closed) return true; 10317 return stalled; 10318 }; 10319 10320 const backoff = () => { 10321 const prev = this._wd.restartBackoffMs || 0; 10322 const next = prev === 0 ? 2000 : Math.min(prev * 2, MAX_BACKOFF_MS); 10323 this._wd.restartBackoffMs = next; 10324 return next; 10325 }; 10326 10327 // ââ Graduated recovery helpers ââââââââââââââââââââââââââââââââââââ 10328 const attemptLightRecovery = async () => { 10329 try { 10330 const cp = this.cameraProducer; 10331 if (!cp || cp.closed) return false; 10332 if (this._isCameraIntentionallyOff()) { 10333 this._reassertCameraOffState(); 10334 _chatLog('[WATCHDOG] Light recovery skipped â camera intentionally off'); 10335 return true; 10336 } 10337 _chatLog('[WATCHDOG] Light recovery: encoder nudge (no pause â pause blanks the cam)'); 10338 const params = cp.rtpSender?.getParameters?.(); 10339 if (params && params.encodings && params.encodings.length) { 10340 const enc = params.encodings[0]; 10341 const origMax = enc.maxBitrate; 10342 enc.maxBitrate = (origMax || 500000) * 0.8; 10343 await cp.rtpSender.setParameters(params); 10344 await new Promise(r => setTimeout(r, 300)); 10345 enc.maxBitrate = origMax || 500000; 10346 await cp.rtpSender.setParameters(params); 10347 } 10348 return true; 10349 } catch (e) { 10350 _chatLog('[WATCHDOG] Light recovery failed:', e); 10351 return false; 10352 } 10353 }; 10354 10355 const attemptIceRestart = async () => { 10356 try { 10357 const pc = this.sendTransport?._handler?._pc; 10358 if (!pc) return false; 10359 _chatLog('[WATCHDOG] ICE restart attempt (client-side; mediasoup may still recover)'); 10360 const offer = await pc.createOffer({ iceRestart: true }); 10361 await pc.setLocalDescription(offer); 10362 return true; 10363 } catch (e) { 10364 _chatLog('[WATCHDOG] ICE restart failed:', e); 10365 // Do not treat as hard failure â brief network blips often self-heal. 10366 return false; 10367 } 10368 }; 10369 10370 const tick = async () => { 10371 try { 10372 if (!this._userWantsWebcamBroadcast()) { this.stopProducerWatchdog(); return; } 10373 // After a process restart the local tracks still look live, so stall 10374 // detection never fires, and the 2-minute hold used to skip republish. 10375 // Ask the server: if the room is gone, produce again. 10376 if (typeof window._ccMediaHoldActive === 'function' && window._ccMediaHoldActive()) { 10377 const me = window.currentUsername || (window.me && window.me.username) || ''; 10378 if (me && typeof window.ccCheckMediaProducerAlive === 'function') { 10379 const alive = await window.ccCheckMediaProducerAlive( 10380 'chatcrack_webcam_' + String(me).toLowerCase(), 'video', me 10381 ); 10382 if (alive === false) { 10383 _chatLog('[WATCHDOG] reboot hold but server has no video producer â republishing'); 10384 await this._autoRestartProducer(true); 10385 } 10386 } 10387 return; 10388 } 10389 // Cam Off pauses the video producer on purpose â zero outbound frames is expected. 10390 if (this._isCameraIntentionallyOff()) { 10391 this._wd.consecutiveStalls = 0; 10392 this._wd.restartBackoffMs = 0; 10393 this._reassertCameraOffState(); 10394 return; 10395 } 10396 10397 const restart = await needsRestart(); 10398 if (restart) { 10399 if (!this._wd.consecutiveStalls) this._wd.consecutiveStalls = 0; 10400 this._wd.consecutiveStalls++; 10401 const stalls = this._wd.consecutiveStalls; 10402 10403 if (stalls <= 2) { 10404 // First stalls: lightweight pause/resume + encoder nudge 10405 _chatLog(`[WATCHDOG] Stall #${stalls} â attempting light recovery`); 10406 await attemptLightRecovery(); 10407 } else if (stalls === 3 || stalls === 4) { 10408 // Give ICE more time before tearing the producer down 10409 _chatLog(`[WATCHDOG] Stall #${stalls} â attempting ICE restart (no full tear-down yet)`); 10410 await attemptIceRestart(); 10411 } 10412 10413 if (this._wd.consecutiveStalls >= 5) { 10414 // Only after repeated failures: full producer restart
10415 const delay = backoff(); 10416 console.warn(`[WATCHDOG] Stall #${stalls} â full restart in ${delay}ms`); 10417 clearTimeout(this._wd._restartTimer); 10418 this._wd._restartTimer = setTimeout(async () => { 10419 try { 10420 await this._autoRestartProducer(); 10421 this._wd.consecutiveStalls = 0; 10422 } catch (e) { 10423 console.error('[WATCHDOG] restart error', e); 10424 } 10425 }, delay); 10426 } 10427 } else { 10428 // Healthy â reset counters 10429 this._wd.restartBackoffMs = 0; 10430 this._wd.consecutiveStalls = 0; 10431 this._wd.pcDisconnectedSince = 0; 10432 this._wd.transportDisconnectedSince = 0; 10433 } 10434 } catch (e) { 10435 console.error('[WATCHDOG] tick error', e); 10436 } 10437 }; 10438 10439 this._wd.tick = setInterval(tick, SAMPLE_MS); 10440 _chatLog('[WATCHDOG] started'); 10441 10442 // Add peer connection state monitoring; delay before treating as unhealthy so ICE can recover 10443 const setupConnectionMonitoring = () => { 10444 try { 10445 if (this.sendTransport?._handler?._pc) { 10446 const pc = this.sendTransport._handler._pc; 10447 let failTimer = null; 10448 10449 const handleConnectionChange = () => { 10450 const state = pc.connectionState; 10451 if (state === 'disconnected' || state === 'failed') { 10452 if (failTimer) clearTimeout(failTimer); 10453 failTimer = setTimeout(() => { 10454 failTimer = null; 10455 const current = pc.connectionState; 10456 if (current !== 'disconnected' && current !== 'failed' && current !== 'closed') return; 10457 const recentlyOk = (Date.now() - (this._wd.lastOkTs || 0)) < 15000; 10458 if (recentlyOk) return; 10459 tick(); 10460 }, 20000); 10461 } else if (state === 'connected' && failTimer) { 10462 clearTimeout(failTimer); 10463 failTimer = null; 10464 } 10465 }; 10466 10467 pc.addEventListener('connectionstatechange', handleConnectionChange); 10468 10469 this._wd._connectionMonitors = () => { 10470 pc.removeEventListener('connectionstatechange', handleConnectionChange); 10471 if (failTimer) clearTimeout(failTimer); 10472 }; 10473 } 10474 } catch (e) { 10475 _chatLog('[WATCHDOG] Could not set up connection monitoring:', e); 10476 } 10477 }; 10478 10479 // Set up connection monitoring after transport is available 10480 setTimeout(setupConnectionMonitoring, 1000); 10481 } 10482 10483 stopProducerWatchdog() { 10484 if (!this._wd) return; 10485 if (this._wd.tick) clearInterval(this._wd.tick); 10486 if (this._wd._restartTimer) clearTimeout(this._wd._restartTimer); 10487 if (this._wd._connectionMonitors) this._wd._connectionMonitors(); 10488 this._wd.tick = null; 10489 this._wd._restartTimer = null; 10490 this._wd._connectionMonitors = null; 10491 // keep lastOkTs/backoff so we don't thrash across quick stops/starts 10492 _chatLog('[WATCHDOG] stopped'); 10493 } 10494 10495 async _autoRestartProducer(forceDuringHold) { 10496 if (!this._userWantsWebcamBroadcast()) return; 10497 if (!forceDuringHold && typeof window._ccMediaHoldActive === 'function' && window._ccMediaHoldActive()) { 10498 _chatLog('[WATCHDOG] auto-restart skipped â media reboot hold (tracks stay live)'); 10499 return; 10500 } 10501 if (this._isCameraIntentionallyOff()) { 10502 this._reassertCameraOffState(); 10503 _chatLog('[WATCHDOG] auto-restart skipped â camera intentionally off'); 10504 return; 10505 } 10506 if (this._suppressTransportCloseRecovery || this._audioReconnectInProgress 10507 || this._videoReconnectInProgress || window.chatCrackWebcam?._settingsDeviceSwapInProgress) return; 10508 if (this._restartInProgress) { _chatLog('[WATCHDOG] restart already in progress, skipping'); return; } 10509 const now = Date.now(); 10510 if (this._lastAutoRestartAt && now - this._lastAutoRestartAt < 5000) { 10511 _chatLog('[WATCHDOG] restart cooldown â skipping'); 10512 return; 10513 } 10514 const broadcastGen = this._webcamBroadcastGen || 0; 10515 const stillBroadcasting = () => 10516 this._userWantsWebcamBroadcast() && (this._webcamBroadcastGen || 0) === broadcastGen; 10517 this._lastAutoRestartAt = now; 10518 this._restartInProgress = true; 10519 this._suppressTransportCloseRecovery = true; 10520 _chatLog('[WATCHDOG] restarting producer (graceful)'); 10521
10522 try { 10523 if (!stillBroadcasting()) return; 10524 const cameraProducer = this.cameraProducer; 10525 const micProducer = this.audioProducer || this.producers?.get?.('mic') || this.micProducer; 10526 10527 // Close camera producer if it exists 10528 if (cameraProducer && !cameraProducer.closed) { 10529 _chatLog('[WATCHDOG] Closing old camera producer:', cameraProducer.id); 10530 try { await cameraProducer.close(); } catch (e) { console.warn('[WATCHDOG] Error closing camera producer:', e); } 10531 } 10532 10533 // Close mic producer if it exists 10534 if (micProducer && !micProducer.closed) { 10535 _chatLog('[WATCHDOG] Closing old mic producer:', micProducer.id); 10536 try { await micProducer.close(); } catch (e) { console.warn('[WATCHDOG] Error closing mic producer:', e); } 10537 } 10538 10539 // Close send transport to notify server 10540 if (this.sendTransport && !this.sendTransport.closed) { 10541 _chatLog('[WATCHDOG] Closing old send transport'); 10542 try { this.sendTransport.close(); } catch (e) { console.warn('[WATCHDOG] Error closing send transport:', e); } 10543 } 10544 10545 // Wait a moment for server to process close events 10546 await new Promise(resolve => setTimeout(resolve, 100)); 10547 if (!stillBroadcasting()) return; 10548 10549 // Leave recv side alone; viewers of others aren't affected 10550 10551 this.cameraProducer = null; 10552 this.audioProducer = null; 10553 this.micProducer = null; 10554 this.producers?.delete?.('camera'); 10555 this.producers?.delete?.('mic'); 10556 10557 _chatLog('[WATCHDOG] Recreating producer...'); 10558 await new Promise((r) => setTimeout(r, 2300)); 10559 if (!stillBroadcasting()) return; 10560 await this._recreateProducer(); 10561 10562 } catch (e) { 10563 console.error('[WATCHDOG] restart error', e); 10564 if (!stillBroadcasting()) { 10565 _chatLog('[WATCHDOG] User turned webcam off â skipping fallback restart'); 10566 return; 10567 } 10568 try { 10569 _chatLog('[WATCHDOG] Fallback: attempting full webcam restart...'); 10570 await new Promise((r) => setTimeout(r, 2300)); 10571 if (!stillBroadcasting()) { 10572 _chatLog('[WATCHDOG] User turned webcam off during fallback wait â aborting'); 10573 return; 10574 } 10575 try { 10576 await this.stopWebcamStreaming(); 10577 _chatLog('[WATCHDOG] Stopped webcam streaming - old producers closed'); 10578 await new Promise(resolve => setTimeout(resolve, 500)); 10579 } catch (stopErr) { 10580 console.warn('[WATCHDOG] Error stopping webcam streaming:', stopErr); 10581 // Fallback to manual cleanup 10582 if (this.cameraProducer && !this.cameraProducer.closed) { 10583 try { await this.cameraProducer.close(); } catch {} 10584 } 10585 if (this.micProducer && !this.micProducer.closed) { 10586 try { await this.micProducer.close(); } catch {} 10587 } 10588 if (this.sendTransport && !this.sendTransport.closed) { 10589 try { this.sendTransport.close(); } catch {} 10590 } 10591 await new Promise(resolve => setTimeout(resolve, 500)); 10592 } 10593 10594 if (!stillBroadcasting()) { 10595 _chatLog('[WATCHDOG] User turned webcam off â not starting fresh stream'); 10596 return; 10597 } 10598 10599 // Now restart cleanly (settings change or media-server recovery only) 10600 _chatLog('[WATCHDOG] Starting fresh webcam stream...'); 10601 if (window.chatCrackWebcam && !window.chatCrackWebcam._settingsRestartInProgress) { 10602 if (typeof window.chatCrackWebcam.republishWebcamKeepingTracks === 'function') { 10603 const kept = await window.chatCrackWebcam.republishWebcamKeepingTracks(); 10604 if (!kept) _chatLog('[WATCHDOG] Republish keeping tracks failed â leaving camera on'); 10605 } 10606 } 10607 } catch (e2) { 10608 console.error('[WATCHDOG] Fallback restart failed', e2); 10609 } 10610 } finally { 10611 this._restartInProgress = false;
10612 setTimeout(() => { this._suppressTransportCloseRecovery = false; }, 3000); 10613 } 10614 } 10615 10616 async _recreateProducer() { 10617 if (!this._userWantsWebcamBroadcast()) { 10618 throw new Error('Webcam broadcast no longer requested'); 10619 } 10620 try { 10621 // Get the local stream from the webcam module 10622 let localStream = window.chatCrackWebcam?.localStream || this.localStream; 10623 let videoTrack = localStream && localStream.getVideoTracks && localStream.getVideoTracks()[0]; 10624 if (!videoTrack || videoTrack.readyState === 'ended') { 10625 _chatLog('[WATCHDOG] Video track ended â reacquiring camera before recreate (saved device/quality)...'); 10626 try { 10627 // Always re-open the saved webcam mic with video. (isMicOn was never set, 10628 // so recreate used to drop audio until the user toggled Webcam Settings.) 10629 const iosNoAudio = !!(window.chatCrackWebcam && window.chatCrackWebcam._iosForceNoAudio); 10630 localStream = await this._reacquireLocalStreamWithSavedSettings(!iosNoAudio); 10631 if (window.chatCrackWebcam) { 10632 window.chatCrackWebcam.localStream = localStream; 10633 } 10634 this.localStream = localStream; 10635 videoTrack = localStream.getVideoTracks()[0]; 10636 } catch (gumErr) { 10637 console.error('[WATCHDOG] Fresh getUserMedia failed:', gumErr); 10638 throw new Error('Video track ended, need fresh stream'); 10639 } 10640 if (!videoTrack || videoTrack.readyState === 'ended') { 10641 throw new Error('Video track ended, need fresh stream'); 10642 } 10643 } else if (!(localStream.getAudioTracks && localStream.getAudioTracks()[0]) 10644 || localStream.getAudioTracks()[0].readyState === 'ended') { 10645 // Video still live but mic track died â refresh stream including audio. 10646 try { 10647 const iosNoAudio = !!(window.chatCrackWebcam && window.chatCrackWebcam._iosForceNoAudio); 10648 if (!iosNoAudio) { 10649 _chatLog('[WATCHDOG] Mic track missing/ended â reacquiring with audio...'); 10650 const keepMuted = this.isMicMuted === true; 10651 localStream = await this._reacquireLocalStreamWithSavedSettings(true); 10652 if (window.chatCrackWebcam) window.chatCrackWebcam.localStream = localStream; 10653 this.localStream = localStream; 10654 this.isMicMuted = keepMuted; 10655 } 10656 } catch (micErr) { 10657 console.warn('[WATCHDOG] Mic reacquire failed (continuing with video):', micErr); 10658 } 10659 } 10660 10661 // Create new transports 10662 _chatLog('[WATCHDOG] Creating new transports...'); 10663 await this._createTransports(); 10664 10665 // Create new producer using existing method 10666 _chatLog('[WATCHDOG] Creating new producer...'); 10667 await this._produceCamera(localStream, 600_000); 10668 10669 // Get the camera producer reference 10670 this.cameraProducer = this.producers?.get('camera') || null; 10671 _chatLog('[WATCHDOG] Producer recreated successfully:', this.cameraProducer?.id); 10672 10673 // Reset watchdog state 10674 this.resetProducerWatchdogState(); 10675 10676 } catch (e) { 10677 console.error('[WATCHDOG] Producer recreation failed', e); 10678 throw e; 10679 } 10680 } 10681 10682 // Test function to simulate producer failure 10683 testWatchdogRestart() { 10684 _chatLog('[WATCHDOG] 𧪠TEST: Simulating producer failure...'); 10685 if (!this._wd) { 10686 console.warn('[WATCHDOG] 𧪠TEST: Watchdog not running, cannot test'); 10687 return; 10688 } 10689 // Force the watchdog to think the producer is unhealthy by: 10690 // 1. Reset last OK timestamp to force stall detection 10691 // 2. Reset frame/byte counters so no progress is detected 10692 this._wd.lastOkTs = 0; 10693 this._wd.lastFramesEncoded = null; 10694 this._wd.lastBytesSent = null; 10695 this._wd.startedAt = Date.now() - 10000; // Make it look like we've been stalled for 10 seconds 10696 _chatLog('[WATCHDOG] 𧪠TEST: Producer marked as stalled, next check will trigger restart'); 10697 } 10698 10699 // More aggressive test - actually close the producer to simulate real failure 10700 testWatchdogRestartHard() { 10701 _chatLog('[WATCHDOG] 𧪠TEST: Simulating HARD producer failure (closing producer)...'); 10702 if (!this._wd) { 10703 console.warn('[WATCHDOG] 𧪠TEST: Watchdog not running, cannot test'); 10704 return; 10705 } 10706 // Actually close the producer to simulate a real failure 10707 if (this.cameraProducer && !this.cameraProducer.closed) { 10708 this.cameraProducer.close(); 10709 _chatLog('[WATCHDOG] 𧪠TEST: Producer closed, watchdog should detect this immediately'); 10710 } else { 10711 console.warn('[WATCHDOG] 𧪠TEST: No producer to close'); 10712 } 10713 } 10714 10715 async stopWebcamStreaming() { 10716 try { 10717 10718 10719 // ð NEW: Stop producer watchdog 10720 this.stopProducerWatchdog(); 10721 this.stopHealthBeacons(); 10722 10723 // ðµ Stop audio bitrate monitoring 10724 this.stopAudioBitrateMonitoring(); 10725 this.stopMicHealthWatchdog(); 10726 10727 // Close producers safely 10728 if (this.producer) { 10729 try { 10730 await this.producer.close(); 10731 10732 } catch (e) { 10733 console.warn("[WebRTC] Error closing video producer:", e); 10734 } 10735 this.producer = null; 10736 } 10737 10738 // Close all producers in the map
10739 this.producers.forEach(producer => { 10740 try { 10741 producer.close(); 10742 } catch (e) { 10743 console.warn("[WebRTC] Error closing producer:", e); 10744 } 10745 }); 10746 this.producers.clear(); 10747 10748 // Stop tracks safely 10749 if (this.localStream) { 10750 this.localStream.getTracks().forEach(track => { 10751 try { 10752 track.stop(); 10753 10754 } catch (e) { 10755 console.warn("[WebRTC] Error stopping track:", e); 10756 } 10757 }); 10758 this.localStream = null; 10759 } 10760 10761 // Close transports 10762 if (this.sendTransport) { 10763 try { 10764 this.sendTransport.close(); 10765 10766 } catch (e) { 10767 console.warn("[WebRTC] Error closing send transport:", e); 10768 } 10769 this.sendTransport = null; 10770 } 10771 if (this.recvTransport) { 10772 try { 10773 this.recvTransport.close(); 10774 10775 } catch (e) { 10776 console.warn("[WebRTC] Error closing receive transport:", e); 10777 } 10778 this.recvTransport = null; 10779 } 10780 10781 // Close socket only when this instance owns it (not a borrowed shared connection) 10782 if (this.socket && !this._borrowsMediaSocket) { 10783 try { 10784 this.socket.close(); 10785 } catch (e) { 10786 console.warn("[WebRTC] Error closing socket:", e); 10787 } 10788 } 10789 this.socket = null; 10790 10791 // Clean up 10792 this.cleanup(); 10793 10794 10795 10796 } catch (error) { 10797 console.error("[WebRTC] Failed to stop webcam streaming:", error); 10798 throw error; 10799 } 10800 } 10801 10802 10803 10804 /** True when a viewer's recv transport is still usable on the client. */ 10805 _ccViewRecvTransportUsable(t) { 10806 if (!t || t.closed || t._ccServerMissing) return false; 10807 const s = t.connectionState; 10808 // Reuse connected/connecting/new. Reject failed/disconnected â those are 10809 // ghosts after reboot or server prune ("Transport not found"). 10810 // Freshly created transports stay "new" until first consume connects; 10811 // video+audio recovery must share that one instead of pruning it. 10812 return s === 'connected' || s === 'connecting' || s === 'new'; 10813 } 10814 10815 /** Serialize recv-transport create/reuse per publisher (avoids server prune race). */ 10816 async _ccWithViewRecvLock(username, fn) { 10817 const key = String(username || '').toLowerCase(); 10818 if (!this._viewRecvLocks) this._viewRecvLocks = new Map(); 10819 const prev = this._viewRecvLocks.get(key) || Promise.resolve(); 10820 let release; 10821 const gate = new Promise((r) => { release = r; }); 10822 const chained = prev.catch(() => {}).then(() => gate); 10823 this._viewRecvLocks.set(key, chained); 10824 await prev.catch(() => {}); 10825 try { 10826 return await fn(); 10827 } finally { 10828 try { release(); } catch (_) {} 10829 if (this._viewRecvLocks.get(key) === chained) this._viewRecvLocks.delete(key); 10830 } 10831 } 10832 10833 /** 10834 * Create or reuse a view recv transport. On "Transport not found" the 10835 * connect handler marks it dead so the next call builds a fresh one. 10836 */ 10837 async _ccEnsureViewRecvTransport(opts) { 10838 const { 10839 existingViewer, viewSocket, viewDevice, webcamRoomId, viewerId, MEDIA_OP_MS, forceNew, skipLock 10840 } = opts || {}; 10841 const build = async () => { 10842 const overlap = !!(forceNew && (opts && opts.overlap)) 10843 || (typeof window._ccMediaRolloverOverlapActive === 'function' 10844 && window._ccMediaRolloverOverlapActive()); 10845 // Reuse a live transport. Even on forceNew, if a sibling consume just 10846 // rebuilt one, prefer that over creating again (server prune race). 10847 // Live rollover MUST build a second recv transport on the new router 10848 // while the old one keeps painting. 10849 let viewRecvTransport = (!overlap && existingViewer 10850 && this._ccViewRecvTransportUsable(existingViewer.recvTransport)) 10851 ? existingViewer.recvTransport 10852 : ((overlap && existingViewer && existingViewer._ccRolloverRecv 10853 && this._ccViewRecvTransportUsable(existingViewer.recvTransport)) 10854 ? existingViewer.recvTransport 10855 : null); 10856 10857 if (!viewRecvTransport) { 10858 if (existingViewer && existingViewer.recvTransport && !
10858overlap) { 10859 try { if (!existingViewer.recvTransport.closed) existingViewer.recvTransport.close(); } catch (_) {} 10860 existingViewer.recvTransport = null; 10861 } else if (overlap && existingViewer && existingViewer.recvTransport) { 10862 existingViewer._ccLegacyRecv = existingViewer.recvTransport; 10863 } 10864 const viewRecvOpts = await new Promise((resolve, reject) => { 10865 const timeout = setTimeout(() => { 10866 reject(new Error('Transport creation timeout for view room')); 10867 }, MEDIA_OP_MS || 45000); 10868 viewSocket.emit('chatcrack:create-transport', { 10869 roomId: webcamRoomId, 10870 userId: viewerId, 10871 direction: 'recv', 10872 rollover: !!overlap 10873 }, (response) => { 10874 clearTimeout(timeout); 10875 if (response && response.ok) resolve(response); 10876 else reject(new Error(`Transport creation failed for view room: ${response?.message || 'Unknown error'}`)); 10877 }); 10878 }); 10879 viewRecvTransport = viewDevice.createRecvTransport( 10880 window.ccMediasoupTransportOpts 10881 ? window.ccMediasoupTransportOpts(viewRecvOpts) 10882 : viewRecvOpts 10883 ); 10884 if (existingViewer) { 10885 existingViewer.recvTransport = viewRecvTransport; 10886 if (overlap) existingViewer._ccRolloverRecv = true; 10887 } 10888 } 10889 10890 if (!viewRecvTransport._ccConnectWired) { 10891 const transportRef = viewRecvTransport; 10892 transportRef.on('connect', ({ dtlsParameters }, callback, errorback) => { 10893 viewSocket.emit('chatcrack:connect-transport', { 10894 roomId: webcamRoomId, 10895 transportId: transportRef.id, 10896 dtlsParameters 10897 }, (response) => { 10898 if (response && response.ok) { 10899 callback(); 10900 return; 10901 } 10902 const msg = response?.message || 'Transport connection failed'; 10903 console.error('[WebRTC] â View recvTransport connection failed:', msg); 10904 // Server no longer has this transport (reboot / prune / 10905 // superseded create). Drop the ghost so we recreate. 10906 try { transportRef._ccServerMissing = true; } catch (_) {} 10907 if (existingViewer && existingViewer.recvTransport === transportRef) { 10908 existingViewer.recvTransport = null; 10909 } 10910 try { if (!transportRef.closed) transportRef.close(); } catch (_) {} 10911 errorback(new Error(msg)); 10912 }); 10913 }); 10914 viewRecvTransport._ccConnectWired = true; 10915 } 10916 if (!viewRecvTransport._ccIceWired) { 10917 viewRecvTransport._ccIceWired = true; 10918 const iceRef = viewRecvTransport; 10919 iceRef.on('connectionstatechange', (state) => { 10920 if (state === 'connected') { 10921 try { 10922 if (existingViewer) { 10923 existingViewer._recvRecoverAttempts = 0; 10924 if (existingViewer._recvRecoverTimer) { 10925 clearTimeout(existingViewer._recvRecoverTimer); 10926 existingViewer._recvRecoverTimer = null; 10927 } 10928 } 10929 } catch (_) {} 10930 return; 10931 } 10932 if (state === 'failed' || state === 'disconnected' || state === 'closed') { 10933 let pub = existingViewer && existingViewer.publisherUsername; 10934 if (!pub && typeof viewerId === 'string' && viewerId.indexOf('#viewer#') !== -1) { 10935 pub = viewerId.split('#viewer#').pop(); 10936 } 10937 if (pub && typeof window._ccRecoverDockAfterIceFail === 'function') { 10938 window._ccRecoverDockAfterIceFail(pub, state, existingViewer); 10939 } 10940 } 10941 }); 10942 } 10943 return viewRecvTransport; 10944 }; 10945 if (skipLock) return build(); 10946 return this._ccWithViewRecvLock(viewerId || webcamRoomId, build); 10947 } 10948 10949 /** consumeUserWebcam with one automatic recreate on Transport not found. */ 10950 async _ccConsumeUserWebcamResilient(ctx, producerId, username, videoElement, device) { 10951 const attempt = async (transport) => { 10952 if (!transport || transport.closed || transport._ccServerMissing) {
10953 throw new Error('Transport not found'); 10954 } 10955 return this.consumeUserWebcam(ctx.viewSocket, transport, producerId, username, videoElement, device); 10956 }; 10957 try { 10958 const overlapLive = typeof window._ccMediaRolloverOverlapActive === 'function' 10959 && window._ccMediaRolloverOverlapActive(); 10960 // Overlap: keep the recv we just built on the new router. Reusing 10961 // the leftover "usable" transport is how new cams stayed black. 10962 if (!overlapLive && ctx.existingViewer && this._ccViewRecvTransportUsable(ctx.existingViewer.recvTransport)) { 10963 ctx.viewRecvTransport = ctx.existingViewer.recvTransport; 10964 } 10965 return await attempt(ctx.viewRecvTransport); 10966 } catch (err) { 10967 const msg = String((err && err.message) || err || ''); 10968 if (!/transport not found|transport closed/i.test(msg)) throw err; 10969 console.warn(`[WebRTC] Recreating recv transport for ${username} after: ${msg}`); 10970 // Allow re-consume of the same producer id after transport rebuild. 10971 try { 10972 const consumerKey = `consumeUserWebcam-${username}-${producerId}`; 10973 if (this._consumedProducerKeys) this._consumedProducerKeys.delete(consumerKey); 10974 } catch (_) {} 10975 // Serialize rebuild so video+audio failures share one new transport. 10976 await this._ccWithViewRecvLock(ctx.viewerId || ctx.webcamRoomId, async () => { 10977 const overlapRetry = typeof window._ccMediaRolloverOverlapActive === 'function' 10978 && window._ccMediaRolloverOverlapActive(); 10979 if (!overlapRetry && ctx.existingViewer && this._ccViewRecvTransportUsable(ctx.existingViewer.recvTransport)) { 10980 ctx.viewRecvTransport = ctx.existingViewer.recvTransport; 10981 return; 10982 } 10983 ctx.viewRecvTransport = await this._ccEnsureViewRecvTransport({ 10984 existingViewer: ctx.existingViewer, 10985 viewSocket: ctx.viewSocket, 10986 viewDevice: ctx.viewDevice || device, 10987 webcamRoomId: ctx.webcamRoomId, 10988 viewerId: ctx.viewerId, 10989 MEDIA_OP_MS: ctx.MEDIA_OP_MS, 10990 forceNew: true, 10991 skipLock: true 10992 }); 10993 }); 10994 if (this.viewingConnections && this.viewingConnections.has(username)) { 10995 const prev = this.viewingConnections.get(username) || {}; 10996 this.viewingConnections.set(username, Object.assign({}, prev, { 10997 socket: ctx.viewSocket, 10998 transport: ctx.viewRecvTransport, 10999 device: ctx.viewDevice || device 11000 })); 11001 } 11002 return attempt(ctx.viewRecvTransport); 11003 } 11004 } 11005 11006 async viewUserWebcamStream(username, videoElement) { 11007 // Coalesce concurrent docks/recovery for the same publisher â parallel 11008 // create-transport calls make the server close the first transport, 11009 // which surfaces as "Transport not found" on connect. 11010 const coalesceKey = String(username || '').toLowerCase(); 11011 if (!this._viewStreamInFlight) this._viewStreamInFlight = new Map(); 11012 if (!this._viewStreamInFlightAt) this._viewStreamInFlightAt = Object.create(null); 11013 if (this._viewStreamInFlight.has(coalesceKey)) { 11014 const started = (this._viewStreamInFlightAt && this._viewStreamInFlightAt[coalesceKey]) || 0; 11015 if (started && (Date.now() - started) < 2500) { 11016 return this._viewStreamInFlight.get(coalesceKey); 11017 } 11018 this._viewStreamInFlight.delete(coalesceKey); 11019 } 11020 const run = this._viewUserWebcamStreamImpl(username, videoElement).finally(() => { 11021 if (this._viewStreamInFlight.get(coalesceKey) === run) { 11022 this._viewStreamInFlight.delete(coalesceKey); 11023 } 11024 try { delete this._viewStreamInFlightAt[coalesceKey]; } catch (_) {} 11025 }); 11026 this._viewStreamInFlight.set(coalesceKey, run); 11027 this._viewStreamInFlightAt[coalesceKey] = Date.now(); 11028 return run; 11029 } 11030 11031 async _viewUserWebcamStreamImpl(username, videoElement) { 11032 if (typeof window._ccIsOwnMediaUser === 'function' && window._ccIsOwnMediaUser(username)) { 11033 // A tile for our own name can never be filled by a consume â the SFU 11034 // will not send us back our own producer. If the dock was built as a 11035 // remote tile (self detection lost the race with the roster), paint 11036 // the local stream onto it instead of leaving it black forever. 11037 try { 11038 const own = (window.chatCrackWebcam && window.chatCrackWebcam.localStream) 11039 || (window.chatCrackWebRTC && window.chatCrackWebRTC.localStream); 11040 const tile = (typeof window._ccFindWebcamDockTile === 'function') 11041 ? window._ccFindWebcamDockTile(username) : null; 11042 const el = videoElement || (tile && tile.querySelector('video')); 11043 if (own && el && typeof window._ccBindLiveVideoToElement === 'function') { 11044 el.muted = true; 11045 window._ccBindLiveVideoToElement(el, own); 11046 if (typeof window._ccSetCamState === 'function') window._ccSetCamState(username, 'live'); 11047 return; 11048 } 11049 if (typeof window._ccNoteCamError === 'function') { 11050 window._ccNoteCamError(username, own ? 'Own camera' : 'Your camera is off'); 11051 } 11052 } catch (_) {} 11053 return; 11054 } 11055 // CRITICAL: Verify video element username matches to prevent cross-contamination 11056 if (videoElement && videoElement.dataset.username) {
11057 const videoUsername = videoElement.dataset.username; 11058 // Compare case-insensitively: roster casing and publisher session 11059 // casing legitimately differ for the same person, and treating that 11060 // as contamination aborted valid consumes. 11061 if (!window._ccSameWebcamUser(videoUsername, username)) { 11062 if (window.WEBCAM_DEBUG) { 11063 console.error(`[WEBCAM QUEUE] Video element is for ${videoUsername} but consumption requested for ${username}`); 11064 } 11065 return; 11066 } 11067 } else if (videoElement) { 11068 videoElement.dataset.username = username; 11069 videoElement.dataset.consumptionTime = Date.now().toString(); 11070 } 11071 // Declared out here so the catch can reach it. A join or transport failure 11072 // below used to strand the socket this call opened: it stays joined to the 11073 // mediasoup room holding a transport, and since these sockets run with 11074 // reconnection off nothing ever reaps it. That is what left rooms sitting 11075 // at producers=0 with orphan transports while watchdogs opened yet another. 11076 let ownedViewSocket = null; 11077 try { 11078 if (window.ccCamPerf) window.ccCamPerf.mark(username, 'STREAM_REQUEST_START'); 11079 11080 if (typeof window.ccAwaitWebcamMediaReady === 'function') { 11081 await window.ccAwaitWebcamMediaReady(10000); 11082 } 11083 11084 // Check if mediasoupClient is available 11085 if (!window.mediasoupClient?.Device) { 11086 const error = new Error('mediasoup-client Device class not available'); 11087 console.error(`[WebRTC] Cannot view ${username}'s webcam - mediasoup-client not loaded from /mediasoup-bundle.js:`, error); 11088 if (typeof window._ccNoteCamError === 'function') { 11089 window._ccNoteCamError(username, 'Video engine not loaded'); 11090 } 11091 if (typeof window._ccSetCamState === 'function') { 11092 window._ccSetCamState(username, 'error', 'Loading video engineâ¦'); 11093 } 11094 // Keep pulling the bundle so the retry a second later succeeds 11095 // instead of failing the same way for the next 30s. 11096 if (typeof window.ccEnsureMediasoupBundle === 'function') { 11097 try { window.ccEnsureMediasoupBundle(); } catch (_) {} 11098 } 11099 throw error; 11100 } 11101 11102 // If no videoElement provided, find or create one in the webcam panel 11103 if (!videoElement) { 11104 11105 // Case-insensitive tile lookup â the tile carries the publisher's 11106 // session casing, which need not match the caller's casing. 11107 const ownTile = (typeof window._ccFindWebcamDockTile === 'function') 11108 ? window._ccFindWebcamDockTile(username) 11109 : document.querySelector(`.webcam-dock[data-username="${CSS.escape(username)}"]`); 11110 videoElement = ownTile ? ownTile.querySelector('video') : null; 11111 11112 if (!videoElement) { 11113 11114 // Find the webcam panel for this user 11115 const webcamTile = ownTile; 11116 if (webcamTile) { 11117 const videoHost = webcamTile.querySelector('.webcam-video'); 11118 if (videoHost) { 11119 videoElement = document.createElement('video'); 11120 videoElement.autoplay = true; 11121 videoElement.playsInline = true; 11122 videoElement.muted = !!(typeof window._ccIsOwnMediaUser === 'function' && window._ccIsOwnMediaUser(username)); 11123 videoElement.style.display = 'block'; 11124 videoElement.style.objectFit = 'cover'; 11125 videoHost.appendChild(videoElement); 11126 if (typeof window._ccSyncDockVideoStyles === 'function') { 11127 window._ccSyncDockVideoStyles(webcamTile, false); 11128 } 11129 // Add audio level indicator and volume controls 11130 const _npTile = videoHost.closest('.webcam-dock'); 11131 this.createAudioLevelIndicator(videoElement, _npTile, username);
11132 this.createVolumeControls(videoElement, _npTile, username); 11133 try { 11134 if (_npTile && typeof window.ensureWebcamNowPlayingOverlay === 'function') { 11135 window.ensureWebcamNowPlayingOverlay(_npTile); 11136 } 11137 if (typeof window.setWebcamNowPlaying === 'function' && window._ccWebcamNowPlayingByUser) { 11138 const cached = window._ccWebcamNowPlayingByUser.get(String(username).toLowerCase()); 11139 if (cached) window.setWebcamNowPlaying(username, cached); 11140 } 11141 if (typeof window.ccFetchWebcamNowPlaying === 'function') { 11142 window.ccFetchWebcamNowPlaying(username); 11143 } 11144 } catch (_) {} 11145 11146 } 11147 } 11148 } 11149 } 11150 11151 if (!videoElement) { 11152 throw new Error(`Could not find or create video element for ${username}`); 11153 } 11154 11155 const webcamRoomId = (typeof window._ccWebcamRoomId === 'function') 11156 ? window._ccWebcamRoomId(username) 11157 : ('chatcrack_webcam_' + String(username).toLowerCase()); 11158 const baseUser = window.currentUsername || window.me?.username || ('viewer_' + Date.now()); 11159 const viewerId = `${baseUser}#viewer#${username}`; 11160 const MEDIA_CONNECT_MS = 15000; 11161 const MEDIA_OP_MS = 15000; 11162 11163 // Drop stale dedicated viewing socket from a prior dock attempt 11164 if (this.viewingConnections && this.viewingConnections.has(username)) { 11165 const stale = this.viewingConnections.get(username); 11166 const viewerSock = window.remoteWebcamViewers?.[username]?.socket; 11167 try { if (stale.transport && !stale.transport.closed) stale.transport.close(); } catch (_) {} 11168 try { 11169 if (stale.socket && stale.socket !== viewerSock) { 11170 if (typeof window._ccKillSocketIo === 'function') window._ccKillSocketIo(stale.socket); 11171 else stale.socket.disconnect(); 11172 } 11173 } catch (_) {} 11174 this.viewingConnections.delete(username); 11175 } 11176 11177 const existingViewer = (typeof window._ccGetRemoteWebcamViewer === 'function') 11178 ? window._ccGetRemoteWebcamViewer(username) 11179 : (window.remoteWebcamViewers && window.remoteWebcamViewers[username]); 11180 let viewSocket; 11181 let viewDevice; 11182 let joinPayload; 11183 const rolloverGen = window._ccMediaRolloverGen || 0; 11184 const tileFresh = typeof window._ccDockTileHasFreshVideo === 'function' 11185 && window._ccDockTileHasFreshVideo(username); 11186 // Socket dropped since the last dock: the SFU has closed this 11187 // viewer's transports, so nothing about it is reusable no matter how 11188 // recently the tile painted. 11189 const viewerNeedsRejoin = !!(existingViewer && existingViewer._ccNeedsRejoin); 11190 const viewerStale = viewerNeedsRejoin || !!(existingViewer && !tileFresh && ( 11191 ((existingViewer._ccRolloverGen || 0) !== rolloverGen) 11192 || (typeof window._ccMediaRolloverOverlapActive === 'function' 11193 && window._ccMediaRolloverOverlapActive() 11194 && !existingViewer._ccRolloverRecv) 11195 )); 11196 11197 // A prewarmed viewer has a connected socket and a loaded device but 11198 // may have had its idle transport pruned. Rebuilding one transport 11199 // is far quicker than throwing the warm socket away and redialling. 11200 if (existingViewer && existingViewer.socket && existingViewer.socket.connected 11201 && existingViewer.device && existingViewer.device.loaded 11202 && !viewerStale && !viewerNeedsRejoin 11203 && (tileFresh || existingViewer._ccPrewarmed 11204 || this._ccViewRecvTransportUsable(existingViewer.recvTransport))) { 11205 viewSocket = existingViewer.socket; 11206 viewDevice = existingViewer.device; 11207 joinPayload = { 11208 routerRtpCapabilities: existingViewer.device.rtpCapabilities, 11209 producers: Array.isArray(existingViewer.lastJoinResponse?.producers) 11210 ? existingViewer.lastJoinResponse.producers.slice() 11211 : [] 11212 }; 11213 try { 11214 const listed = await new Promise((resolve) => { 11215 const timeout = setTimeout(() => resolve(null), MEDIA_OP_MS); 11216 viewSocket.emit('chatcrack:list-producers', { roomId: webcamRoomId }, (resp) => { 11217 clearTimeout(timeout); 11218 resolve(resp); 11219 }); 11220 }); 11221 if (listed && listed.ok && Array.isArray(listed.list) && listed.list.length > 0) { 11222 joinPayload.producers = listed.list; 11223 } 11224 } catch (_) {} 11225 try { 11226 const forced = window._ccForcedWebcamPlay && window._ccForcedWebcamPlay[String(username).toLowerCase()];
11227 if (forced && Array.isArray(forced.producers) && forced.producers.length 11228 && (!joinPayload.producers || joinPayload.producers.length < forced.producers.length 11229 || (Date.now() - (forced.at || 0)) < 8000)) { 11230 joinPayload.producers = forced.producers; 11231 } 11232 } catch (_) {} 11233 } else { 11234 if (existingViewer && existingViewer.socket) { 11235 try { 11236 if (typeof window._ccKillSocketIo === 'function') window._ccKillSocketIo(existingViewer.socket); 11237 else existingViewer.socket.disconnect(); 11238 } catch (_) {} 11239 } 11240 // https:// (not wss://) so polling can connect when WS upgrade fails. 11241 const mediaUrl = (typeof window.ccNormalizeMediaOrigin === 'function') 11242 ? window.ccNormalizeMediaOrigin(this.serverUrl) 11243 : String(this.serverUrl || 'https://media.chatcrack.us').replace(/^wss:/i, 'https:').replace(/^ws:/i, 'http:'); 11244 viewSocket = io(mediaUrl, { 11245 transports: (typeof window.ccMediaSocketTransportOrder === 'function') 11246 ? window.ccMediaSocketTransportOrder() 11247 : ['polling', 'websocket'], 11248 tryAllTransports: true, 11249 timeout: MEDIA_CONNECT_MS, 11250 forceNew: true, 11251 upgrade: true, 11252 rememberUpgrade: false, 11253 reconnection: true, 11254 reconnectionAttempts: 6, 11255 reconnectionDelay: 1200, 11256 reconnectionDelayMax: 8000, 11257 path: '/socket.io/', 11258 query: { role: 'viewer', user: viewerId, ns: 'viewer' } 11259 }); 11260 ownedViewSocket = viewSocket; 11261 if (typeof window._ccWireViewerMediaSocket === 'function') { 11262 window._ccWireViewerMediaSocket(viewSocket, username, null); 11263 } 11264 11265 await new Promise((resolve, reject) => { 11266 viewSocket.once('connect', resolve); 11267 viewSocket.once('connect_error', reject); 11268 setTimeout(() => reject(new Error('Connection timeout')), MEDIA_CONNECT_MS); 11269 }); 11270 11271 if (window.ccCamPerf) window.ccCamPerf.mark(username, 'CAMERA_STATUS_REQUEST'); 11272 joinPayload = await new Promise((resolve, reject) => { 11273 const timeout = setTimeout(() => { 11274 reject(new Error('View room join timeout')); 11275 }, MEDIA_OP_MS); 11276 11277 viewSocket.emit('chatcrack:join-room', { 11278 roomId: webcamRoomId, 11279 userId: viewerId 11280 }, (response) => { 11281 clearTimeout(timeout); 11282 if (response && response.ok) { 11283 resolve(response); 11284 } else { 11285 reject(new Error(`Server error: ${response?.message || 'Room join failed'}`)); 11286 } 11287 }); 11288 }); 11289 if (window.ccCamPerf) { 11290 window.ccCamPerf.mark(username, 'CAMERA_STATUS_RESPONSE', 11291 (joinPayload.producers || []).length + ' producers'); 11292 } 11293 11294 viewDevice = new window.mediasoupClient.Device(); 11295 await viewDevice.load({ routerRtpCapabilities: joinPayload.routerRtpCapabilities }); 11296 if (window.ccCamPerf) window.ccCamPerf.mark(username, 'PLAYER_INITIALIZED'); 11297 } 11298 11299 try { 11300 const forcedAll = window._ccForcedWebcamPlay && window._ccForcedWebcamPlay[String(username).toLowerCase()]; 11301 if (forcedAll && Array.isArray(forcedAll.producers) && forcedAll.producers.length) { 11302 joinPayload.producers = forcedAll.producers; 11303 } 11304 } catch (_) {} 11305 if ((!joinPayload.producers || !joinPayload.producers.length) && viewSocket && viewSocket.connected) { 11306 try { 11307 const listedNow = await new Promise(function (resolve) { 11308 const t = setTimeout(function () { resolve(null); }, 2500); 11309 viewSocket.emit('chatcrack:list-producers', { roomId: webcamRoomId }, function (resp) { 11310 clearTimeout(t); 11311 resolve(resp); 11312 }); 11313 }); 11314 if (listedNow && listedNow.ok && Array.isArray(listedNow.list) && listedNow.list.length) { 11315 joinPayload.producers = listedNow.list; 11316 } 11317 } catch (_) {} 11318 } 11319 11320 const { routerRtpCapabilities } = joinPayload; 11321 11322 const viewCtx = { 11323 existingViewer: (viewerStale ? null : existingViewer), 11324 viewSocket, 11325 viewDevice, 11326 webcamRoomId, 11327 viewerId, 11328 MEDIA_OP_MS, 11329 viewRecvTransport: null 11330 }; 11331 let viewRecvTransport = await this._ccEnsureViewRecvTransport(viewCtx); 11332 viewCtx.viewRecvTransport = viewRecvTransport; 11333 if (window.ccCamPerf) window.ccCamPerf.mark(username, 'STREAM_CONNECTION_STARTED'); 11334 try { 11335 if (existingViewer) { 11336 existingViewer._ccRolloverGen = window._ccMediaR
11336olloverGen || 0; 11337 // Only this viewer object getting the rebuilt transport means 11338 // it is whole again; a stale one keeps forcing the slow path. 11339 if (viewCtx.existingViewer === existingViewer) existingViewer._ccNeedsRejoin = false; 11340 } 11341 } catch (_) {} 11342 11343 // Listen for the user's webcam producer - try multiple event names 11344 const handleNewProducer = (payload) => { 11345 11346 var producerId = payload && payload.producerId; 11347 var userId = payload && (payload.userId || payload.producerUserId); 11348 var rid = payload && payload.roomId; 11349 var kind = payload && payload.kind; 11350 11351 11352 if (!producerId || !userId) { 11353 11354 return; 11355 } 11356 const sameUser = (typeof window._ccSameWebcamUser === 'function') 11357 ? window._ccSameWebcamUser(userId, username) 11358 : (String(userId).toLowerCase() === String(username).toLowerCase()); 11359 if (!sameUser) { 11360 return; 11361 } 11362 11363 // CRITICAL FIX: Retry mechanism to wait for video element if it doesn't exist yet 11364 // This handles race conditions where new-producer events arrive before the video element is created 11365 // Track pending producers per username to prevent duplicate retries 11366 if (!window._pendingProducers) { 11367 window._pendingProducers = new Map(); 11368 } 11369 11370 const producerKey = `${username}_${producerId}`; 11371 if (window._pendingProducers.has(producerKey)) { 11372 // Already processing this producer, skip duplicate 11373 if (window.WEBCAM_DEBUG) { 11374 11375 } 11376 return; 11377 } 11378 11379 // Same video producer while the dock is still painting â do not 11380 // close the live consumer (that was the black flash on resync). 11381 if (kind === 'video' || !kind) { 11382 const alreadyKey = `consumeUserWebcam-${username}-${producerId}`; 11383 const already = !!(this._consumedProducerKeys && this._consumedProducerKeys.has(alreadyKey)); 11384 const painted = typeof window._ccDockTileHasPaintedVideo === 'function' 11385 && window._ccDockTileHasPaintedVideo(username); 11386 const fresh = typeof window._ccDockTileHasFreshVideo === 'function' 11387 ? window._ccDockTileHasFreshVideo(username) 11388 : painted; 11389 if (already && (fresh || painted)) { 11390 if (typeof window._ccCancelWebcamProducerCloseGrace === 'function') { 11391 window._ccCancelWebcamProducerCloseGrace(username); 11392 } 11393 return; 11394 } 11395 } 11396 11397 const tryConsumeProducer = (retryCount = 0) => { 11398 const maxRetries = 60; // Try for up to 30 seconds (60 * 500ms) 11399 const retryDelay = 500; // Wait 500ms between retries 11400 11401 // Check if video element exists and is in the DOM 11402 const currentTile = (typeof window._ccFindWebcamDockTile === 'function') 11403 ? window._ccFindWebcamDockTile(username) 11404 : document.querySelector(`.webcam-dock[data-username="${CSS.escape(username)}"]`); 11405 const currentVideoElement = currentTile ? currentTile.querySelector('video') : null; 11406 11407 if (!currentVideoElement) { 11408 if (retryCount < maxRetries) { 11409 // Video element not ready yet, retry after a delay 11410 if (retryCount === 0) { 11411 window._pendingProducers.set(producerKey, true); 11412 11413 if (window.WEBCAM_DEBUG) { 11414 11415 11416 } 11417 } else if (retryCount % 2 === 0 && window.WEBCAM_DEBUG) { 11418 // Only log every other retry to reduce clutter 11419 11420 } 11421 setTimeout(() => tryConsumeProducer(retryCount + 1), retryDelay); 11422 return; 11423 } else { 11424 // Max retries reached, give up 11425 window._pendingProducers.delete(producerKey); 11426 11427 return; 11428 } 11429 } 11430 11431 // Video element found, proceed with consumption 11432 window._pendingProducers.delete(producerKey); 11433 11434 if (window.WEBCAM_DEBUG) { 11435 11436 } 11437 11438 clearProducerTimeout(); // Clear the timeout since we found a producer 11439 11440 // Mic re-produce / unmute must NOT tear video consumers â that blacks 11441 // fav docks and triggers forceReconnect â Transport closed storms.
11442 const overlapLive = typeof window._ccMediaRolloverOverlapActive === 'function' 11443 && window._ccMediaRolloverOverlapActive(); 11444 const producerKind = (kind === 'audio' || kind === 'video') ? kind : null; 11445 if (producerKind === 'audio' && !overlapLive && typeof window._ccMarkAudioProducerGrace === 'function') { 11446 try { window._ccMarkAudioProducerGrace(username, 16000); } catch (_) {} 11447 } 11448 11449 // Close only same-kind consumers (audio new-producer keeps video alive). 11450 // Never close a live video consumer that is still painting, or the 11451 // same producerId â that blacks the dock before the replacement attaches. 11452 // Live rollover keeps the old consumer until the new stream paints. 11453 const viewer = window.remoteWebcamViewers?.[username]; 11454 if (viewer && viewer.consumers && viewer.consumers instanceof Map && !overlapLive) { 11455 const consumersToClose = []; 11456 const paintedVideo = typeof window._ccDockTileHasPaintedVideo === 'function' 11457 && window._ccDockTileHasPaintedVideo(username); 11458 const freshVideo = typeof window._ccDockTileHasFreshVideo === 'function' 11459 ? window._ccDockTileHasFreshVideo(username) 11460 : paintedVideo; 11461 viewer.consumers.forEach((consumer, key) => { 11462 if (!consumer || typeof consumer !== 'object' || consumer.closed) return; 11463 if (producerKind && consumer.kind && consumer.kind !== producerKind) return; 11464 if (consumer.kind === 'video' || (!consumer.kind && (producerKind === 'video' || !producerKind))) { 11465 if (consumer.producerId && String(consumer.producerId) === String(producerId)) return; 11466 if (paintedVideo || freshVideo) return; 11467 } 11468 consumersToClose.push({ consumer, key }); 11469 }); 11470 11471 if (consumersToClose.length > 0) { 11472 consumersToClose.forEach(({ consumer, key }) => { 11473 try { 11474 if (consumer && !consumer.closed) { 11475 consumer.close(); 11476 if (key) viewer.consumers.delete(key); 11477 } 11478 } catch (e) { 11479 console.warn(`[WebRTC] Error closing old consumer:`, e); 11480 } 11481 }); 11482 } 11483 } 11484 11485 // Soft clear only ended tracks of the same kind (never strip live video for audio churn). 11486 if (currentVideoElement && currentVideoElement.srcObject) { 11487 const oldStream = currentVideoElement.srcObject; 11488 oldStream.getTracks().forEach(track => { 11489 if (!track || track.readyState !== 'ended') return; 11490 if (producerKind && track.kind !== producerKind) return; 11491 try { oldStream.removeTrack(track); } catch (_) {} 11492 }); 11493 } 11494 11495 // Clear dedupe keys for same-kind producers only. 11496 if (this._consumedProducerKeys) { 11497 const keysToRemove = []; 11498 this._consumedProducerKeys.forEach(key => { 11499 if (!key.includes(`-${username}-`)) return; 11500 const prevKind = this._consumedProducerKinds 11501 ? this._consumedProducerKinds.get(key) 11502 : null; 11503 if (!producerKind || !prevKind || prevKind === producerKind) { 11504 keysToRemove.push(key); 11505 } 11506 }); 11507 keysToRemove.forEach(key => { 11508 this._consumedProducerKeys.delete(key); 11509 if (this._consumedProducerKinds) this._consumedProducerKinds.delete(key); 11510 }); 11511 } 11512 11513 // CRITICAL: Use the current video element from DOM, not the one passed to viewUserWebcamStream 11514 // This ensures we're using the element that actually exists 11515 viewRecvTransport = viewCtx.viewRecvTransport 11516 || (existingViewer && existingViewer.recvTransport) 11517 || viewRecvTransport; 11518 viewCtx.viewRecvTransport = viewRecvTransport; 11519 const runConsume = () => { 11520 this._ccConsumeUserWebcamResilient(viewCtx, producerId, username, currentVideoElement, viewDevice) 11521 .catch((err) => { 11522 const msg = String((err && err.message) || err || ''); 11523 if (/transport closed|InvalidStateError|closed/i.test(msg)) { 11524 if (window.WEBCAM_DEBUG) console.warn(`[WebRTC] Consume aborted (viewer closed) for ${username}`); 11525 return; 11526 } 11527 console.error(`[WebRTC] Failed to consume ${username}'s webcam:`, err); 11528 }); 11529 }; 11530 const needNewRecv = overlapLive 11531 || !viewRecvTransport 11532 || viewRecvTransport.closed 11533 || viewRecvTransport._ccServerMissing; 11534 if (needNewRecv) { 11535 this._ccEnsureViewRecvTransport(Object.assign({}, viewCtx, { forceNew: true, overlap: !!overlapLive })) 11536 .then((t) => { 11537 viewRecvTransport = t; 11538 viewCtx.viewRecvTransport = t; 11539 runConsume(); 11540 }) 11541 .catch(() => { 11542 window._pendingProducers && window._pendingProducers.delete(producerKey);
11543 console.warn(`[WebRTC] Skip consume ${username}: transport closed before producer attach`); 11544 }); 11545 return; 11546 } 11547 runConsume(); 11548 }; 11549 11550 // Start the retry mechanism 11551 tryConsumeProducer(); 11552 }; 11553 11554 // Listen for multiple possible event names 11555 viewSocket.on('chatcrack:new-producer', handleNewProducer); 11556 viewSocket.on('new-producer', handleNewProducer); 11557 viewSocket.on('mediasoup:new-producer', handleNewProducer); 11558 11559 // A re-produce supersedes the producer we are consuming. Retire the 11560 // dead one now so the new-producer that follows is free to consume 11561 // instead of being skipped as a duplicate. 11562 viewSocket.on('chatcrack:producer-closed', (payload) => { 11563 try { 11564 if (!payload || !payload.producerId) return; 11565 const who = payload.userId || payload.username; 11566 if (who && typeof window._ccSameWebcamUser === 'function' 11567 && !window._ccSameWebcamUser(who, username)) return; 11568 const consumerKey = `consumeUserWebcam-${username}-${payload.producerId}`; 11569 if (this._consumedProducerKeys) this._consumedProducerKeys.delete(consumerKey); 11570 if (this._consumedProducerKinds) this._consumedProducerKinds.delete(consumerKey); 11571 if (window._pendingProducers) { 11572 // Must match the key tryConsumeProducer writes, or a 11573 // republish under the same id stays permanently "pending" 11574 // and every new-producer for it is skipped. 11575 window._pendingProducers.delete(`${username}_${payload.producerId}`); 11576 } 11577 const tile = (typeof window._ccFindWebcamDockTile === 'function') 11578 ? window._ccFindWebcamDockTile(username) : null; 11579 const video = tile && tile.querySelector('video'); 11580 const stream = video && video.srcObject; 11581 if (stream && stream.getTracks) { 11582 stream.getTracks().forEach((t) => { 11583 if (t && t.readyState === 'ended') { 11584 try { stream.removeTrack(t); } catch (_) {} 11585 } 11586 }); 11587 } 11588 } catch (_) {} 11589 }); 11590 11591 // Set up a timeout to show a helpful message if no producer appears 11592 const producerTimeout = setTimeout(() => { 11593 11594 11595 11596 }, 15000); 11597 11598 // Clear timeout when we successfully consume a producer 11599 const clearProducerTimeout = () => { 11600 clearTimeout(producerTimeout); 11601 11602 }; 11603 11604 // CRITICAL FIX: Since list-producers may not be supported, we need to rely on new-producer events 11605 // But if the producer already exists, we won't get a new-producer event 11606 // So let's try a different approach - check if the user is already streaming by looking at the room join response 11607 // Track if we consumed from join response to avoid duplicate consumption 11608 let consumedFromJoin = false; 11609 let consumedVideoFromJoin = false; 11610 11611 // Check if producer already exists in room join response 11612 if (joinPayload.producers && Array.isArray(joinPayload.producers)) { 11613 11614 // Look for video producer first (priority), then audio 11615 let videoProducer = null; 11616 let audioProducer = null; 11617 if (typeof window._ccPickRoomWebcamProducers === 'function') { 11618 const picked = window._ccPickRoomWebcamProducers(joinPayload.producers, username); 11619 videoProducer = picked.video; 11620 audioProducer = picked.audio; 11621 } else { 11622 for (const p of joinPayload.producers) { 11623 const samePub = (typeof window._ccSameWebcamUser === 'function') 11624 ? window._ccSameWebcamUser(p.userId, username) 11625 : (String(p.userId).toLowerCase() === String(username).toLowerCase()); 11626 if (samePub) { 11627 if (p.kind === 'video') { 11628 videoProducer = p; 11629 } else if (p.kind === 'audio') { 11630 audioProducer = p; 11631 } 11632 } 11633 } 11634 } 11635 11636 // Consume video first and wait â audio completing first left the 11637 // <video> bound to an audio-only stream, so addTrack(video) never 11638 // painted (black tile + kbps). 11639 if (videoProducer) { 11640 clearProducerTimeout(); 11641 try { 11642 await this._ccConsumeUserWebcamResilient(viewCtx, videoProducer.id, username, videoElement, viewDevice); 11643 consumedFromJoin = true; 11644 consumedVideoFromJoin = true; 11645 } catch (err) { 11646 const msg = String((err && err.message) || err || ''); 11647 if (!/transport closed|InvalidStateError|closed/i.test(msg)) { 11648 console.error(`[WebRTC] Failed to consume ${username}'s webcam:`, err); 11649 } 11650 } 11651 } 11652 11653 if (audioProducer) { 11654 // For audio, we need to create a separate consumer 11655 // The video element will handle both video and audio tracks 11656 11657 // CRITICAL: Check if video element already has a healthy audio track 11658 if (videoElement && videoElement.srcObject) { 11659 const existingAudioTracks = videoElement.srcObject.getAudioTracks() 11660 .filter((t) => t && t.readyState === 'live' && t.enabled !== false); 11661 if (existingAudioTracks.length > 0) { 11662 if (window.WEBCAM_DEBUG) { 11663 _chatLog(`[AUDIO DEBUG] â ï¸ Skipping audio consumption from room join - video element already has ${existingAudioTracks.length} audio track(s)`); 11664 } 11665 // Don't consume audio if we already have it, but mark as consumed 11666 consumedFromJoin = true; 11667 } else { 11668 try { 11669 await this._ccConsumeUserWebcamResilient(viewCtx, audioProducer.id, username, videoElement, viewDevice); 11670 } catch (err) { 11671 const msg = String((err && err.message) || err || ''); 11672 if (!/transport closed|InvalidStateError|closed/i.test(msg)) { 11673 console.error(`[WebRTC] Failed to consume ${username}'s webcam:`, err); 11674 } 11675 } 11676 consumedFromJoin = true; 11677 } 11678 } else { 11679 try { 11680 await this._ccConsumeUserWebcamResilient(viewCtx, audioProducer.id, username, videoElement, viewDevice); 11681 } catch (err) { 11682 const msg = String((err && err.message) || err || ''); 11683 if (!/transport closed|InvalidStateError|closed/i.test(msg)) { 11684 console.error(`[WebRTC] Failed to consume ${username}'s webcam:`, err); 11685 } 11686 } 11687 consumedFromJoin = true; 11688 } 11689 if (typeof window._ccEnsureDockWebcamAudioPlaying === 'function') { 11690 try { window._ccEnsureDockWebcamAudioPlaying(username, { skipIfNoTrack: false }); } catch (_) {} 11691 } 11692 } 11693 11694 if (!videoProducer && !audioProducer) { 11695 if (window.WEBCAM_DEBUG) { 11696 11697 } 11698 } 11699 } else { 11700 if (window.WEBCAM_DEBUG) { 11701 11702 } 11703 } 11704 11705 // Poll only when join did not already attach video. Re-consuming a 11706 // just-started video producer before the first IDR paints black. 11707 const paintedAtJoin = typeof window._ccDockTileHasPaintedVideo === 'function' 11708 && window._ccDockTileHasPaintedVideo(username); 11709 if (!consumedVideoFromJoin && (!consumedFromJoin || !paintedAtJoin)) { 11710 if (window.WEBCAM_DEBUG) { 11711 11712 } 11713 11714 // Retry logic: If no producers found initially, keep checking for up to 15 seconds 11715 let retryCount = 0; 11716 const maxRetries = 16; // ~20s with mixed delays â black fav docks need more than 15s 11717 const checkForProducers = () => { 11718 retryCount++; 11719 if (window.WEBCAM_DEBUG) { 11720 11721 } 11722 viewSocket.emit('chatcrack:list-producers', { roomId: webcamRoomId }, async (resp) => { 11723 if (window.WEBCAM_DEBUG) { 11724 11725 } 11726 if (resp && resp.ok && resp.list && resp.list.length) { 11727 if (window.WEBCAM_DEBUG) { 11728 11729 } 11730 11731 // DEDUPLICATION: Only consume the most recent audio and video producers for this user 11732 // Group by kind and keep only the most recent (assuming later in array = more recent) 11733 let videoProducer = null; 11734 let audioProducer = null; 11735 if (typeof window._ccPickRoomWebcamProducers === 'function') { 11736 const picked = window._ccPickRoomWebcamProducers(resp.list, username); 11737 videoProducer = picked.video; 11738 audioProducer = picked.audio; 11739 } else { 11740 const latestVideo = []; 11741 const latestAudio = []; 11742 11743 for (var i=0;i<resp.list.length;i++){ 11744 var p = resp.list[i]; 11745 var samePub = (typeof window._ccSameWebcamUser === 'function') 11746 ? window._ccSameWebcamUser(p.userId, username) 11747 : (String(p.userId).toLowerCase() === String(username).toLowerCase()); 11748 if (samePub) { 11749 if (p.kind === 'video') { 11750 latestVideo.push(p); 11751 } else if (p.kind === 'audio') { 11752 latestAudio.push(p); 11753 } 11754 } 11755 } 11756 videoProducer = latestVideo.length > 0 ? latestVideo[latestVideo.length - 1] : null; 11757 audioProducer = latestAudio.length > 0 ? latestAudio[latestAudio.length - 1] : null; 11758 } 11759 11760 if (videoProducer) { 11761 if (window.WEBCAM_DEBUG) { 11762 11763 } 11764 clearProducerTimeout(); 11765 try { 11766 await this._ccConsumeUserWebcamResilient(viewCtx, videoProducer.id, username, videoElement, viewDevice); 11767 } catch (err) { 11768 console.error(`[WebRTC] Failed to consume ${username}'s webcam:`, err); 11769 } 11770 } 11771 11772 if (audioProducer) { 11773 if (!videoProducer) clearProducerTimeout(); 11774 const existingAudioTracks = (videoElement && videoElement.srcObject) 11775 ? videoElement.srcObject.getAudioTracks() 11776 .filter((t) => t && t.readyState === 'live' && t.enabled !== false) 11777 : []; 11778 if (existingAudioTracks.length === 0) { 11779 try { 11780 await this._ccConsumeUserWebcamResilient(viewCtx, audioProducer.id, username, videoElement, viewDevice); 11781 } catch (err) { 11782 console.error(`[WebRTC] Failed to consume ${username}'s webcam:`, err); 11783 } 11784 } 11785 } 11786 11787 if (!videoProducer && !audioProducer && resp.list.length > 0) { 11788 if (window.WEBCAM_DEBUG) { 11789 11790 } 11791 } 11792 const paintedNow = typeof window._ccDockTileHasPaintedVideo === 'function' 11793 && window._ccDockTileHasPaintedVideo(username); 11794 const hasAudioNow = !!(videoElement && videoElement.srcObject 11795 && videoElement.srcObject.getAudioTracks 11796 && videoElement.srcObject.getAudioTracks().some(function (t) { 11797 return t && t.readyState === 'live' && t.enabled !== false; 11798 })); 11799 if (paintedNow && !hasAudioNow && audioProducer 11800 && typeof window._ccRolloverRepairDockAudio === 'function') { 11801 try { window._ccRolloverRepairDockAudio(username); } catch (_) {} 11802 } 11803 if (typeof window._ccEnsureDockWebcamAudioPlaying === 'function') { 11804 try { window._ccEnsureDockWebcamAudioPlaying(username, { skipIfNoTrack: false, once: true }); } catch (_) {} 11805 } 11806 if ((!paintedNow || !hasAudioNow) && retryCount < maxRetries) { 11807 setTimeout(checkForProducers, retryCount < 8 ? 800 : 1500); 11808 } 11809 } else { 11810 if (window.WEBCAM_DEBUG) { 11811 11812 } 11813 // Green icon / active set means they are live. An empty 11814 // list is a join/casing miss â retry fast, do not show 11815 // "Publisher not streaming" over a live cam. 11816 var stillLive = (typeof window._ccActiveWebcamHas === 'function' && window._ccActiveWebcamHas(username)) 11817 || (typeof window._ccIsUserAuthoritativelyLive === 'function' && window._ccIsUserAuthoritativelyLive(username)); 11818 if (!stillLive && retryCount >= 2 && typeof window._ccNoteCamError === 'function') { 11819 window._ccNoteCamError(username, 'Publisher not streaming'); 11820 } 11821 if (stillLive && retryCount === 2) { 11822 try { viewSocket.emit('chatcrack:join-room', { roomId: webcamRoomId }); } catch (_) {} 11823 } 11824 if (retryCount < maxRetries) { 11825 setTimeout(checkForProducers, stillLive ? 400 : 1500); 11826 } else { 11827 if (window.WEBCAM_DEBUG) { 11828 11829 } 11830 } 11831
11831 } 11832 }); 11833 }; 11834 11835 // Start checking for producers 11836 checkForProducers(); 11837 11838 // Add timeout for list-producers call 11839 setTimeout(() => { 11840 if (window.WEBCAM_DEBUG) { 11841 11842 } 11843 }, 5000); 11844 } else { 11845 if (window.WEBCAM_DEBUG) { 11846 11847 } 11848 } 11849 11850 // Store the viewing connection for cleanup 11851 this.viewingConnections = this.viewingConnections || new Map(); 11852 this.viewingConnections.set(username, { socket: viewSocket, transport: viewRecvTransport, device: viewDevice }); 11853 ownedViewSocket = null; 11854 11855 } catch (error) { 11856 // Only tear down a socket this call opened. Reused viewer sockets are 11857 // owned by remoteWebcamViewers and get closed with the dock. 11858 if (ownedViewSocket) { 11859 try { ownedViewSocket.disconnect(); } catch (_) {} 11860 ownedViewSocket = null; 11861 } 11862 try { 11863 const why = String((error && error.message) || error || 'viewer setup failed'); 11864 if (typeof window._ccNoteCamError === 'function') window._ccNoteCamError(username, why); 11865 if (window.ccCamPerf) window.ccCamPerf.mark(username, 'STREAM_ERROR', why); 11866 } catch (_) {} 11867 // Suppress WebSocket errors since webcam system works anyway 11868 if (!error?.message?.includes('websocket error')) { 11869 console.error(`[WebRTC] Failed to view ${username}'s webcam:`, error); 11870 } 11871 throw error; 11872 } 11873 } 11874 11875 async consumeUserWebcam(socket, transport, producerId, username, videoElement, device) { 11876 try { 11877 // HARD GUARD: never treat our local publisher stream as a remote consume success. 11878 const me = window.currentUsername || (window.me && window.me.username); 11879 const isSelf = !!(me && username && typeof window._ccSameWebcamUser === 'function' 11880 && window._ccSameWebcamUser(username, me)); 11881 if (videoElement && videoElement.srcObject 11882 && typeof window._ccIsLocalPublisherStream === 'function' 11883 && window._ccIsLocalPublisherStream(videoElement.srcObject) 11884 && !isSelf) { 11885 if (!(typeof window._ccMediaHoldActive === 'function' && window._ccMediaHoldActive()) 11886 && !(typeof window._ccMediaRolloverOverlapActive === 'function' && window._ccMediaRolloverOverlapActive()) 11887 && !videoElement._ccHoldLastFrame) { 11888 console.warn('[CAM_IDENTITY] Clearing leaked local stream before remote consume for', username); 11889 try { videoElement.srcObject = null; } catch (_) {} 11890 if (this._consumedProducerKeys) { 11891 this._consumedProducerKeys.delete(`consumeUserWebcam-${username}-${producerId}`); 11892 } 11893 } 11894 } 11895 11896 // CRITICAL: Check if video element already has tracks BEFORE doing anything 11897 // This prevents creating consumers unnecessarily 11898 if (videoElement && videoElement.srcObject) { 11899 const stream = videoElement.srcObject; 11900 const audioTracks = stream.getAudioTracks(); 11901 const videoTracks = stream.getVideoTracks(); 11902 11903 // Determine what kind of track we're trying to consume 11904 // We'll check this after we get the kind from the server response 11905 // But first, let's check if we already have the producer consumed 11906 } 11907 11908 // CRITICAL: Check if we're already consuming this specific producer to prevent duplicates 11909 const consumerKey = `consumeUserWebcam-${username}-${producerId}`; 11910 if (!this._consumedProducerKeys) { 11911 this._consumedProducerKeys = new Set(); 11912 } 11913 11914 // Check if we're already consuming this producer 11915 if (this._consumedProducerKeys.has(consumerKey)) { 11916 // CRITICAL FIX: Only skip if the video element actually has a working REMOTE stream 11917 // If the stream is gone (e.g., dock was closed and reopened), allow re-consumption 11918 if (videoElement && videoElement.srcObject) { 11919 const stream = videoElement.srcObject; 11920 // Never early-return if this is our local publisher on a non-self tile 11921 if (!isSelf && typeof window._ccIsLocalPublisherStream === 'function' 11922 && window._ccIsLocalPublisherStream(stream)) { 11923 try { videoElement.srcObject = null; } catch (_) {} 11924 this._consumedProducerKeys.delete(consumerKey); 11925 } else { 11926 const audioTracks = stream.getAudioTracks(); 11927 const videoTracks = stream.getVideoTracks(); 11928 11929 // Check if tracks are actually active (not ended) 11930 const activeAudioTracks = audioTracks.filter(t => t.readyState === 'live'); 11931 const activeVideoTracks = videoTracks.filter(t => t.readyState === 'live'); 11932 11933 if (window.WEBCAM_DEBUG) { 11934 _chatLog(`[AUDIO DEBUG] Stream check - audio: ${audioTracks.length} (${activeAudioTracks.length} active), video: ${videoTracks.length} (${activeVideoTracks.length} active)`); 11935 } 11936 11937 // Only the kind this producer actually feeds counts as "still 11938 // working". Treating any live track as proof meant a dead video 11939 // producer was never re-consumed as long as audio was alive â 11940 // the tile went black while the user could still be heard. 11941 // 11942 // An UNKNOWN kind is not evidence of anything: the kind is only 11943 // recorded after the server acks, so a key with no kind is the 11944 // leftover of a consume that failed. Counting the live audio 11945 // track on the same <video> as proof of health is what pinned a 11946 // publisher to "mic works, picture never comes back" until they 11947 // restarted their camera.
11948 const knownKind = this._consumedProducerKinds 11949 ? this._consumedProducerKinds.get(consumerKey) 11950 : null; 11951 const stillLive = knownKind === 'video' 11952 ? activeVideoTracks.length > 0 11953 : (knownKind === 'audio' ? activeAudioTracks.length > 0 : false); 11954 11955 // Live-but-black video (muted RTP / never painted) must re-consume. 11956 let camOff = false; 11957 try { 11958 camOff = !!(window.chatCrackWebcam 11959 && typeof window.chatCrackWebcam._isUserCameraOff === 'function' 11960 && window.chatCrackWebcam._isUserCameraOff(username)); 11961 } catch (_) {} 11962 const videoUnhealthy = !camOff && knownKind === 'video' 11963 && (videoElement.videoWidth | 0) === 0 11964 && !(videoElement.srcObject && videoElement.srcObject.getVideoTracks 11965 && videoElement.srcObject.getVideoTracks().some(function (t) { 11966 return t && t.readyState === 'live' && t.enabled !== false; 11967 })); 11968 11969 // If we have active healthy tracks, skip consumption 11970 if (stillLive && !videoUnhealthy) { 11971 if (window.WEBCAM_DEBUG) { 11972 _chatLog(`[AUDIO DEBUG] â Video element already has active tracks, skipping duplicate consumption`); 11973 } 11974 return; 11975 } else { 11976 // Tracks ended or zombie-black â clear the key and allow re-consumption 11977 _chatLog(`[AUDIO DEBUG] â ï¸ Producer key exists but tracks unhealthy/ended, clearing key and allowing re-consumption`); 11978 this._consumedProducerKeys.delete(consumerKey); 11979 } 11980 } 11981 } else { 11982 // No video element or no stream - clear the key and allow re-consumption 11983 _chatLog(`[AUDIO DEBUG] â ï¸ Producer key exists but no video element or stream, clearing key and allowing re-consumption`); 11984 this._consumedProducerKeys.delete(consumerKey); 11985 } 11986 } 11987 11988 this._consumedProducerKeys.add(consumerKey); 11989 _chatLog(`[AUDIO DEBUG] â Adding consumer key: ${consumerKey} for producer ${producerId}`); 11990 11991 11992 11993 // Verify device is properly loaded 11994 if (!device || !device.loaded) { 11995 throw new Error(`Device not properly loaded. loaded=${device?.loaded}, device=${!!device}`); 11996 } 11997 11998 if (!getDeviceRecvRtpCapabilities(device)) { 11999 throw new Error('Device RTP capabilities not available'); 12000 } 12001 12002 // Verify transport is ready â dock may have been closed/rebuilt mid-event. 12003 if (!transport || transport.closed) { 12004 this._consumedProducerKeys.delete(consumerKey); 12005 console.warn(`[WebRTC] Skip consume ${username}: recv transport closed (will retry on next producer/reconnect)`); 12006 return; 12007 } 12008 12009 // Verify video element exists 12010 if (!videoElement) { 12011 throw new Error('Video element not provided'); 12012 } 12013 12014 12015 12016 12017 12018 // CRITICAL: Check if video element already has tracks BEFORE requesting consumer from server 12019 // This prevents unnecessary server requests and consumer creation 12020 const existingStream = videoElement.srcObject; 12021 if (existingStream) { 12022 const audioTracks = existingStream.getAudioTracks(); 12023 const videoTracks = existingStream.getVideoTracks(); 12024 12025 // If we already have both audio and video tracks, we probably don't need to consume again 12026 // But we can't know the kind until after the server response, so we'll check after 12027 // However, if we have tracks and the duplicate check passed, we should be good 12028 _chatLog(`[AUDIO DEBUG] Pre-check: stream has ${audioTracks.length} audio, ${videoTracks.length} video tracks`); 12029 } 12030 12031 // Create consumer for this specific producer 12032 const { id, kind, rtpParameters, producerPaused } = await new Promise((resolve, reject) => { 12033 // Fail fast: a consume that has not been acked in 12s is not 12034 // coming back, and the healer can only redial once this 12035 // settles. 45s meant one bad attempt held a black tile. 12036 const timeout = setTimeout(() => { 12037 reject(new Error('Consumer creation timeout')); 12038 }, 12000); 12039 12040 12041 12042 const consumeRoomId = (typeof window._ccWebcamRoomId === 'function') 12043 ? window._ccWebcamRoomId(username) 12044 : ('chatcrack_webcam_' + String(username).toLowerCase()); 12045 const consumeBaseUser = window.currentUsername || window.me?.username || 'viewer'; 12046 const consumeUserId = `${consumeBaseUser}#viewer#${username}`; 12047 socket.emit("chatcrack:consume", { 12048 roomId: consumeRoomId, 12049 userId: consumeUserId, 12050 transportId: transport.id, 12051 rtpCapabilities: getDeviceRecvRtpCapabilities(device), 12052 producerId: producerId 12053 }, (response) => { 12054 clearTimeout(timeout); 12055 if (response && response.ok) { 12056 if (window.ccCamPerf) { 12057 window.ccCamPerf.mark(username, 'STREAM_URL_RECEIVED', response.kind); 12058 } 12059 resolve(response); 12060 } else { 12061 if (window.ccCamPerf) { 12062 window.ccCamPerf.mark(username, 'STREAM_ERROR', 12063 (response && response.message) || 'consume failed'); 12064 } 12065 reject(new Error(`Server error: ${response?.message || 'Consumer creation failed'}`)); 12066 } 12067 }); 12068 }); 12069 12070 // CRITICAL: Always get the stream from the video element to ensure we use the same stream reference 12071 // This prevents creating multiple streams and interrupting playback 12072 let stream = videoElement.srcObject; 12073 12074 // HARD GUARD: never add remote tracks into the local publisher MediaStream 12075 if (stream && typeof window._ccIsLocalPublisherStream === 'function' 12076 && window._ccIsLocalPublisherStream(stream) && !isSelf) { 12077 console.warn('[CAM_IDENTITY] Detaching local publisher stream before remote track attach for', username); 12078 try { videoElement.srcObject = null; } catch (_) {} 12079 stream = null; 12080 } 12081 12082 if (!this._consumedProducerKinds) this._consumedProducerKinds = new Map(); 12083 this._consumedProducerKinds.set(consumerKey, kind); 12084 12085 // CRITICAL: Check if stream already has tracks of this kind BEFORE creating consumer 12086 // This prevents creating unnecessary consumers that will just be removed 12087 if (stream) { 12088 // Ended tracks are corpses left behind by a closed consumer (producer 12089 // restart, ICE blip). Counting them here made this bail out before 12090 // attaching the replacement track, so the tile stayed black forever 12091 // even though the guard above had explicitly cleared the way.
12092 stream.getTracks().forEach((t) => { 12093 if (t.kind === kind && t.readyState === 'ended') { 12094 try { stream.removeTrack(t); } catch (_) {} 12095 } 12096 }); 12097 const existingTracksOfKind = stream.getTracks() 12098 .filter(t => t.kind === kind && t.readyState === 'live'); 12099 if (existingTracksOfKind.length > 0) { 12100 // Cam Off / BRB: black is expected â keep the existing track. 12101 let camOff = false; 12102 try { 12103 camOff = !!(window.chatCrackWebcam 12104 && typeof window.chatCrackWebcam._isUserCameraOff === 'function' 12105 && window.chatCrackWebcam._isUserCameraOff(username)); 12106 } catch (_) {} 12107 // A "live" video track can still be a zombie (never painted). 12108 // Do NOT treat brief RTP mute as dead while frames are on screen â 12109 // that replaced a working consumer and flashed the dock black. 12110 const videoZombie = !camOff && kind === 'video' 12111 && videoElement && (videoElement.videoWidth | 0) === 0; 12112 const audioZombie = kind === 'audio' && existingTracksOfKind.every((t) => 12113 !t || t.readyState !== 'live' || t.enabled === false); 12114 if (!videoZombie && !audioZombie) { 12115 const overlapActive = typeof window._ccMediaRolloverOverlapActive === 'function' 12116 && window._ccMediaRolloverOverlapActive(); 12117 const overlapSwap = overlapActive && ( 12118 (kind === 'video' 12119 && videoElement._ccVideoProducerId 12120 && String(videoElement._ccVideoProducerId) !== String(producerId)) 12121 || (kind === 'audio' 12122 && existingTracksOfKind.length > 0 12123 && (!videoElement._ccAudioProducerId 12124 || String(videoElement._ccAudioProducerId) !== String(producerId))) 12125 ); 12126 if (!overlapSwap) { 12127 if (window.WEBCAM_DEBUG) { 12128 _chatLog(`[AUDIO DEBUG] Stream already has ${existingTracksOfKind.length} ${kind} track(s), skipping duplicate consumer creation`); 12129 } 12130 // Server already created a consumer â close the orphan. 12131 try { 12132 const closeRoomId = (typeof window._ccWebcamRoomId === 'function') 12133 ? window._ccWebcamRoomId(username) 12134 : ('chatcrack_webcam_' + String(username).toLowerCase()); 12135 socket.emit('chatcrack:close-consumer', { roomId: closeRoomId, consumerId: id }); 12136 } catch (_) {} 12137 return; 12138 } 12139 } else { 12140 if (window.WEBCAM_DEBUG) { 12141 _chatLog(`[AUDIO DEBUG] Replacing unhealthy ${kind} track(s) for ${username}`); 12142 } 12143 existingTracksOfKind.forEach((t) => { 12144 try { stream.removeTrack(t); } catch (_) {} 12145 try { t.stop(); } catch (_) {} 12146 }); 12147 try { 12148 const viewer = window.remoteWebcamViewers && window.remoteWebcamViewers[username]; 12149 if (viewer && viewer.consumers instanceof Map) { 12150 viewer.consumers.forEach((c, key) => { 12151 if (c && !c.closed && (c.kind === kind || String(key).startsWith(kind))) { 12152 try { c.close(); } catch (_) {} 12153 try { viewer.consumers.delete(key); } catch (_) {} 12154 } 12155 }); 12156 } 12157 } catch (_) {} 12158 } 12159 } 12160 } 12161 12162 if (!transport || transport.closed) {
12163 throw new Error('Transport closed'); 12164 } 12165 let consumer; 12166 try { 12167 consumer = await transport.consume({ id, producerId, kind, rtpParameters }); 12168 } catch (consumeErr) { 12169 const cmsg = String((consumeErr && consumeErr.message) || consumeErr || ''); 12170 if (/closed|InvalidStateError/i.test(cmsg) || (consumeErr && consumeErr.name === 'InvalidStateError')) { 12171 // Viewer was torn down mid-consume (fav health refresh storm) â quiet. 12172 throw new Error('Transport closed'); 12173 } 12174 throw consumeErr; 12175 } 12176 try { 12177 const viewerCtx = window.remoteWebcamViewers && window.remoteWebcamViewers[username]; 12178 if (viewerCtx && viewerCtx.consumers instanceof Map) { 12179 viewerCtx.consumers.set(`${kind}-${producerId}`, consumer); 12180 if (kind === 'video') { 12181 const overlapKeepOld = typeof window._ccMediaRolloverOverlapActive === 'function' 12182 && window._ccMediaRolloverOverlapActive(); 12183 if (!overlapKeepOld) { 12184 viewerCtx.consumers.forEach((c, key) => { 12185 if (!c || c === consumer || c.closed || c.kind !== 'video') return; 12186 if (c.producerId && String(c.producerId) === String(producerId)) return; 12187 try { c.close(); } catch (_) {} 12188 try { viewerCtx.consumers.delete(key); } catch (_) {} 12189 }); 12190 } 12191 } 12192 } 12193 } catch (_) {} 12194 if (kind === 'video') { 12195 try { 12196 videoElement._ccVideoConsumer = consumer; 12197 videoElement._ccVideoConsumerId = id; 12198 videoElement._ccVideoProducerId = producerId; 12199 videoElement._ccVideoSocket = socket; 12200 videoElement._ccVideoRoomId = consumeRoomId; 12201 videoElement._ccVideoUsername = username; 12202 if (typeof window._ccNoteRemoteCamVideo === 'function') { 12203 window._ccNoteRemoteCamVideo(videoElement); 12204 } 12205 if (typeof window._ccCancelWebcamProducerCloseGrace === 'function') { 12206 window._ccCancelWebcamProducerCloseGrace(username); 12207 } 12208 } catch (_) {} 12209 } 12210 12211 if (kind === 'audio') { 12212 try { videoElement._ccAudioProducerId = producerId; } catch (_) {} 12213 this._stabilizeRemoteAudioPlayoutSoon(transport, consumer.track); 12214 this._bindRemoteMicUnmuteNudge(consumer, username); 12215 } 12216 12217 // When the remote producer closes (e.g. connection blip), wait a beat 12218 // before overlaying â a republish/ICE gap used to flash the dock black. 12219 consumer.on('producerclose', () => { 12220 // Mic re-produce closes audio only â do not flash "disconnected" on the cam. 12221 if (kind === 'audio') { 12222 try { 12223 const s = videoElement && videoElement.srcObject; 12224 const tid = consumer.track && consumer.track.id; 12225 if (s && s.getAudioTracks) { 12226 s.getAudioTracks().forEach((t) => { 12227 if (!t || (tid && t.id === tid) || t.readyState === 'ended') { 12228 try { s.removeTrack(t); } catch (_) {} 12229 } 12230 }); 12231 } 12232 } catch (_) {} 12233 return; 12234 } 12235 const closedPid = consumer.producerId || producerId; 12236 if (videoElement && videoElement._ccVideoProducerId 12237 && String(videoElement._ccVideoProducerId) !== String(closedPid)) { 12238 return; 12239 } 12240 console.log('[CAM_CLOSE_DEBUG] Consumer producerclose for', username, '- grace before overlay'); 12241 if (typeof window._ccScheduleWebcamProducerCloseGrace === 'function') { 12242 window._ccScheduleWebcamProducerCloseGrace(username, { producerId: closedPid }); 12243 return; 12244 } 12245 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.showStreamDisconnectedState === 'function') { 12246 window.chatCrackWebcam.showStreamDisconnectedState(username); 12247 } else if (window.chatCrackWebcam && typeof window.chatCrackWebcam.removeDockedWebcam === 'function') { 12248 window.chatCrackWebcam.removeDockedWebcam(username, 'producer_closed'); 12249 } 12250 }); 12251 12252 // Client Consumer starts paused. Server resume alone leaves black tiles 12253 // with bitrate ticking â resume BOTH, then keyframe. 12254 try { 12255 const resumeRoomId = (typeof window._ccWebcamRoomId === 'function') 12256 ? window._ccWebcamRoomId(username) 12257 : ('chatcrack_webcam_' + String(username).toLowerCase()); 12258 const resumeNow = function () { 12259 if (typeof window._ccResumeWebcamConsumer === 'function') { 12260 window._ccResumeWebcamConsumer(consumer, socket, resumeRoomId, id); 12261 } else { 12262 try { consumer.resume().catch(function () {}); } catch (_) {} 12263 try { socket.emit('chatcrack:resume-consumer', { roomId: resumeRoomId, consumerId: id }, function () {}); } catch (_) {} 12264 } 12265 if (kind === 'video' && typeof window._ccAskDockKeyframeBurst === 'function') { 12266 window._ccAskDockKeyframeBurst(socket, resumeRoomId, id, username); 12267 } 12268 }; 12269 resumeNow(); 12270 try { consumer.on('producerresume', resumeNow); } catch (_) {} 12271 if (producerPaused === true && window.WEBCAM_DEBUG) { 12272 _chatLog('[AUDIO DEBUG] consumed paused mic for', username, 'â waiting for unmute'); 12273 } 12274 } catch (_) {} 12275 12276 // CRITICAL FIX: Always get the stream from video element (don't create new one) 12277 // This ensures both video and audio tracks are added to the SAME stream 12278 stream = videoElement.srcObject; 12279 if (stream && typeof window._ccIsLocalPublisherStream === 'function' 12280 && window._ccIsLocalPublisherStream(stream) && !isSelf) { 12281 try { videoElement.srcObject = null; } catch (_) {} 12282 stream = null; 12283 } 12284 12285 // DEBUG: Log stream state before creating/using 12286 _chatLog(`[AUDIO DEBUG] ð Stream state check for ${username}:`, { 12287 hasStream: !!stream,
12288 streamId: stream?.id, 12289 currentTracks: stream ? stream.getTracks().map(t => ({ kind: t.kind, id: t.id, enabled: t.enabled })) : [], 12290 videoElementInDOM: document.body.contains(videoElement), 12291 videoElementSrcObject: !!videoElement.srcObject, 12292 videoElementSrcObjectId: videoElement.srcObject?.id 12293 }); 12294 12295 // Get or create the combined stream for this video element 12296 if (!stream) { 12297 // CRITICAL: Before creating a new stream, check if there's an orphaned stream somewhere 12298 // This can happen if the video element was removed and recreated but the stream still exists 12299 // Only ever harvest an orphaned stream from THIS publisher's own 12300 // tiles. A case-sensitive attribute selector could miss them, 12301 // and any broader query risks adopting another user's stream. 12302 const allVideoElements = Array.from(document.querySelectorAll('.webcam-dock[data-username] video')) 12303 .filter((el) => { 12304 const tile = el.closest('.webcam-dock'); 12305 return tile && window._ccSameWebcamUser(tile.getAttribute('data-username'), username); 12306 }); 12307 let orphanedStream = null; 12308 for (const el of allVideoElements) { 12309 if (el !== videoElement && el.srcObject && el.srcObject.getTracks().length > 0) { 12310 if (typeof window._ccIsLocalPublisherStream === 'function' 12311 && window._ccIsLocalPublisherStream(el.srcObject) && !isSelf) { 12312 try { el.srcObject = null; } catch (_) {} 12313 continue; 12314 } 12315 _chatLog(`[AUDIO DEBUG] ð Found orphaned stream on another video element for ${username}:`, { 12316 element: el, 12317 streamId: el.srcObject.id, 12318 tracks: el.srcObject.getTracks().map(t => ({ kind: t.kind, id: t.id })) 12319 }); 12320 orphanedStream = el.srcObject; 12321 break; 12322 } 12323 } 12324 12325 if (orphanedStream) { 12326 _chatLog(`[AUDIO DEBUG] â»ï¸ Reusing orphaned stream for ${username} instead of creating new one`); 12327 stream = orphanedStream; 12328 videoElement.srcObject = stream; 12329 } else { 12330 stream = new MediaStream(); 12331 videoElement.srcObject = stream; 12332 _chatLog(`[AUDIO DEBUG] ð Created new MediaStream for ${username} video element`); 12333 } 12334 } else { 12335 _chatLog(`[AUDIO DEBUG] â»ï¸ Reusing existing stream for ${username} - current tracks:`, 12336 stream.getTracks().map(t => ({ kind: t.kind, id: t.id }))); 12337 } 12338 12339 // DEBUG: Log final stream state 12340 _chatLog(`[AUDIO DEBUG] ð Final stream state for ${username}:`, { 12341 streamId: stream.id, 12342 tracks: stream.getTracks().map(t => ({ kind: t.kind, id: t.id, enabled: t.enabled })), 12343 videoElementSrcObject: videoElement.srcObject?.id, 12344 streamsMatch: stream.id === videoElement.srcObject?.id 12345 }); 12346 12347 // CRITICAL: Check if there are multiple video elements for this user 12348 const allVideoElements = document.querySelectorAll(`.webcam-dock[data-username="${CSS.escape(username)}"] video`); 12349 if (allVideoElements.length > 1) { 12350 console.error(`[AUDIO DEBUG] â MULTIPLE VIDEO ELEMENTS DETECTED for ${username}! Found ${allVideoElements.length} video elements.`); 12351 allVideoElements.forEach((el, idx) => { 12352 _chatLog(`[AUDIO DEBUG] Video element ${idx + 1}:`, { 12353 id: el.id, 12354 srcObject: !!el.srcObject, 12355 tracks: el.srcObject ? el.srcObject.getTracks().length : 0, 12356 audioTracks: el.srcObject ? el.srcObject.getAudioTracks().length : 0 12357 }); 12358 }); 12359 } 12360 12361 // CRITICAL: Check if this exact track is already in the stream 12362 const existingTrack = stream.getTracks().find(t => t.id === consumer.track.id); 12363 if (existingTrack) { 12364 console.warn(`[AUDIO DEBUG] â ï¸ DUPLICATE DETECTED: Track ${consumer.track.id} (${kind}) already exists in stream for ${username}! Skipping add.`); 12365 _chatLog(`[AUDIO DEBUG] Current stream tracks:`, stream.getTracks().map(t => ({ kind: t.kind, id: t.id, enabled: t.enabled }))); 12366 if (kind === 'video' && videoElement && (videoElement.videoWidth | 0) === 0) { 12367 if (typeof window._ccBindLiveVideoToElement === 'function') { 12368 window._ccBindLiveVideoToElement(videoElement, stream); 12369 } 12370 if (typeof window._ccResumeWebcamConsumer === 'function') { 12371 const resumeRoomId = (typeof window._ccWebcamRoomId === 'function') 12372 ? window._ccWebcamRoomId(username) 12373 : ('chatcrack_webcam_' + String(username).toLowerCase()); 12374 window._ccResumeWebcamConsumer(consumer, socket, resumeRoomId, id); 12375 } 12376 } 12377 return; // Don't add duplicate track 12378 } 12379 12380 // Add the new track FIRST, then retire old same-kind tracks. 12381 // Stopping the old video track before addTrack paints the tile black. 12382 _chatLog(`[AUDIO DEBUG] â Adding ${kind} track ${consumer.track.id} to stream for ${username}`); 12383 _chatLog(`[AUDIO DEBUG] Stream before add - tracks:`, stream.getTracks().map(t => ({ kind: t.kind, id: t.id })));
12384 const overlapActive = typeof window._ccMediaRolloverOverlapActive === 'function' 12385 && window._ccMediaRolloverOverlapActive(); 12386 const overlapVideoSwap = kind === 'video' 12387 && overlapActive 12388 && (videoElement.videoWidth | 0) > 0 12389 && stream.getVideoTracks().some((t) => t && t.readyState === 'live' && t.id !== consumer.track.id); 12390 if (overlapVideoSwap && typeof window._ccSwapRemoteVideoWhenPainted === 'function') { 12391 const next = new MediaStream(); 12392 try { next.addTrack(consumer.track); } catch (_) {} 12393 stream.getAudioTracks().forEach((t) => { 12394 if (t && t.readyState === 'live') { 12395 try { next.addTrack(t); } catch (_) {} 12396 } 12397 }); 12398 window._ccSwapRemoteVideoWhenPainted(videoElement, next, function () { 12399 try { 12400 const viewerCtx = window.remoteWebcamViewers && window.remoteWebcamViewers[username]; 12401 if (viewerCtx && viewerCtx.consumers instanceof Map) { 12402 viewerCtx.consumers.forEach((c, key) => { 12403 if (!c || c === consumer || c.closed || c.kind !== 'video') return; 12404 try { c.close(); } catch (_) {} 12405 try { viewerCtx.consumers.delete(key); } catch (_) {} 12406 }); 12407 } 12408 } catch (_) {} 12409 if (typeof window._ccEnsureDockWebcamAudioPlaying === 'function') { 12410 try { window._ccEnsureDockWebcamAudioPlaying(username, { skipIfNoTrack: false }); } catch (_) {} 12411 } 12412 }); 12413 } else if (kind === 'audio' && overlapActive) { 12414 if (typeof window._ccAttachTrackToRolloverStreams === 'function') { 12415 window._ccAttachTrackToRolloverStreams(videoElement, consumer.track); 12416 } else { 12417 try { stream.addTrack(consumer.track); } catch (_) {} 12418 } 12419 setTimeout(function () { 12420 const streams = []; 12421 try { if (videoElement.srcObject) streams.push(videoElement.srcObject); } catch (_) {} 12422 try { if (videoElement._ccRolloverNextStream) streams.push(videoElement._ccRolloverNextStream); } catch (_) {} 12423 streams.forEach(function (s) { 12424 if (!s || !s.getAudioTracks) return; 12425 const live = s.getAudioTracks().filter(function (t) { 12426 return t && t.readyState === 'live'; 12427 }); 12428 if (live.length < 2) return; 12429 live.forEach(function (t) { 12430 if (t.id === consumer.track.id) return; 12431 try { s.removeTrack(t); } catch (_) {} 12432 }); 12433 }); 12434 }, 1500); 12435 } else { 12436 stream.addTrack(consumer.track); 12437 _chatLog(`[AUDIO DEBUG] Stream after add - tracks:`, stream.getTracks().map(t => ({ kind: t.kind, id: t.id }))); 12438 12439 const oldTracks = stream.getTracks().filter((track) => 12440 track && track.kind === kind && track.id !== consumer.track.id); 12441 if (oldTracks.length > 0) { 12442 oldTracks.forEach((track) => { 12443 _chatLog(`[AUDIO DEBUG] ð§¹ Retiring old ${kind} track:`, track.id, 'for', username); 12444 try { stream.removeTrack(track); } catch (_) {} 12445 try { track.stop(); } catch (_) {} 12446 }); 12447 } 12448 if (kind === 'video' && typeof window._ccBindLiveVideoToElement === 'function') { 12449 window._ccBindLiveVideoToElement(videoElement, stream); 12450 } 12451 } 12452 12453 // CRITICAL: Verify we don't have duplicate audio tracks 12454 const audioTracks = stream.getAudioTracks(); 12455 if (audioTracks.length >
12455 1 && !(typeof window._ccMediaRolloverOverlapActive === 'function' && window._ccMediaRolloverOverlapActive())) { 12456 console.error(`[AUDIO DEBUG] â DUPLICATE AUDIO TRACKS DETECTED for ${username}! Found ${audioTracks.length} audio tracks:`, 12457 audioTracks.map(t => ({ id: t.id, enabled: t.enabled, readyState: t.readyState }))); 12458 // Remove all but the most recent audio track 12459 const tracksToRemove = audioTracks.slice(0, -1); 12460 tracksToRemove.forEach(track => { 12461 _chatLog(`[AUDIO DEBUG] ð§¹ Removing duplicate audio track:`, track.id); 12462 track.stop(); 12463 stream.removeTrack(track); 12464 }); 12465 } 12466 12467 // A remote audio track just arrived (mic turned on / late audio) â 12468 // the video tile may still be muted from an autoplay fallback, so 12469 // force it to play unmuted. Without this the publisher's mic is on 12470 // but the viewer hears nothing until they reopen the cam. 12471 if (kind === 'audio' && typeof window._ccEnsureDockWebcamAudioPlaying === 'function') { 12472 try { window._ccEnsureDockWebcamAudioPlaying(username, { skipIfNoTrack: false }); } catch (_) {} 12473 } 12474 // Re-apply viewer volume / boost (never detaches tracks). 12475 if (kind === 'audio' && window.chatCrackWebcam && typeof window.chatCrackWebcam._applyCamMicBoost === 'function') { 12476 try { 12477 let pct = 100; 12478 if (typeof window.ccGetCamUserVolPct === 'function') pct = window.ccGetCamUserVolPct(username); 12479 else { 12480 const raw = localStorage.getItem('cc_cam_user_vol_' + String(username || '').toLowerCase()); 12481 if (raw != null && raw !== '') pct = parseInt(raw, 10) || 100; 12482 } 12483 window.chatCrackWebcam._applyCamMicBoost(videoElement, username, pct); 12484 } catch (_) {} 12485 } 12486 // Rebind viewer VU meter now that the mic track exists. 12487 if (kind === 'audio' && typeof window._ccRefreshDockAudioLevelForUser === 'function') { 12488 try { window._ccRefreshDockAudioLevelForUser(username); } catch (_) {} 12489 if (typeof window._ccMediaRolloverOverlapActive === 'function' 12490 && window._ccMediaRolloverOverlapActive()) { 12491 setTimeout(function () { 12492 try { window._ccRefreshDockAudioLevelForUser(username); } catch (_) {} 12493 }, 400); 12494 setTimeout(function () { 12495 try { window._ccRefreshDockAudioLevelForUser(username); } catch (_) {} 12496 }, 1800); 12497 } 12498 } 12499 12500 // After attaching consumer stream to the dock tile video element 12501 try { 12502 // Only monitor video consumers for bitrate 12503 if (consumer?.track?.kind === 'video') { 12504 const vid = videoElement 12505 || (typeof window._ccFindWebcamDockTile === 'function' 12506 && window._ccFindWebcamDockTile(username) 12507 && window._ccFindWebcamDockTile(username).querySelector('video')) 12508 || null; 12509 window.WebcamBitrate?.observeRemoteConsumer(username, consumer, vid); 12510 } 12511 } catch(e) { console.error('[BITRATE ERROR] observeRemoteConsumer failed:', e); } 12512 12513 // Keep this tile from sticking on a black/frozen frame: a "live" track 12514 // can still render black after packet loss or tab throttling. Recover 12515 // via keyframe (on this viewer socket) â re-consume. Mirrors the 12516 // combined-room flow in consumeFromProducer. 12517 try { 12518 if (consumer?.track?.kind === 'video') { 12519 const kfRoomId = (typeof window._ccWebcamRoomId === 'function') 12520 ? window._ccWebcamRoomId(username) 12521 : ('chatcrack_webcam_' + String(username).toLowerCase()); 12522 this._startDockFrameWatchdog(videoElement, id, username, consumer.track, { socket, roomId: kfRoomId }); 12523 if (typeof window._ccAskDockKeyframeBurst === 'function') { 12524 window._ccAskDockKeyframeBurst(socket, kfRoomId, id, username); 12525 } 12526 if (typeof window._ccEnsureRemoteCamPaintsNow === 'function') { 12527 window._ccEnsureRemoteCamPaintsNow(username, videoElement, socket, kfRoomId, id); 12528 } 12529 } 12530 } catch (_) {} 12531 12532 // CRITICAL FIX: Only set srcObject once and only play after both tracks are added
12533 // Don't reassign srcObject if it's already set (this interrupts playback) 12534 // Only set properties if this is the video track (first track) 12535 if (kind === 'video') { 12536 try { 12537 if (window.chatCrackWebcam 12538 && typeof window.chatCrackWebcam.setRemoteCameraOffOverlay === 'function' 12539 && typeof window.chatCrackWebcam._isUserCameraOff === 'function' 12540 && !window.chatCrackWebcam._isUserCameraOff(username)) { 12541 window.chatCrackWebcam.setRemoteCameraOffOverlay(username, false); 12542 } 12543 } catch (_) {} 12544 try { 12545 const dockTile = videoElement.closest('.webcam-dock'); 12546 if (dockTile) { 12547 dockTile.classList.add('active'); 12548 if (typeof window._ccClearWebcamConnectingPlaceholder === 'function') { 12549 window._ccClearWebcamConnectingPlaceholder(dockTile); 12550 } else { 12551 const vHost = dockTile.querySelector('.webcam-video'); 12552 if (vHost) vHost.querySelectorAll('.webcam-placeholder.connecting').forEach(el => el.remove()); 12553 } 12554 } 12555 } catch (_) {} 12556 videoElement.playsInline = true; 12557 videoElement.autoplay = true; 12558 videoElement.volume = 1.0; 12559 if (typeof window.ccApplyCamVolumeToElement === 'function') { 12560 try { window.ccApplyCamVolumeToElement(videoElement, username); } catch (_) {} 12561 } 12562 12563 const hasVideo = stream.getVideoTracks().length > 0; 12564 12565 if (hasVideo) { 12566 setTimeout(() => { 12567 const canUnmute = typeof window._ccHasUserMediaGesture === 'function' 12568 ? window._ccHasUserMediaGesture() 12569 : !!window._ccAudioUnlocked; 12570 // Start muted until a gesture â avoids Chrome "Unmuting failedâ¦" spam 12571 // on favourite auto-open before the user has clicked anything. 12572 videoElement.muted = !canUnmute; 12573 if (!canUnmute) { 12574 try { videoElement.dataset.ccPendingUnmute = '1'; } catch (_) {} 12575 } 12576 const playEl = (typeof window._ccSafeMediaPlay === 'function') 12577 ? () => window._ccSafeMediaPlay(videoElement) 12578 : () => { 12579 try { 12580 const pr = videoElement.play(); 12581 if (pr && typeof pr.catch === 'function') pr.catch(function () {}); 12582 return pr || Promise.resolve(); 12583 } catch (_) { return Promise.resolve(); } 12584 }; 12585 playEl().then(function () { 12586 if (canUnmute) { 12587 if (!(videoElement._ccAutoplayBlocked || (videoElement.paused && videoElement.srcObject && !videoElement.muted))) return; 12588 } 12589 if (!videoElement.muted) { 12590 videoElement.muted = true; 12591 try { videoElement.dataset.ccPendingUnmute = '1'; } catch (_) {} 12592 } 12593 playEl().then(function () { 12594 function _unmuteOnInteract() { 12595 try { 12596 if (videoElement) { 12597 videoElement.muted = false; 12598 if (typeof videoElement.volume !== 'number' || videoElement.volume <= 0) { 12599 videoElement.volume = 1; 12600 } 12601 try { 12602 const pU = playEl(); 12603 if (pU && typeof pU.catch === 'function') pU.catch(function () {}); 12604 } catch (_) {} 12605 } 12606 if (typeof window._ccEnsureDockWebcamAudioPlaying === 'function') { 12607 window._ccEnsureDockWebcamAudioPlaying(username, { skipIfNoTrack: false, once: true }); 12608 } 12609 } catch (_) {} 12610 document.removeEventListener('click', _unmuteOnInteract); 12611 document.removeEventListener('keydown', _unmuteOnInteract); 12612 } 12613 document.addEventListener('click', _unmuteOnInteract, { once: true }); 12614 document.addEventListener('keydown', _unmuteOnInteract, { once: true }); 12615 }).catch(function () {}); 12616 }).catch(function () {}); 12617 }, 100); 12618 } 12619 } 12620 12621 12622 12623 12624 12625 12626 12627 12628 12629 // If stream was reattached after a disconnect, hide the disconnected overlay 12630 if ((kind === 'video' || kind === 'audio') && window.chatCrackWebcam && typeof window.chatCrackWebcam.hideStreamDisconnectedOverlay === 'function') { 12631 window.chatCrackWebcam.hideStreamDisconnectedOverlay(username); 12632 } 12633 if (kind === 'video' && typeof window._ccCancelWebcamProducerCloseGrace === 'function') { 12634 window._ccCancelWebcamProducerCloseGrace(username); 12635 } 12636 12637 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.applyCameraOffStateForUser === 'function') { 12638 window.chatCrackWebcam.applyCameraOffStateForUser(username); 12639 } 12640 12641 } catch (error) { 12642 // The dedupe key is claimed before the server round-trip so parallel 12643 // callers cannot double-consume. Leaving it behind after a failure 12644 // made every later attempt at this producer return early as 12645 // "already consuming", so the tile kept the mic it had already 12646 // consumed and never got a picture again â for that one publisher, 12647 // until they restarted their camera and minted a new producer id. 12648 try { 12649 const failedKey = `consumeUserWebcam-${username}-${producerId}`; 12650 if (this._consumedProducerKeys) this._consumedProducerKeys.delete(failedKey); 12651 if (this._consumedProducerKinds) this._consumedProducerKinds.delete(failedKey); 12652 } catch (_) {} 12653 try { 12654 const why = String((error && error.message) || error || 'consume failed'); 12655 if (window.ccCamPerf) window.ccCamPerf.mark(username, 'STREAM_ERROR', why); 12656 if (typeof window._ccNoteCamError === 'function') window._ccNoteCamError(username, why); 12657 } catch (_) {} 12658 console.error(`[WebRTC] Failed to consume ${username}'s webcam:`, error); 12659 throw error; 12660 } 12661 } 12662 12663 async stopViewingUserWebcam(username) { 12664 try { 12665 12666 12667 // Never touch main publisher artifacts here. 12668 // Allowed to access ONLY viewerContext for <username>. 12669 const vc = window.remoteWebcamViewers?.[username]; 12670 if (!vc) { 12671 // If we had stale UI with no viewer context, just return; no-op. 12672 if (this.viewingConnections && this.viewingConnections.has(username)) { 12673 // Legacy path: clean up old viewingConnections if they exist 12674 const connection = this.viewingConnections.get(username); 12675 try { 12676 if (connection.recvTransport && !connection.recvTransport.closed) connection.recvTransport.close(); 12677 } catch {} 12678 try { 12679 if (connection.socket && connection.socket.connected) connection.socket.disconnect(); 12680 } catch {} 12681 this.viewingConnections.delete(username); 12682 } 12683 return; 12684 } 12685 12686 // If we have an isolated viewer, delegate to removeDockedWebcam to avoid touching publisher 12687 console.log('[ViewerManager] stopViewingUserWebcam routed to isolated cleanup for', username); 12688 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.removeDockedWebcam === 'function') { 12689 window.chatCrackWebcam.removeDockedWebcam(username); 12690 } 12691 12692 // Defensive: explicitly avoid referencing this.sendTransport / this.cameraProducer / this.socket here. 12693 // Close only vc (viewer context) artifacts. 12694 // The viewer cleanup is handled by removeDockedWebcam above. 12695 12696 } catch (error) { 12697 console.error(`[WebRTC] Failed to stop viewing ${username}'s webcam:`, error); 12698 // Don't throw - just log, this is viewer cleanup 12699 } 12700 } 12701 12702 async startVideoCall() { 12703 try { 12704 12705 12706 const callUser = window.currentUsername || window.me?.username; 12707 if (!callUser) { 12708 throw new Error('Chat identity not ready â reload and try again.'); 12709 } 12710 this.userId = callUser; 12711 this.roomId = `chatcrack_room_${Date.now()}`; 12712 12713 12714 12715 // Get user media with safer constraints for Chrome 137-140 compatibility 12716 this.localStream = await navigator.mediaDevices.getUserMedia({ 12717 video: { 12718 width: { ideal: 640 }, 12719 height: { ideal: 360 }, 12720 frameRate: { ideal: 30, max: 30 } 12721 }, 12722 audio: true 12723 }); 12724 12725 // ð NEW: Set contentHint for video tracks 12726 const videoTracks = this.localStream.getVideoTracks(); 12727 videoTracks.forEach(track => { 12728 if (track.contentHint !== undefined) { 12729 track.contentHint = 'motion';
12730 _chatLog('[WebRTC] Set video track contentHint to motion'); 12731 } 12732 }); 12733 12734 12735 12736 // Connect to mediasoup server 12737 await this.connectToMediasoup(); 12738 12739 // Show video container 12740 this.showVideoContainer(); 12741 12742 } catch (error) { 12743 console.error("[WebRTC] Failed to start video call:", error); 12744 alert('Failed to start video call: ' + error.message); 12745 } 12746 } 12747 12748 async joinRoomAndLoadDevice() { 12749 const socket = await this.ensureSocketConnected(); 12750 12751 _chatLog('[WebRTC] ð¡ Joining room:', this.roomId, 'as', this.userId); 12752 12753 const joinResp = await new Promise((resolve) => { 12754 socket.emit( 12755 'chatcrack:join-room', 12756 { roomId: this.roomId, userId: this.userId, userRole: this.userRole || 'member' }, 12757 resolve 12758 ); 12759 }); 12760 12761 if (!joinResp || joinResp.ok !== true) { 12762 console.error('[WebRTC] â join-room failed:', joinResp); 12763 throw new Error('join-room failed: ' + ((joinResp && joinResp.message) || 'no response')); 12764 } 12765 12766 if (!joinResp.routerRtpCapabilities || !Array.isArray(joinResp.routerRtpCapabilities.codecs)) { 12767 console.error('[WebRTC] â Missing/invalid routerRtpCapabilities:', joinResp.routerRtpCapabilities); 12768 throw new Error('invalid router caps from server'); 12769 } 12770 12771 this.routerRtpCapabilities = joinResp.routerRtpCapabilities; 12772 12773 // Create and load mediasoup Device *after* a successful join 12774 // Debug messages removed for cleaner console 12775 const DeviceCtor = await getMediasoupDeviceCtor(); // â robust getter 12776 this.device = new DeviceCtor(); // â¬ ï¸ was: new Device() 12777 await this.device.load({ routerRtpCapabilities: this.routerRtpCapabilities }); 12778 12779 _chatLog('[WebRTC] â Device.load() complete'); 12780 _chatLog('[codec] Device codecs:', (this.device.rtpCapabilities.codecs || []).map(c => c.mimeType)); 12781 } 12782 12783 12784 startKeepAlive() { 12785 // Your server schedules room cleanup when empty. Add keep-alive. 12786 // Clear first: repeated calls used to stack 20s emitters that never stopped. 12787 if (this._keepAliveTimer) clearInterval(this._keepAliveTimer); 12788 this._keepAliveTimer = setInterval(() => { 12789 this.socket.emit('chatcrack:keep-alive', { roomId: this.roomId }, () => {}); 12790 }, 20000); 12791 12792 12793 } 12794 12795 async createTransport(direction) { 12796 return new Promise((resolve, reject) => { 12797 const timeout = setTimeout(() => { 12798 reject(new Error(`Transport creation timeout for ${direction}`)); 12799 }, 15000); 12800 12801 // Emit the create-transport event 12802 this.socket.emit("chatcrack:create-transport", { 12803 roomId: this.roomId, 12804 userId: this.userId, 12805 direction: direction 12806 }); 12807 12808 // Listen for the transport-created event 12809 this.socket.once('chatcrack:transport-created', (transportData) => { 12810 clearTimeout(timeout); 12811 12812 resolve(transportData); 12813 }); 12814 12815 // Listen for error events 12816 this.socket.once('chatcrack:error', (error) => { 12817 clearTimeout(timeout); 12818 console.log("[createTransport] â Server sent error:", error); 12819 console.log("[createTransport] â Error type:", typeof error); 12820 console.log("[createTransport] â Error keys:", error ? Object.keys(error) : 'null/undefined'); 12821 console.log("[createTransport] â Error stringified:", JSON.stringify(error, null, 2)); 12822 reject(new Error(`Transport creation failed: ${error?.message || error?.toString() || 'Unknown error'}`)); 12823 }); 12824 }); 12825 } 12826 12827 async createTransports() { 12828 const socket = await this.ensureSocketConnected(); 12829 _chatLog('[WebRTC] ð§ Creating transports...'); 12830 12831 const create = (direction) => 12832 new Promise((resolve, reject) => { 12833 socket.emit( 12834 'chatcrack:create-transport', 12835 { roomId: this.roomId, userId: this.userId, direction }, 12836 (res) => (res && res.ok ? resolve(res) : reject(new Error('create-transport failed: ' + (res && res.message)))) 12837 ); 12838 }); 12839 12840 const sendInfo = await create('send'); 12841 const recvInfo = await create('recv'); 12842 12843 _chatLog('[WebRTC] â Send transport created:', sendInfo.id); 12844 _chatLog('[WebRTC] â Receive transport created:', recvInfo.id); 12845 12846 this.sendTransport = this.device.createSendTransport( 12847 window.ccMediasoupTransportOpts ? window.ccMediasoupTransportOpts(sendInfo) : sendInfo 12848 ); 12849 this.recvTransport = this.device.createRecvTransport( 12850 window.ccMediasoupTransportOpts ? window.ccMediasoupTransportOpts(recvInfo) : recvInfo 12851 ); 12852
12853 // Wire connect handshakes 12854 this.sendTransport.on('connect', ({ dtlsParameters }, callback, errback) => { 12855 socket.emit( 12856 'chatcrack:connect-transport', 12857 { roomId: this.roomId, transportId: sendInfo.id, dtlsParameters }, 12858 (res) => (res && res.ok ? callback() : errback(new Error(res && res.message || 'send connect failed'))) 12859 ); 12860 }); 12861 12862 this.recvTransport.on('connect', ({ dtlsParameters }, callback, errback) => { 12863 socket.emit( 12864 'chatcrack:connect-transport', 12865 { roomId: this.roomId, transportId: recvInfo.id, dtlsParameters }, 12866 (res) => (res && res.ok ? callback() : errback(new Error(res && res.message || 'recv connect failed'))) 12867 ); 12868 }); 12869 12870 // Required for producing 12871 this.sendTransport.on('produce', ({ kind, rtpParameters, appData }, callback, errback) => { 12872 socket.emit( 12873 'chatcrack:produce', 12874 { roomId: this.roomId, transportId: sendInfo.id, kind, rtpParameters, userId: this.userId }, 12875 (res) => (res && res.ok ? callback({ id: res.producerId }) : errback(new Error(res && res.message || 'produce failed'))) 12876 ); 12877 }); 12878 this.sendTransport.on('close', () => { 12879 if (this._suppressTransportCloseRecovery) return; 12880 if (typeof window._ccMediaHoldActive === 'function' && window._ccMediaHoldActive()) return; 12881 console.warn('[WebRTC] Send transport closed â watchdog will restart producer'); 12882 if (typeof this._onProducerTransportClose === 'function') this._onProducerTransportClose(); 12883 }); 12884 12885 _chatLog('[WebRTC] Transports created successfully'); 12886 } 12887 12888 12889 async connectTransports() { 12890 _chatLog('[WebRTC] ð Skipping manual connect; transports auto-connect via on("connect")'); 12891 } 12892 12893 async startProducing() { 12894 _chatLog('[WebRTC] ð Starting media production...'); 12895 if (!this.localStream) throw new Error('no local stream'); 12896 const audioTrack = this.localStream.getAudioTracks?.()[0] || null; 12897 const videoTrack = this.localStream.getVideoTracks?.()[0] || null; 12898 if (!audioTrack && !videoTrack) throw new Error('no media tracks'); 12899 12900 // Produce audio FIRST to keep m-section mid ordering aligned 12901 if (audioTrack && this.device?.canProduce('audio')) { 12902 try { 12903 const keepUnmuted = this.isMicMuted === false || this._pendingMicUnmute === true; 12904 this._pendingMicUnmute = false; 12905 audioTrack.enabled = keepUnmuted; 12906 12907 // Get configured audio bitrate for logging (encode uses mediasoup defaults). 12908 let audioBitrate = 16000; 12909 let audioQuality = 'medium'; 12910 try { 12911 const audioConstraints = window.chatCrackWebcam?.getAudioConstraints?.(); 12912 if (audioConstraints && audioConstraints.bitrate) { 12913 audioBitrate = audioConstraints.bitrate; 12914 } 12915 const qualitySelect = document.getElementById('audioQualitySelect'); 12916 if (qualitySelect) { 12917 audioQuality = qualitySelect.value || 'medium'; 12918 } 12919 } catch (e) { 12920 console.warn('[AUDIO BITRATE] Could not get audio constraints, using defaults:', e); 12921 } 12922 _chatLog(`[AUDIO BITRATE] ðµ Configured audio bitrate: ${(audioBitrate / 1000).toFixed(0)} kbps (quality: ${audioQuality})`); 12923 12924 // d540d9c: minimal produce. Private voice calls only get explicit Opus opts. 12925 const isVcallProduce = String(this.roomId || '').startsWith('chatcrack_vcall_'); 12926 const produceOpts = { track: audioTrack, paused: !keepUnmuted, stopTracks: false }; 12927 if (isVcallProduce) { 12928 produceOpts.codecOptions = { 12929 opusDtx: false, 12930 opusFec: true, 12931 opusMaxAverageBitrate: Math.max(48000, audioBitrate || 0) 12932 }; 12933 } 12934 12935 const aProd = await this.sendTransport.produce(produceOpts); 12936 this.producers.set('mic', aProd); 12937 this.isMicMuted = !keepUnmuted; 12938 this.audioProducer = aProd; 12939 // Cam starts muted by default â the first capture often lands on 12940 // a silent/wrong WASAPI endpoint until a fresh gUM. Force that 12941 // Settings-style reopen on the first unmute so people hear you 12942 // immediately (no CableâFifine dance). 12943 if (!keepUnmuted) this._micNeedsFreshOpenOnUnmute = true; 12944 this._bindMicTrackDeviceRecovery(audioTrack); 12945 this.startMicHealthWatchdog(); 12946 if (keepUnmuted && typeof this._serverMicProducerOp === 'function') { 12947 try { this._serverMicProducerOp('resume', aProd); } catch (_) {} 12948 } 12949 12950 _chatLog('[WebRTC] ðï¸ Mic producer created (muted=' + !keepUnmuted + '):', aProd.id); 12951 _chatLog(`[AUDIO BITRATE] ðµ Audio producer created with configured bitrate: ${(audioBitrate / 1000).toFixed(0)} kbps`); 12952 12953 this.startAudioBitrateMonitoring(aProd); 12954 } catch (e) { 12955 console.warn('[WebRTC] audio produce failed:', e?.message || e); 12956 } 12957 } 12958 12959 if (videoTrack && this.device?.canProduce('video')) {
12960 const enc = this.getSendEncodingFor(this.currentQuality || 'low'); 12961 let vProd; 12962 try { 12963 vProd = await this.sendTransport.produce({ 12964 track: videoTrack, 12965 encodings: enc, 12966 codecOptions: { videoGoogleStartBitrate: 120 }, 12967 stopTracks: false 12968 }); 12969 } catch (_) { 12970 vProd = await this.sendTransport.produce({ track: videoTrack, stopTracks: false }); 12971 } 12972 vProd.on('trackended', () => console.warn('[WebRTC] producer track ended')); 12973 vProd.on('transportclose', () => { 12974 if (this._suppressTransportCloseRecovery) return; 12975 console.warn('[WebRTC] producer transport closed'); 12976 this._onProducerTransportClose(); 12977 }); 12978 this.cameraProducer = vProd; 12979 _chatLog('[WebRTC] ð¥ Producing video id:', vProd.id); 12980 try { 12981 const selfRtc = this; 12982 ;[0, 180, 500, 1200, 2500].forEach(function (ms) { 12983 setTimeout(function () { 12984 try { 12985 if (!selfRtc.socket || !selfRtc.socket.connected || !selfRtc.roomId) return; 12986 if (!selfRtc.cameraProducer || selfRtc.cameraProducer.closed) return; 12987 if (typeof window._ccNudgePublisherKeyframes === 'function') { 12988 window._ccNudgePublisherKeyframes(); 12989 } else { 12990 selfRtc.socket.emit('chatcrack:force-keyframes', { 12991 roomId: selfRtc.roomId, 12992 userId: selfRtc.userId 12993 }); 12994 } 12995 } catch (_) {} 12996 }, ms); 12997 }); 12998 } catch (_) {} 12999 13000 // After successful local producer creation: own-dock uplink bitrate 13001 try { 13002 if (window.WebcamBitrate && typeof window.WebcamBitrate.refreshLocal === 'function') { 13003 window.WebcamBitrate.refreshLocal(); 13004 } else { 13005 const _lv = document.querySelector('#webcamModal video, .local-webcam video, .webcam-dock video'); 13006 window.WebcamBitrate?.observeLocalProducer(this.cameraProducer, _lv); 13007 } 13008 } catch(e) {} 13009 13010 // Get the RTCRtpSender for bitrate control 13011 const pc = this.sendTransport._handler._pc; 13012 const senders = pc.getSenders(); 13013 this.sender = senders.find(s => s.track && s.track.kind === 'video'); 13014 13015 console.log(`[BITRATE] ð Found ${senders.length} senders, video sender:`, !!this.sender); 13016 13017 // Immediately enforce conservative start, can upshift later 13018 if (this.sender) { 13019 console.log(`[BITRATE] ð Initial webcam start - applying ${this.currentQuality} quality settings`); 13020 await this.updateBitrate(this.currentQuality); 13021 13022 // Note: Track constraints are already set during getUserMedia, 13023 // RTCRtpSender parameters are the main bitrate control mechanism 13024 13025 // Retry bitrate setting after a short delay to ensure producer is fully established 13026 setTimeout(async () => { 13027 console.log(`[BITRATE] ð Retrying bitrate setting after delay...`); 13028 try { 13029 await this.updateBitrate(this.currentQuality); 13030 console.log(`[BITRATE] â Retry successful`); 13031 } catch (error) { 13032 console.warn(`[BITRATE] â ï¸ Retry failed:`, error); 13033 } 13034 }, 1000); 13035 13036 } else { 13037 console.warn('[BITRATE] â ï¸ No RTCRtpSender available for initial bitrate setting'); 13038 } 13039 } 13040 13041 // Local preview and health beacons 13042 this.ensureMediaContainers(); 13043 // DISABLED: localCam element assignment to prevent overlap with webcamPreview 13044 // The webcamPreview element in the modal is sufficient for local preview 13045 // try { 13046 // var localEl = document.getElementById('localCam'); 13047 // if (localEl) { localEl.srcObject = this.localStream; localEl.play().catch(function(){}); } 13048 // } catch (_) {} 13049 this.startHealthBeacons({ user: this.userId, roomId: this.roomId }); 13050 this.bindAudioDeviceChangeListener(); 13051 // Announce is owned by startWebcam / restart paths â do not double-POST here
13052 // (Firefox was freezing on duplicate update_webcam_status + system message). 13053 } 13054 13055 _trackDeviceId(track) { 13056 try { 13057 return track?.getSettings?.()?.deviceId || ''; 13058 } catch (_) { 13059 return ''; 13060 } 13061 } 13062 13063 /** 13064 * Stable voice encode for webcam / open-mic / recreate. 13065 * Mono Opus with FEC, no DTX cutouts, fixed 20ms frames. 13066 * Bitrate follows Audio Quality (low 48k ⦠Super HD 192k). 13067 */ 13068 _micUsesVirtualCable() { 13069 try { 13070 const wc = window.chatCrackWebcam; 13071 if (wc && typeof wc.micDeviceIsVirtualCable === 'function') { 13072 return wc.micDeviceIsVirtualCable(); 13073 } 13074 let label = ''; 13075 const sel = document.getElementById('audioDeviceSelect'); 13076 if (sel && sel.selectedOptions && sel.selectedOptions[0]) { 13077 label = String(sel.selectedOptions[0].textContent || ''); 13078 } 13079 const deviceId = String(wc?.webcamSettings?.audioDeviceId || ''); 13080 const blob = (label + ' ' + deviceId).toLowerCase(); 13081 return /vb-?audio|virtual cable|cable (input|output)|voicemeeter|vb-cable/.test(blob); 13082 } catch (_) {} 13083 return false; 13084 } 13085 13086 /** d540d9c-compatible mic produce: minimal mediasoup produce (vcall-only Opus opts). */ 13087 _stableMicProduceOptions(track, paused) { 13088 const isVcallProduce = String(this.roomId || '').startsWith('chatcrack_vcall_'); 13089 const opts = { track: track, paused: !!paused, stopTracks: false }; 13090 if (isVcallProduce) { 13091 opts.codecOptions = { 13092 opusDtx: false, 13093 opusFec: true, 13094 opusMaxAverageBitrate: 64000 13095 }; 13096 } 13097 return opts; 13098 } 13099 13100 async _produceMicStable(track, paused) { 13101 return this.sendTransport.produce(this._stableMicProduceOptions(track, paused)); 13102 } 13103 13104 _serverMicProducerOp(op, prod) { 13105 return new Promise((resolve) => { 13106 try { 13107 if (!this.socket || !prod || !prod.id || !this.roomId) return resolve(false); 13108 const evt = op === 'pause' ? 'chatcrack:pause-producer' : 'chatcrack:resume-producer'; 13109 const t = setTimeout(() => resolve(false), 2500); 13110 this.socket.emit(evt, { roomId: this.roomId, producerId: prod.id }, (res) => { 13111 clearTimeout(t); 13112 resolve(!!(res && res.ok)); 13113 }); 13114 } catch (_) { 13115 resolve(false); 13116 } 13117 }); 13118 } 13119 13120 /** 13121 * Rebind your own VU meter to the live mic track. Toggling the mic is a 13122 * user gesture, so this is also the right moment to unlock Web Audio â 13123 * without it the analyser stays null and the bar never moves until the 13124 * next click somewhere else in the page. 13125 */ 13126 _refreshOwnMicMeter() { 13127 try { window._ccAudioUnlocked = true; } catch (_) {} 13128 const run = () => { 13129 try { 13130 const me = window.currentUsername || (window.me && window.me.username) || ''; 13131 if (me && typeof window._ccRefreshDockAudioLevelForUser === 'function') { 13132 window._ccRefreshDockAudioLevelForUser(me); 13133 } else if (typeof window._ccRefreshAllDockAudioLevels === 'function') { 13134 window._ccRefreshAllDockAudioLevels(); 13135 } 13136 } catch (_) {} 13137 }; 13138 run(); 13139 // Mic re-produce swaps the producer a moment later â rebind again so the 13140 // meter follows the new track instead of freezing on the closed one. 13141 try { 13142 setTimeout(run, 300); 13143 setTimeout(run, 1200); 13144 } catch (_) {} 13145 } 13146 13147 /** 13148 * The capture we are holding may not be the mic the user pinned (Firefox 13149 * rotates deviceIds, so the first gUM of a session can fall back to the 13150 * system default) or its source may be dead (`track.muted`). Either way the 13151 * old unmute just re-published silence, which is why switching device in 13152 * Settings and back was the only thing that fixed it. Re-open on the pinned 13153 * device instead. 13154 */ 13155 async _ensureMicTrackIsPinnedAndLive() { 13156 try { 13157 const wc = window.chatCrackWebcam; 13158 const settings = wc && wc.webcamSettings; 13159 const track = this.localStream?.getAudioTracks?.()[0]; 13160 if (!track || track.readyState === 'ended') return 'reopen'; 13161 if (track.muted === true) { 13162 console.warn('ðï¸ [MIC] live track is delivering no data â reopening pinned mic'); 13163 return 'reopen'; 13164 } 13165 if (!settings) return 'ok'; 13166 // Re-pin ids first: today's id for the saved device may have changed. 13167 if (typeof wc._remapSavedDeviceIds === 'function' && navigator.mediaDevices?.enumerateDevices) { 13168 try { 13169 const devices = await navigator.mediaDevices.enumerateDevices(); 13170 if (typeof wc._deviceListIsTrustworthy !== 'function' 13171 || wc._deviceListIsTrustworthy(devices)) { 13172 wc._remapSavedDeviceIds(devices); 13173 } 13174 } catch (_) {} 13175 } 13176 const wantId = settings.audioDeviceId || ''; 13177 const wantLabel = String(settings.audioDeviceLabel || '').trim().toLowerCase(); 13178 if (!wantId && !wantLabel) return 'ok'; 13179 const haveId = this._trackDeviceId ? this._trackDeviceId(track) : ''; 13180 const haveLabel = String(track.label || '').trim().toLowerCase(); 13181 if (wantId && haveId && haveId === wantId) return 'ok'; 13182 if (wantLabel && haveLabel && haveLabel === wantLabel) return 'ok'; 13183 console.warn('ðï¸ [MIC] publishing "' + track.label + '" but "' 13184 + (settings.audioDeviceLabel || wantId) + '" is pinned â switching'); 13185 return 'reopen'; 13186 } catch (_) {} 13187 return 'ok'; 13188 } 13189 13190 async _serverPauseMicProducer(prod) { 13191 return this._serverMicProducerOp('pause', prod || this.producers?.get?.('mic') || this.audioProducer); 13192 } 13193 13194 async _serverResumeMicProducer(prod) { 13195 return this._serverMicProducerOp('resume', prod || this.producers?.get?.('mic') || this.audioProducer); 13196 } 13197 13198 /** d540: no close+re-produce â quality/device changes use replaceTrack only. */ 13199 async reproduceMicWithCurrentSettings() { 13200 return this.applyAudioDeviceFromSettings(); 13201 } 13202 13203 _bindMicTrackDeviceRecovery(track) { 13204 if (!track || track._ccDeviceRecoveryBound) return; 13205 track._ccDeviceRecoveryBound = true; 13206 track.onended = () => { 13207 if (this._suppressAudioTrackEnded || window.chatCrackWebcam?._settingsDeviceSwapInProgress) return; 13208 _chatLog('[WebRTC] Audio track ended â mic stays as-is until you mute/unmute or change device in settings'); 13209 }; 13210 } 13211 13212 /** 13213 * Soft playout keep-alive only â never close/re-produce the mic. 13214 * Matches what switching audio output does (resume AudioContext + re-play sinks). 13215 */ 13216 startMicHealthWatchdog() { 13217 this.stopMicHealthWatchdog(); 13218 const self = this; 13219 this._micHealthInterval = setInterval(function () { 13220 try { self._micLongSessionKeepAliveTick(); } catch (_) {} 13221 }, 10 * 60 * 1000); 13222 this._micClockSlipInterval = setInterval(function () { 13223 self._micClockStallTick().catch(function () {}); 13224 }, MicClockStall.SAMPLE_MS); 13225 this._micClockSample = null; 13226 this._micClockBadWindows = 0; 13227 } 13228 13229 stopMicHealthWatchdog() { 13230 if (this._micClockSlipInterval) { 13231 clearInterval(this._micClockSlipInterval); 13232 this._micClockSlipInterval = null; 13233 } 13234 this._micClockSample = null; 13235 this._micClockBadWindows = 0; 13236 if (this._micHealthInterval) { 13237 clearInterval(this._micHealthInterval); 13238 this._micHealthInterval = null; 13239 } 13240 if (this._audioEndedTimer) { 13241 clearTimeout(this._audioEndedTimer); 13242 this._audioEndedTimer = null; 13243 } 13244 } 13245 13246 _micHealthTick() { 13247 return; 13248 } 13249 13250 async _micPreventiveRefreshTick() { 13251 return; 13252 } 13253 13254 async _micLongSessionKeepAliveTick(_prod, _track) { 13255 if (typeof document !== 'undefined' && document.hidden) return; 13256 try { 13257 if (typeof window._ccRefreshAudioEndpoint === 'function') { 13258 await window._ccRefreshAudioEndpoint('mic_keepalive'); 13259 } 13260 } catch (_) {} 13261 } 13262 13263 /** 13264 * Catch the sound device thread falling behind, and reopen the mic for it. 13265 * 13266 * The packet-rate watchdog cannot see this: a stalling device still sends 13267 * 43-47 pkt/s, comfortably inside "healthy". What gives it away is the audio 13268 * clock losing ground against wall time â when the device thread stalls, the 13269 * AudioContext clock and the mic encoder both slow by the same fraction, and 13270 * that stall is what people hear as crackling and jumping. The only thing 13271 * that clears it is letting go of the device and taking it again, which is 13272 * why the workaround has been switching audio devices by hand. 13273 */ 13274 async _micClockStallTick() { 13275 const S = MicClockStall; 13276 const prod = this.producers?.get?.('mic') || this.audioProducer; 13277 const track = this.localStream?.getAudioTracks?.()[0]; 13278 13279 if ((typeof document !== 'undefined' && document.hidden) 13280 || !prod || prod.closed || prod.paused 13281 || !track || track.readyState !== 'live' || track.enabled === false 13282 || this.isMicMuted 13283 || this._audioReconnectInProgress 13284 || window.chatCrackWebcam?._settingsDeviceSwapInProgress) { 13285 this._micClockSample = null; 13286 this._micClockBadWindows = 0; 13287 return; 13288 } 13289 13290 const ctx = window._ccSharedAudioCtx; 13291 if (!ctx || ctx.state !== 'running' || typeof ctx.currentTime !== 'number') { 13292 this._micClockSample = null; 13293 return; 13294 } 13295 13296 const wall = (window.performance && performance.now) ? performance.now() : Date.now(); 13297 const prev = this._micClockSample; 13298 this._micClockSample = { wall, audio: ctx.currentTime }; 13299 if (!prev) return; 13300 13301 const wallDelta = wall - prev.wall; 13302 const audioDelta = (ctx.currentTime - prev.audio) * 1000; 13303 // A long JS stall or a tab throttle skews both clocks â not a device fault. 13304 if (wallDelta < S.SAMPLE_MS * 0.5 || wallDelta > S.SAMPLE_MS * 2 || audioDelta < 0) { 13305 this._micClockBadWindows = 0; 13306 return; 13307 } 13308 13309 const slipMs = wallDelta - audioDelta; 13310 if (slipMs < S.SLIP_MS) { 13311 this._micClockBadWindows = 0; 13312 // A sustained clean stretch means the last heal worked, so the next 13313 // stall gets the short cooldown again. 13314 this._micClockCleanWindows = (this._micClockCleanWindows || 0) + 1; 13315 if (this._micClockCleanWindows >= S.CLEAN_WINDOWS_TO_RESET) { 13316 this._micClockHealStreak = 0; 13317 } 13318 return; 13319 } 13320 this._micClockCleanWindows = 0; 13321 13322 this._micClockBadWindows = (this._micClockBadWindows || 0) + 1; 13323 console.warn('[MIC HEALTH] audio clock lost ' + slipMs.toFixed(0) + 'ms in ' 13324 + (wallDelta / 1000).toFixed(1) + 's â stall window ' + this._micClockBadWindows); 13325 if (this._micClockBadWindows < S.BAD_WINDOWS_BEFORE_HEAL) return; 13326 13327 // Reopening costs a short dropout. If it is not clearing the stall, back
13328 // off hard rather than interrupting the user every 90 seconds forever. 13329 const streak = this._micClockHealStreak || 0; 13330 const cooldown = S.HEAL_COOLDOWN_MS * Math.min(1 + streak, S.MAX_COOLDOWN_MULTIPLIER); 13331 const now = Date.now(); 13332 if (this._lastMicClockHealAt && now - this._lastMicClockHealAt < cooldown) return; 13333 this._lastMicClockHealAt = now; 13334 this._micClockHealStreak = streak + 1; 13335 this._micClockBadWindows = 0; 13336 this._micClockSample = null; 13337 13338 try { 13339 if (typeof window._ccMarkAudioProducerGrace === 'function') { 13340 window._ccMarkAudioProducerGrace( 13341 window.currentUsername || (window.me && window.me.username) || '', 13342 20000 13343 ); 13344 } 13345 window._ccMicHealGraceUntil = now + 20000; 13346 _chatLog('[MIC HEALTH] Sound device stalled â releasing and reopening the mic'); 13347 const ok = await this._reopenPinnedMic({ releaseFirst: true }); 13348 if (ok) { 13349 showToast('ðï¸ Mic reset â the sound device had stalled', 'info', { ttlMs: 3500 }); 13350 } else { 13351 console.warn('[MIC HEALTH] clock-stall reopen did not apply'); 13352 } 13353 } catch (e) { 13354 console.warn('[MIC HEALTH] clock-stall heal failed:', e?.message || e); 13355 } 13356 } 13357 13358 /** 13359 * Heal only on hard Opus packet collapse. Mild dips (~35â45 pkt/s) are common 13360 * under webcam load â closing/re-producing the mic was what made audio skip 13361 * and forced users to switch devices. Prefer replaceTrack only; never close 13362 * the producer for PPS heals. 13363 */ 13364 async _micPacketHealthTick(prod, track) { 13365 const EXPECTED_PPS = 50; // 20ms Opus frames, DTX off 13366 const MIN_HEALTHY_PPS = 28; // only heal hard collapses (not mild congestion) 13367 const CLEAR_BAD_PPS = 36; // hysteresis 13368 const MIN_PLAUSIBLE_PPS = 8; // below this it is silence/DTX, not crackle 13369 const BAD_WINDOWS_BEFORE_HEAL = 4; // ~32s of confirmed collapse 13370 const HEAL_COOLDOWN_MS = 3 * 60 * 1000; 13371 13372 if (typeof document !== 'undefined' && document.hidden) { 13373 this._micPpsSample = null; 13374 this._micPpsBadWindows = 0; 13375 return; 13376 } 13377 if (prod.paused || track.muted || track.enabled === false) { 13378 this._micPpsSample = null; 13379 this._micPpsBadWindows = 0; 13380 return; 13381 } 13382 13383 let packets = null; 13384 let atMs = 0; 13385 try { 13386 const stats = await prod.getStats();
13387 stats.forEach((r) => { 13388 if (r && r.type === 'outbound-rtp' && r.kind === 'audio' && typeof r.packetsSent === 'number') { 13389 packets = r.packetsSent; 13390 atMs = typeof r.timestamp === 'number' ? r.timestamp : Date.now(); 13391 } 13392 }); 13393 } catch (_) { 13394 return; 13395 } 13396 if (packets == null) return; 13397 13398 const prev = this._micPpsSample; 13399 this._micPpsSample = { packets, atMs, producerId: prod.id }; 13400 13401 // Need two samples from the same producer to measure a rate at all. 13402 if (!prev || prev.producerId !== prod.id) return; 13403 const elapsed = (atMs - prev.atMs) / 1000; 13404 const sent = packets - prev.packets; 13405 if (elapsed < 5 || sent < 0) return; 13406 13407 const pps = sent / elapsed; 13408 if (pps <= MIN_PLAUSIBLE_PPS) { 13409 // Silence / DTX â not starvation. Do not clear an existing bad streak 13410 // either; a quiet moment mid-crackle should not reset the counter. 13411 return; 13412 } 13413 if (pps >= CLEAR_BAD_PPS) { 13414 this._micPpsBadWindows = 0; 13415 return; 13416 } 13417 if (pps >= MIN_HEALTHY_PPS) { 13418 // Grey band (mild congestion): log sparsely, never heal. 13419 if (!this._micPpsLastSoftWarnAt || (Date.now() - this._micPpsLastSoftWarnAt) > 30000) { 13420 this._micPpsLastSoftWarnAt = Date.now(); 13421 if (window.WEBCAM_DEBUG) { 13422 console.warn(`[MIC HEALTH] Mic ${pps.toFixed(1)} pkt/s (soft â not healing)`); 13423 } 13424 } 13425 return; 13426 } 13427 13428 this._micPpsBadWindows = (this._micPpsBadWindows || 0) + 1; 13429 console.warn(`[MIC HEALTH] Mic sending ${pps.toFixed(1)} pkt/s (expected ~${EXPECTED_PPS}) â bad window ${this._micPpsBadWindows}`); 13430 if (this._micPpsBadWindows < BAD_WINDOWS_BEFORE_HEAL) return; 13431 13432 const now = Date.now(); 13433 if (this._lastMicHealAt && now - this._lastMicHealAt < HEAL_COOLDOWN_MS) return; 13434 this._micHealCount = (this._micHealCount || 0) + 1; 13435 this._lastMicHealAt = now; 13436 this._micPpsBadWindows = 0; 13437 this._micPpsSample = null; 13438 this._micPublishStartedAt = now; 13439 13440 // Always soft-heal: endpoint refresh + replaceTrack. Never "give up" after N 13441 // heals â that left long sessions stuck until the user switched output. 13442 try { 13443 if (typeof window._ccRefreshAudioEndpoint === 'function') { 13444 await window._ccRefreshAudioEndpoint('mic_pps_heal'); 13445 } 13446 if (typeof window._ccMarkAudioProducerGrace === 'function') { 13447 window._ccMarkAudioProducerGrace( 13448 window.currentUsername || (window.me && window.me.username) || '', 13449 20000 13450 ); 13451 } 13452 window._ccMicHealGraceUntil = now + 20000; 13453 this._micLastFullRefreshAt = now; 13454 this._micLastLightRefreshAt = now; 13455 this._micLastKeepAliveAt = now; 13456 _chatLog('[MIC HEALTH] Encoder collapsed (' + pps.toFixed(1) + ' pkt/s) â replaceTrack heal'); 13457 const ok = await this.applyAudioDeviceFromSettings(); 13458 if (!ok) { 13459 console.warn('[MIC HEALTH] replaceTrack heal failed â not tearing down producer'); 13460 } 13461 } catch (e) { 13462 console.warn('[MIC HEALTH] mic heal failed:', e?.message || e); 13463 } 13464 } 13465 13466 _bindVideoTrackDeviceRecovery(track) { 13467 if (!track || track._ccVideoRecoveryBound) return; 13468 track._ccVideoRecoveryBound = true; 13469 track.onended = () => { 13470 if (this._suppressVideoTrackEnded || window.chatCrackWebcam?._settingsDeviceSwapInProgress) return; 13471 if (this._isCameraIntentionallyOff()) return; 13472 const prod = this.cameraProducer; 13473 if (!prod || prod.closed) return; 13474 _chatLog('[WebRTC] Video track ended â scheduling camera reconnect'); 13475 clearTimeout(this._videoEndedTimer); 13476 this._videoEndedTimer = setTimeout(() => this.applyVideoDeviceFromSettings(), 1200); 13477 }; 13478 } 13479 13480 bindAudioDeviceChangeListener() { 13481 // Mic device swaps run only when the user saves webcam settings â not on OS 13482 // devicechange events (Bluetooth/USB flicker was reopening the mic and dropping audio). 13483 // Playout refresh is handled globally by window._ccRefreshAudioEndpoint. 13484 if (this._audioDeviceListenerBound) return; 13485 this._audioDeviceListenerBound = true; 13486 } 13487
13488 _stripMediaBitrate(constraints) { 13489 if (!constraints || typeof constraints !== 'object') return {}; 13490 const { bitrate, ...rest } = constraints; 13491 return rest; 13492 } 13493 13494 _syncWebcamManagerStream() { 13495 if (window.chatCrackWebcam && this.localStream) { 13496 window.chatCrackWebcam.localStream = this.localStream; 13497 } 13498 } 13499 13500 _refreshLocalWebcamPreview() { 13501 const stream = this.localStream; 13502 if (!stream) return; 13503 this._syncWebcamManagerStream(); 13504 const preview = document.getElementById('webcamPreview'); 13505 if (preview) { 13506 preview.srcObject = stream; 13507 try { preview.muted = true; } catch (_) {} 13508 } 13509 const u = window.currentUsername || window.me?.username; 13510 if (u) { 13511 const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(u) : u; 13512 const dockVid = document.querySelector(`.webcam-dock[data-username="${esc}"] video`); 13513 if (dockVid) { 13514 dockVid.srcObject = stream; 13515 try { dockVid.muted = true; } catch (_) {} 13516 } 13517 const floatVid = document.querySelector(`.floating-webcam[data-username="${esc}"] video`); 13518 if (floatVid) { 13519 floatVid.srcObject = stream; 13520 try { floatVid.muted = true; } catch (_) {} 13521 } 13522 } 13523 document.querySelectorAll('#webcamPreview, #webcamVideo, #localWebcamVideo, .webcam-video-element').forEach((el) => { 13524 if (el && el.srcObject !== stream) el.srcObject = stream; 13525 try { el.muted = true; } catch (_) {} 13526 }); 13527 this._ensureSelfDockMuted(); 13528 } 13529 13530 async _getUserMediaWithDevice(kind, baseConstraints, deviceId) { 13531 const key = kind === 'audio' ? 'audio' : 'video'; 13532 // Mic swaps must mirror open-mic: audio-only gUM. Never open a camera 13533 // track here â that steals the live webcam and blacks the dock. 13534 if (kind === 'audio') { 13535 const buildAudio = (useExact) => ({ 13536 video: false, 13537 audio: { 13538 ...baseConstraints, 13539 ...(deviceId ? { deviceId: useExact ? { exact: deviceId } : { ideal: deviceId } } : {}) 13540 } 13541 }); 13542 try { 13543 return await navigator.mediaDevices.getUserMedia(buildAudio(true)); 13544 } catch (e) { 13545 if (!deviceId || e.name !== 'OverconstrainedError') throw e; 13546 return await navigator.mediaDevices.getUserMedia(buildAudio(false)); 13547 } 13548 } 13549 const build = (useExact) => ({ 13550 [key]: { 13551 ...baseConstraints, 13552 ...(deviceId ? { deviceId: useExact ? { exact: deviceId } : { ideal: deviceId } } : {}) 13553 } 13554 }); 13555 const wc = window.chatCrackWebcam; 13556 try { 13557 if (wc && typeof wc.ccCompatGetUserMedia === 'function') { 13558 return await wc.ccCompatGetUserMedia(build(true), { includeAudio: false }); 13559 } 13560 return await navigator.mediaDevices.getUserMedia(build(true)); 13561 } catch (e) { 13562 if (!deviceId || e.name !== 'OverconstrainedError') throw e; 13563 if (wc && typeof wc.ccCompatGetUserMedia === 'function') { 13564 return await wc.ccCompatGetUserMedia(build(false), { includeAudio: false }); 13565 } 13566 return await navigator.mediaDevices.getUserMedia(build(false)); 13567 } 13568 } 13569 13570 /** Ask SFU viewers to pull a keyframe after replaceTrack (avoids black docks). */ 13571 _nudgeViewersAfterVideoTrackReplace() { 13572 try { 13573 const roomId = this.roomId || (this.userId ? ('chatcrack_webcam_' + String(this.userId).toLowerCase()) : null); 13574 if (!roomId || !this.socket || !this.socket.connected) return; 13575 this.socket.emit('chatcrack:force-keyframes', { 13576 roomId, 13577 userId: this.userId || window.currentUsername || window.me?.username 13578 }); 13579 } catch (_) {} 13580 } 13581 13582 /** Swap camera via replaceTrack only â viewers keep the same producer. */ 13583 async applyVideoDeviceFromSettings(opts) { 13584 const forceFreshTrack = !!(opts && opts.forceFreshTrack); 13585 const prod = this.cameraProducer; 13586 if (!prod || prod.closed) return false;
13587 const now = Date.now(); 13588 if (this._videoReconnectInProgress) return false; 13589 // Settings Save / forceFreshTrack bypass the 3s throttle â device swaps must apply. 13590 if (!forceFreshTrack && !window.chatCrackWebcam?._settingsDeviceSwapInProgress 13591 && this._lastVideoReconnectAt && now - this._lastVideoReconnectAt < 3000) { 13592 return false; 13593 } 13594 this._videoReconnectInProgress = true; 13595 this._suppressTransportCloseRecovery = true; 13596 this._suppressVideoTrackEnded = true; 13597 this._lastVideoSwapError = null; 13598 const wc = window.chatCrackWebcam; 13599 try { 13600 const vc = this._stripMediaBitrate(wc?.getVideoConstraints?.() || {}); 13601 const deviceId = wc?.webcamSettings?.videoDeviceId; 13602 const existing = this.localStream?.getVideoTracks?.()[0]; 13603 const samePhysicalDevice = existing && deviceId 13604 && this._trackDeviceId(existing) === deviceId; 13605 const wantW = Number(vc.width && (vc.width.ideal || vc.width.max || vc.width)) || 0; 13606 const wantH = Number(vc.height && (vc.height.ideal || vc.height.max || vc.height)) || 0; 13607 const wantFps = Number(vc.frameRate && (vc.frameRate.ideal || vc.frameRate.max || vc.frameRate)) || 0; 13608 13609 if (!forceFreshTrack && samePhysicalDevice && typeof existing.applyConstraints === 'function') { 13610 try { 13611 await existing.applyConstraints(vc); 13612 const got = (typeof existing.getSettings === 'function' && existing.getSettings()) || {}; 13613 const wOk = !wantW || !got.width || Math.abs(got.width - wantW) <= Math.max(24, wantW * 0.35); 13614 const hOk = !wantH || !got.height || Math.abs(got.height - wantH) <= Math.max(24, wantH * 0.35); 13615 const fOk = !wantFps || !got.frameRate || got.frameRate >= Math.max(1, wantFps * 0.55); 13616 if (wOk && hOk && fOk) { 13617 await prod.replaceTrack({ track: existing }); 13618 this._refreshLocalWebcamPreview(); 13619 const q = wc?.webcamSettings?.videoQuality 13620 || document.getElementById('videoQualitySelect')?.value 13621 || 'medium'; 13622 if (typeof this.updateBitrate === 'function') { 13623 await this.updateBitrate(q); 13624 } 13625 this._lastVideoReconnectAt = Date.now(); 13626 this._reassertCameraOffState(); 13627 this._nudgeViewersAfterVideoTrackReplace(); 13628 _chatLog('[WebRTC] ð¹ Video quality updated on same device', got); 13629 return true; 13630 } 13631 console.warn('[WebRTC] applyConstraints did not reach target resolution â opening a fresh track', { 13632 want: { w: wantW, h: wantH, fps: wantFps }, 13633 got: { w: got.width, h: got.height, fps: got.frameRate } 13634 }); 13635 } catch (acErr) { 13636 console.warn('[WebRTC] applyConstraints camera failed:', acErr?.message || acErr); 13637 } 13638 } 13639 13640 let fresh; 13641 fresh = await this._getUserMediaWithDevice('video', vc, deviceId); 13642 const newTrack = fresh.getVideoTracks()[0]; 13643 if (!newTrack) return false; 13644 const old = this.localStream?.getVideoTracks?.()[0]; 13645 // replaceTrack FIRST â stopping the old track before a successful 13646 // replace left publishers/viewers on a black/dead producer. 13647 await prod.replaceTrack({ track: newTrack }); 13648 if (!this.localStream) { 13649 this.localStream = fresh; 13650 } else if (!this.localStream.getVideoTracks().includes(newTrack)) { 13651 this.localStream.addTrack(newTrack); 13652 } 13653 if (old && old !== newTrack) { 13654 try { old.onended = null; } catch (_) {} 13655 try { this.localStream.removeTrack(old); } catch (_) {} 13656 try { old.stop(); } catch (_) {} 13657 } 13658 try {
13659 fresh.getVideoTracks().forEach((t) => { 13660 if (t !== newTrack) try { t.stop(); } catch (_) {} 13661 }); 13662 } catch (_) {} 13663 this._bindVideoTrackDeviceRecovery(newTrack); 13664 this._syncWebcamManagerStream(); 13665 this._refreshLocalWebcamPreview(); 13666 // Sender may point at the old track â refresh for bitrate updates 13667 try { 13668 const pc = this.sendTransport && this.sendTransport._handler && this.sendTransport._handler._pc; 13669 const senders = pc && typeof pc.getSenders === 'function' ? pc.getSenders() : []; 13670 this.sender = senders.find(s => s.track && s.track.kind === 'video') || this.sender; 13671 } catch (_) {} 13672 const q = wc?.webcamSettings?.videoQuality 13673 || document.getElementById('videoQualitySelect')?.value 13674 || 'medium'; 13675 if (typeof this.updateBitrate === 'function') { 13676 await this.updateBitrate(q); 13677 } 13678 this._lastVideoReconnectAt = Date.now(); 13679 // Fresh tracks are enabled by default â keep Cam Off if user paused video. 13680 this._reassertCameraOffState(); 13681 this._nudgeViewersAfterVideoTrackReplace(); 13682 setTimeout(() => this._nudgeViewersAfterVideoTrackReplace(), 500); 13683 _chatLog('[WebRTC] ð¹ Video track replaced (same producer, viewers stay connected)'); 13684 return true; 13685 } catch (e) { 13686 console.warn('[WebRTC] applyVideoDeviceFromSettings failed:', e && e.name, e); 13687 // Kept so the settings toast can say what actually went wrong instead 13688 // of offering the same generic advice for every cause. 13689 this._lastVideoSwapError = e; 13690 return false; 13691 } finally { 13692 this._videoReconnectInProgress = false; 13693 setTimeout(() => { 13694 this._suppressVideoTrackEnded = false; 13695 if (!window.chatCrackWebcam?._settingsDeviceSwapInProgress) { 13696 this._suppressTransportCloseRecovery = false; 13697 } 13698 this._reassertCameraOffState(); 13699 }, 1200); 13700 } 13701 } 13702 13703 /** Apply resolution/FPS from quality preset without tearing down producers. */ 13704 async applyVideoConstraintsFromSettings() { 13705 const track = this.localStream?.getVideoTracks?.()[0]; 13706 const wc = window.chatCrackWebcam; 13707 if (!track || !wc) return false; 13708 const vc = this._stripMediaBitrate(wc.getVideoConstraints?.() || {}); 13709 const deviceId = wc.webcamSettings?.videoDeviceId; 13710 try { 13711 await track.applyConstraints({ 13712 ...vc, 13713 ...(deviceId ? { deviceId: { ideal: deviceId } } : {}) 13714 }); 13715 this._refreshLocalWebcamPreview(); 13716 return true; 13717 } catch (e) { 13718 console.warn('[WebRTC] applyVideoConstraintsFromSettings failed:', e?.message || e); 13719 return false; 13720 } 13721 } 13722 13723 /** 13724 * Swap mic via replaceTrack only â same pattern as open-mic applyMicDevice: 13725 * plain audio-only getUserMedia + replaceTrack. Never touches the camera 13726 * track/producer, so the dock stays lit while the mic switches. 13727 */ 13728 async applyAudioDeviceFromSettings() { 13729 const prod = this.producers?.get?.('mic') || this.audioProducer; 13730 if (!prod || prod.closed) return false; 13731 const now = Date.now(); 13732 if (this._audioReconnectInProgress) return false; 13733 // Settings Save bypasses throttle so mic device swaps always apply. 13734 if (!window.chatCrackWebcam?._settingsDeviceSwapInProgress 13735 && this._lastAudioReconnectAt && now - this._lastAudioReconnectAt < 3000) { 13736 return false; 13737 } 13738 this._audioReconnectInProgress = true; 13739 this._suppressTransportCloseRecovery = true; 13740 this._suppressAudioTrackEnded = true; 13741 // Mic-only swap must not trigger video-track-ended camera recovery. 13742 this._suppressVideoTrackEnded = true; 13743 const wasMuted = !!this.isMicMuted; 13744 const wc = window.chatCrackWebcam; 13745 try { 13746 // Saved id may belong to a previous session â re-pin from the label 13747 // before asking for it, or gUM silently lands on the default mic. 13748 if (wc && typeof wc._remapSavedDeviceIds === 'function' && navigator.mediaDevices?.enumerateDevices) { 13749 try { 13750 const devices = await navigator.mediaDevices.enumerateDevices(); 13751 if (typeof wc._deviceListIsTrustworthy !== 'function' 13752 || wc._deviceListIsTrustworthy(devices)) { 13753 wc._remapSavedDeviceIds(devices); 13754 } 13755 } catch (_) {} 13756 } 13757 const ac = this._stripMediaBitrate(wc?.getAudioConstraints?.() || {}); 13758 const deviceId = wc?.webcamSettings?.audioDeviceId; 13759 const existing = this.localStream?.getAudioTracks?.()[0]; 13760 const wantLabel = String(wc?.webcamSettings?.audioDeviceLabel || '').trim().toLowerCase(); 13761 const haveLabel = String(existing && existing.label || '').trim().toLowerCase(); 13762 const samePhysicalDevice = !!(existing && ( 13763 (deviceId && this._trackDeviceId(existing) === deviceId) 13764 || (wantLabel && haveLabel && wantLabel === haveLabel) 13765 )); 13766 13767 // Ended / forced-reopen tracks cannot be revived with applyConstraints â 13768 // always take a fresh gUM (unmute recovery sets _forceMicDeviceReopen). 13769 if (!this._forceMicDeviceReopen 13770 && samePhysicalDevice && existing.readyState === 'live' 13771 && typeof existing.applyConstraints === 'function') { 13772 try {
13773 // Quality-only: applyConstraints without replaceTrack when the 13774 // producer already owns this track (avoids a crackle blip). 13775 await existing.applyConstraints(ac); 13776 if (prod.track !== existing) { 13777 await prod.replaceTrack({ track: existing }); 13778 } 13779 existing.enabled = !wasMuted; 13780 this._bindMicTrackDeviceRecovery(existing); 13781 if (wasMuted) { await prod.pause(); this.isMicMuted = true; } 13782 else { await prod.resume(); this.isMicMuted = false; } 13783 this._lastAudioReconnectAt = Date.now(); 13784 this._ensureSelfDockMuted(); 13785 _chatLog('[WebRTC] ðï¸ Audio quality updated on same device'); 13786 return true; 13787 } catch (acErr) { 13788 console.warn('[WebRTC] applyConstraints mic switch failed:', acErr?.message || acErr); 13789 } 13790 } 13791 13792 // Release-first reopen (clock-stall heal). Asking for the same device 13793 // while the old track still holds it can hand back the very stream 13794 // that stalled â Firefox only rebuilds its audio stream once the last 13795 // consumer lets go. This is what switching devices by hand achieves. 13796 let released = null; 13797 if (this._micReleaseBeforeReopen && existing) { 13798 released = existing; 13799 try { existing.onended = null; } catch (_) {} 13800 try { existing.stop(); } catch (_) {} 13801 await new Promise((r) => setTimeout(r, 120)); 13802 } 13803 13804 // Open-mic style: { audio, video: false } â never grab the camera. 13805 let fresh; 13806 try { 13807 fresh = await this._getUserMediaWithDevice('audio', ac, deviceId); 13808 } catch (gumErr) { 13809 if (!released) throw gumErr; 13810 // We already let the old device go â a retry without the pinned id 13811 // is better than leaving the user with no mic at all. 13812 console.warn('[WebRTC] reopen after release failed, retrying default:', gumErr?.message || gumErr); 13813 fresh = await this._getUserMediaWithDevice('audio', ac, null); 13814 } 13815 const newTrack = fresh.getAudioTracks()[0]; 13816 if (!newTrack) return false; 13817 // Stop any accidental video tracks from a bad gUM path before they 13818 // can collide with the live webcam device. 13819 try { 13820 fresh.getVideoTracks().forEach((vt) => { 13821 try { vt.stop(); } catch (_) {} 13822 }); 13823 } catch (_) {} 13824 const old = this.localStream?.getAudioTracks?.()[0]; 13825 this._suppressAudioTrackEnded = true; 13826 newTrack.enabled = !wasMuted; 13827 await prod.replaceTrack({ track: newTrack }); 13828 13829 if (this.localStream) { 13830 if (old && old !== newTrack) { 13831 try { this.localStream.removeTrack(old); } catch (_) {} 13832 } 13833 if (!this.localStream.getAudioTracks().includes(newTrack)) { 13834 this.localStream.addTrack(newTrack); 13835 } 13836 } else { 13837 this.localStream = fresh; 13838 } 13839 if (old && old !== newTrack) { 13840 try { old.onended = null; } catch (_) {} 13841 try { old.stop(); } catch (_) {} 13842 } 13843 try { 13844 fresh.getAudioTracks().forEach((t) => { 13845 if (t !== newTrack) try { t.stop(); } catch (_) {} 13846 }); 13847 } catch (_) {} 13848 13849 this._bindMicTrackDeviceRecovery(newTrack); 13850 this._syncWebcamManagerStream(); 13851 // Keep the existing video track on preview elements â do not rebuild 13852 // srcObject (that can flash black). Only re-assert self mute. 13853 this._ensureSelfDockMuted(); 13854 if (wasMuted) { 13855 await prod.pause(); 13856 this.isMicMuted = true; 13857 } else { 13858 try { newTrack.enabled = true; } catch (_) {} 13859 await prod.resume(); 13860 this.isMicMuted = false;
13861 } 13862 this._lastAudioReconnectAt = Date.now(); 13863 _chatLog('[WebRTC] ðï¸ Audio track replaced (muted=' + wasMuted + ')'); 13864 return true; 13865 } catch (e) { 13866 console.warn('[WebRTC] applyAudioDeviceFromSettings failed:', e); 13867 return false; 13868 } finally { 13869 this._audioReconnectInProgress = false; 13870 setTimeout(() => { 13871 this._suppressAudioTrackEnded = false; 13872 this._suppressVideoTrackEnded = false; 13873 if (!window.chatCrackWebcam?._settingsDeviceSwapInProgress) { 13874 this._suppressTransportCloseRecovery = false; 13875 } 13876 }, 1200); 13877 } 13878 } 13879 13880 /** Keep local self-view tiles muted so the mic never loops into speakers. */ 13881 _ensureSelfDockMuted() { 13882 try { 13883 const u = window.currentUsername || window.me?.username; 13884 if (!u) return; 13885 const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(u) : u; 13886 document.querySelectorAll( 13887 `.webcam-dock[data-username="${esc}"] video,` + 13888 `.floating-webcam[data-username="${esc}"] video,` + 13889 '#webcamPreview, #webcamVideo, #localWebcamVideo' 13890 ).forEach((el) => { 13891 try { el.muted = true; } catch (_) {} 13892 }); 13893 } catch (_) {} 13894 } 13895 13896 async reconnectAudioAfterDeviceChange() { 13897 return this.applyAudioDeviceFromSettings(); 13898 } 13899 13900 pickH264CB(rtpCapabilities) { 13901 if (!rtpCapabilities?.codecs) return undefined; 13902 const idx = rtpCapabilities.codecs.findIndex( 13903 c => c.mimeType?.toLowerCase() === 'video/h264' && 13904 (c.parameters?.['profile-level-id']?.toLowerCase?.() === '42e01f' || 13905 c.parameters?.['profile-level-id']?.toLowerCase?.() === '42001f') 13906 ); 13907 return idx >= 0 ? { mimeType: 'video/h264' } : undefined; 13908 } 13909 13910 teardownConsumerHandlers() { 13911 if (!this._consumerHandlersBound || !this.socket) return; 13912 if (this._onNewProducer) this.socket.off('chatcrack:new-producer', this._onNewProducer); 13913 if (this._onRetryConsume) this.socket.off('chatcrack:retry-consume', this._onRetryConsume); 13914 this._consumerHandlersBound = false; 13915 } 13916 13917 setupConsumerHandlers() { 13918 this.teardownConsumerHandlers(); 13919 // 1) On new producer announcements 13920 this._onNewProducer = ({ producerId, userId, kind, roomId }) => { 13921 if (roomId !== this.roomId) return; 13922 if (userId === this.userId || (typeof window._ccIsOwnMediaUser === 'function' && window._ccIsOwnMediaUser(userId))) return; 13923 13924 // Audio and video are separate producers (vcall cam-on, webcam A/V, screen+tab audio). 13925 // Do NOT stop live tracks here â that paints dock tiles black until the new 13926 // consumer arrives. consumeFromProducer adds the new track then retires old ones. 13927 const kindLc = String(kind || '').toLowerCase(); 13928 if (this.userStreams && this.userStreams.has(userId) && !kindLc) { 13929 // Legacy announce without kind â fall back to full replace. 13930 const oldStream = this.userStreams.get(userId); 13931 if (oldStream) { 13932 oldStream.getTracks().forEach((track) => { 13933 try { track.stop(); } catch (_) {} 13934 }); 13935 this.userStreams.delete(userId); 13936 } 13937 if (this.consumedProducers) { 13938 const keysToRemove = []; 13939 this.consumedProducers.forEach((key) => { 13940 if (key.startsWith(`${userId}-`)) keysToRemove.push(key); 13941 }); 13942 keysToRemove.forEach((key) => this.consumedProducers.delete(key)); 13943 } 13944 } 13945 13946 // Drop only this producerId's dedupe key (new id); keep the other kind's consumer. 13947 if (this.consumedProducers) { 13948 this.consumedProducers.delete(`${userId}-${producerId}`); 13949 } 13950 if (this.consumingProducers) { 13951 this.consumingProducers.delete(`${userId}-${producerId}`); 13952 } 13953 13954 this.consumeFromProducer({ producerId, producerUserId: userId }); 13955 }; 13956 this.socket.on('chatcrack:new-producer', this._onNewProducer); 13957 13958 // 2) If server queued us before producer existed, retry comes here 13959 this._onRetryConsume = (data) => { 13960 // data = { roomId, rtpCapabilities, producerId? , producerUserId? } 13961 if (data.roomId === this.roomId) { 13962 if (typeof window._ccIsOwnMediaUser === 'function' && window._ccIsOwnMediaUser(data.producerUserId)) return; 13963 const producerId = data.producerId; 13964 this.consumeFromProducer({ producerId, producerUserId: data.producerUserId }); 13965 } 13966 }; 13967 this.socket.on('chatcrack:retry-consume', this._onRetryConsume); 13968 this._consumerHandlersBound = true; 13969 13970 // 3) Consume already-existing producers when we join late 13971 if (this._skipInitialProducerList) return; 13972 (async () => { 13973 try { 13974 const listed = await this.emitAsync('chatcrack:list-producers', { roomId: this.roomId }); 13975 if (listed?.ok && Array.isArray(listed.list)) { 13976 for (const p of listed.list) { 13977 if (p.userId === this.userId) continue; 13978 if (typeof window._ccIsOwnMediaUser === 'function' && window._ccIsOwnMediaUser(p.userId)) continue; 13979 this.consumeFromProducer({ producerId: p.id, producerUserId: p.userId }); 13980 } 13981 } 13982 } catch (error) { 13983 console.warn("[WebRTC] Failed to list existing producers:", error); 13984 } 13985 })(); 13986 } 13987 13988 async connectAndStart() { 13989 _chatLog('[WebRTC] ð Using new streamlined connection flow...'); 13990 await this._ensureSocketConnected(); 13991 await this._joinRoom(this.roomId, this.userId); 13992 await this._ensureDeviceLoaded(); 13993 await this._createTransports(); 13994 this.setupConsumerHandlers(); // Set up consumption event listeners 13995 await this.startProducing(); 13996 } 13997 13998 /** 13999 * Owns the in-flight dedupe key for a consume attempt. 14000 * 14001 * The attempt below can throw anywhere after the key is claimed (transport 14002 * closed mid-consume, bad rtpParameters, a listener hook blowing up). When 14003 * that happened the key was never released, so every later resync saw the 14004 * producer as "already being consumed" and skipped it â that listener stayed 14005 * deaf to that speaker until a page refresh, even though the roster still lit 14006 * them up red. Releasing in a finally makes a failed attempt retryable. 14007 */ 14008 async consumeFromProducer(args) { 14009 const producerId = args && args.producerId; 14010 const producerUserId = args && args.producerUserId; 14011 if (typeof window._ccIsOwnMediaUser === 'function' && window._ccIsOwnMediaUser(producerUserId)) { 14012 return; 14013 } 14014 const consumerKey = `${producerUserId}-${producerId}`; 14015 if (!this.consumingProducers) this.consumingProducers = new Set(); 14016 if (this.consumingProducers.has(consumerKey)) return; 14017 this.consumingProducers.add(consumerKey); 14018 this._consumeAttemptStartedAt = this._consumeAttemptStartedAt || new Map(); 14019 this._consumeAttemptStartedAt.set(consumerKey, Date.now()); 14020 try { 14021 return await this._consumeFromProducerAttempt({ producerId, producerUserId }); 14022 } finally { 14023 try { 14024 this.consumingProducers.delete(consumerKey); 14025 this._consumeAttemptStartedAt.delete(consumerKey); 14026 } catch (_) {} 14027 } 14028 } 14029 14030 async _consumeFromProducerAttempt({ producerId, producerUserId }) { 14031 if (!this.recvTransport) throw new Error('recvTransport not ready'); 14032 const roomId = this.roomId; 14033 const isScreenRoom = String(roomId || '').startsWith('chatcrack_screen_'); 14034 const isOpenMicRoom = String(roomId || '').startsWith('chatcrack_openmic_'); 14035 const isVcallRoom = String(roomId || '').startsWith('chatcrack_vcall_'); 14036 14037 if ((isScreenRoom || isOpenMicRoom || isVcallRoom) && typeof window.ccPrefetchHeavyScriptForMediaRoom === 'function') { 14038 await window.ccPrefetchHeavyScriptForMediaRoom(roomId); 14039 } 14040 14041 if (isOpenMicRoom && producerUserId 14042 && typeof window.ccIsOpenMicAudioBlocked === 'function' 14043 && window.ccIsOpenMicAudioBlocked(producerUserId)) { 14044 return; 14045 } 14046 14047 // Prevent duplicate consumption of the same producer. The in-flight half 14048 // of this guard lives in consumeFromProducer() so it is always released. 14049 const consumerKey = `${producerUserId}-${producerId}`; 14050 if (this.consumedProducers && this.consumedProducers.has(consumerKey)) { 14051 14052 return; 14053 } 14054 14055 if (!this.consumedProducers) { 14056 this.consumedProducers = new Set(); 14057 } 14058 14059 // Ask server to create its Consumer & give us rtpParameters for the client-side Consumer 14060 let id, kind, rtpParameters, producerPaused; 14061 try { 14062 const result = await ccRequestConsume(this.socket, { 14063 roomId, 14064 rtpCapabilities: getDeviceRecvRtpCapabilities(this.device), 14065 producerId, 14066 producerUserId, 14067 transportId: this.recvTransport && this.recvTransport.id 14068 }); 14069 id = result.id; 14070 kind = result.kind; 14071 rtpParameters = result.rtpParameters; 14072 producerPaused = result.producerPaused; 14073 } catch (error) { 14074 // Clean up in-progress flag on error 14075 if (this.consumingProducers) { 14076 this.consumingProducers.delete(consumerKey); 14077 } 14078 console.error(`[WebRTC] Failed to create consumer for producer ${producerId} from ${producerUserId}:`, error); 14079 throw error; 14080 } 14081 14082 // Create client Consumer 14083 const consumer = await this.recvTransport.consume({ id, producerId, kind, rtpParameters }); 14084 try { 14085 consumer._ccProducerUserId = producerUserId; 14086 if (!this.consumers) this.consumers = new Map(); 14087 this.consumers.set(`${kind}-${producerId}`, consumer); 14088 } catch (_) {} 14089 14090 if (kind === 'audio') { 14091 this._stabilizeRemoteAudioPlayoutSoon(this.recvTransport, consumer.track); 14092 this._bindRemoteMicUnmuteNudge(consumer, producerUserId); 14093 } 14094 14095 // When remote producer closes, show disconnected overlay but keep tile (same as isolated viewer flow) 14096 consumer.on('producerclose', () => { 14097 if (isOpenMicRoom) { 14098 if (window.chatCrackOpenMic && typeof window.chatCrackOpenMic.detachRemoteAudio === 'function') { 14099 // Pass the producerId so a LATE close from the publisher's OLD 14100 // producer (they reconnected) can't tear down the freshly 14101 // re-consumed audio â that race left listeners silently deaf. 14102 window.chatCrackOpenMic.detachRemoteAudio(producerUserId, { producerId }); 14103 } 14104 return; 14105 } 14106 if (isVcallRoom) { 14107 if (kind === 'audio' && typeof window.ccVoiceCallDetachRemoteAudio === 'function') { 14108 window.ccVoiceCallDetachRemoteAudio(producerUserId, { producerId }); 14109 } 14110 if (kind === 'video' && typeof window.ccVoiceCallDetachRemoteVideo === 'function') { 14111 window.ccVoiceCallDetachRemoteVideo(producerUserId, { producerId }); 14112 } 14113 return; 14114 } 14115 if (isScreenRoom) { 14116 return; 14117 } 14118 // Mic re-produce closes audio only â keep the video dock live. 14119 if (kind === 'audio') return; 14120 const closedPid = consumer.producerId || producerId; 14121 try { 14122 const tile = (typeof window._ccFindWebcamDockTile === 'function') 14123 ? window._ccFindWebcamDockTile(producerUserId) : null; 14124 const vid = tile && tile.querySelector('video'); 14125 if (vid && vid._ccVideoProducerId && String(vid._ccVideoProducerId) !== String(closedPid)) { 14126 return; 14127 } 14128 } catch (_) {} 14129 console.log('[CAM_CLOSE_DEBUG] Consumer producerclose (main) for', producerUserId, '- grace before overlay'); 14130 if (typeof window._ccScheduleWebcamProducerCloseGrace === 'function') { 14131 window._ccScheduleWebcamProducerCloseGrace(producerUserId, { producerId: closedPid }); 14132 return; 14133 } 14134 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.showStreamDisconnectedState === 'function') { 14135 window.chatCrackWebcam.showStreamDisconnectedState(producerUserId); 14136 } else if (window.chatCrackWebcam && typeof window.chatCrackWebcam.removeDockedWebcam === 'function') { 14137 window.chatCrackWebcam.removeDockedWebcam(producerUserId, 'producer_closed'); 14138 } 14139 }); 14140 14141 // Get or create the combined stream for this user 14142 if (!this.userStreams) { 14143 this.userStreams = new Map(); 14144 } 14145 14146 let stream = this.userStreams.get(producerUserId); 14147 if (!stream) { 14148 stream = new MediaStream(); 14149 this.userStreams.set(producerUserId, stream); 14150 } 14151 14152 // CRITICAL: Check if this exact track is already in the stream 14153 const existingTrack = stream.getTracks().find(t => t.id === consumer.track.id); 14154 if (existingTrack) { 14155 console.warn(`[AUDIO DEBUG] â ï¸ DUPLICATE DETECTED: Track ${consumer.track.id} (${kind}) already exists in stream for ${producerUserId}! Skipping add.`); 14156 _chatLog(`[AUDIO DEBUG] Current stream tracks:`, stream.getTracks().map(t => ({ kind: t.kind, id: t.id, enabled: t.enabled })));
14157 // Always release the in-progress lock â leaving it set blocked later consumes forever. 14158 if (this.consumingProducers) this.consumingProducers.delete(consumerKey); 14159 if (this.consumedProducers) this.consumedProducers.add(consumerKey); 14160 if (isOpenMicRoom) { 14161 try { 14162 if (kind === 'audio' && window.chatCrackOpenMic && typeof window.chatCrackOpenMic.attachRemoteAudio === 'function') { 14163 window.chatCrackOpenMic.attachRemoteAudio(producerUserId, stream, producerId); 14164 } 14165 } catch (_) {} 14166 } 14167 return consumer; 14168 } 14169 14170 // Add first, then retire old same-kind tracks (avoids black flash). 14171 if (window.WEBCAM_DEBUG) { 14172 _chatLog(`[AUDIO DEBUG] â Adding ${kind} track ${consumer.track.id} to stream for ${producerUserId}`); 14173 _chatLog(`[AUDIO DEBUG] Stream before add - tracks:`, stream.getTracks().map(t => ({ kind: t.kind, id: t.id }))); 14174 } 14175 stream.addTrack(consumer.track); 14176 if (window.WEBCAM_DEBUG) { 14177 _chatLog(`[AUDIO DEBUG] Stream after add - tracks:`, stream.getTracks().map(t => ({ kind: t.kind, id: t.id }))); 14178 } 14179 const oldTracks = stream.getTracks().filter((track) => 14180 track && track.kind === kind && track.id !== consumer.track.id); 14181 if (oldTracks.length > 0) { 14182 oldTracks.forEach((track) => { 14183 _chatLog(`[AUDIO DEBUG] ð§¹ Retiring old ${kind} track:`, track.id, 'for', producerUserId); 14184 try { stream.removeTrack(track); } catch (_) {} 14185 try { track.stop(); } catch (_) {} 14186 }); 14187 } 14188 14189 if (isScreenRoom) { 14190 try { 14191 if (typeof this._onScreenStreamUpdate === 'function') { 14192 this._onScreenStreamUpdate(); 14193 } else if (kind === 'video' || kind === 'audio') { 14194 const screenVideo = document.querySelector(`.screen-dock[data-username="${CSS.escape(producerUserId)}"] video`) 14195 || document.querySelector(`.floating-screen[data-username="${CSS.escape(producerUserId)}"] video`); 14196 if (screenVideo && window.chatCrackScreenShare && typeof window.chatCrackScreenShare.attachScreenPlayback === 'function') { 14197 window.chatCrackScreenShare.attachScreenPlayback(screenVideo, stream); 14198 } else if (screenVideo) { 14199 screenVideo.srcObject = stream; 14200 screenVideo.muted = false; 14201 screenVideo.volume = 1; 14202 screenVideo.play().catch(() => {}); 14203 } 14204 } 14205 } catch (e) { 14206 console.warn('[ScreenShare] dock attach failed:', e); 14207 } 14208 if (this.consumingProducers) { 14209 this.consumingProducers.delete(consumerKey); 14210 } 14211 if (this.consumedProducers) { 14212 this.consumedProducers.add(consumerKey); 14213 } 14214 if (producerPaused === true) { 14215 this.socket.emit('chatcrack:resume-consumer', { roomId, consumerId: id }, (res) => { 14216 if (!res || res.ok === false) console.warn('resume-consumer failed', res); 14217 }); 14218 } 14219 return consumer; 14220 } 14221 14222 if (isOpenMicRoom) { 14223 try { 14224 if (kind === 'audio' && window.chatCrackOpenMic && typeof window.chatCrackOpenMic.attachRemoteAudio === 'function') { 14225 window.chatCrackOpenMic.attachRemoteAudio(producerUserId, stream, producerId); 14226 } 14227 } catch (e) { 14228 console.warn('[OpenMic] remote audio attach failed:', e); 14229 } 14230 if (this.consumingProducers) { 14231 this.consumingProducers.delete(consumerKey); 14232 } 14233 if (this.consumedProducers) { 14234 this.consumedProducers.add(consumerKey); 14235 } 14236 const resumeOm = () => { 14237 try { consumer.resume().catch(function () {}); } catch (_) {} 14238 try { 14239 this.socket.emit('chatcrack:resume-consumer', { roomId, consumerId: id }, function () {}); 14240 } catch (_) {} 14241 }; 14242 resumeOm(); 14243 try { 14244 consumer.on('producerresume', function () { 14245 resumeOm(); 14246 try { 14247 if (window.chatCrackOpenMic && typeof window.chatCrackOpenMic._onRemoteProducerResumed === 'function') { 14248 window.chatCrackOpenMic._onRemoteProducerResumed(producerUserId); 14249 } 14250 } catch (_) {} 14251 }); 14252 } catch (_) {} 14253 return consumer; 14254 } 14255 14256 if (isVcallRoom) { 14257 try { 14258 if (kind === 'audio' && typeof window.ccVoiceCallAttachRemoteAudio === 'function') { 14259 window.ccVoiceCallAttachRemoteAudio(producerUserId, stream, producerId); 14260 } 14261 if (kind === 'video' && typeof window.ccVoiceCallAttachRemoteVideo === 'function') { 14262 window.ccVoiceCallAttachRemoteVideo(producerUserId, stream, producerId); 14263 } 14264 } catch (e) { 14265 console.warn('[VCall] remote media attach failed:', e); 14266 } 14267 if (this.consumingProducers) { 14268 this.consumingProducers.delete(consumerKey); 14269 } 14270 if (this.consumedProducers) { 14271 this.consumedProducers.add(consumerKey); 14272 } 14273 if (producerPaused === true) { 14274 this.socket.emit('chatcrack:resume-consumer', { roomId, consumerId: id }, (res) => { 14275 if (!res || res.ok === false) console.warn('resume-consumer failed', res); 14276 }); 14277 } 14278 return consumer; 14279 } 14280 14281 // CRITICAL: Verify we don't have duplicate audio tracks 14282 const audioTracks = stream.getAudioTracks(); 14283 if (audioTracks.length > 1) { 14284 console.error(`[AUDIO DEBUG] â DUPLICATE AUDIO TRACKS DETECTED for ${producerUserId}! Found ${audioTracks.length} audio tracks:`, 14285 audioTracks.map(t => ({ id: t.id, enabled: t.enabled, readyState: t.readyState }))); 14286 // Remove all but the most recent audio track 14287 const tracksToRemove = audioTracks.slice(0, -1); 14288 tracksToRemove.forEach(track => { 14289 _chatLog(`[AUDIO DEBUG] ð§¹ Removing duplicate audio track:`, track.id); 14290 track.stop(); 14291 stream.removeTrack(track); 14292 }); 14293 } 14294 14295 // After attaching consumer stream to the dock tile video element 14296 try { 14297 // Find the video element for this user's docked webcam 14298 const dockTile = (typeof window._ccFindWebcamDockTile === 'function') 14299 ? window._ccFindWebcamDockTile(producerUserId) 14300 : document.querySelector(`.webcam-dock[data-username="${CSS.escape(producerUserId)}"]`); 14301 const videoElement = dockTile ? dockTile.querySelector('video') : null; 14302 // Only monitor video consumers for bitrate 14303 if (consumer?.track?.kind === 'video') { 14304 window.WebcamBitrate?.observeRemoteConsumer(producerUserId, consumer, videoElement); 14305 } 14306 } catch(e) { console.error('[BITRATE ERROR] observeRemoteConsumer failed:', e); } 14307 14308 14309 14310 // DISABLED: Legacy remote video attachment to prevent duplicate video elements 14311 // The new mediasoup flow creates properly styled video elements in the webcam panel 14312 // try { 14313 // this.ensureMediaContainers(); 14314 // var elId = 'remote_' + String(producerId) + '_' + String(kind); 14315 // var el = document.getElementById(elId); 14316 // if (!el) { 14317 // el = document.createElement(kind === 'video' ? 'video' : 'audio'); 14318 // el.id = elId; 14319 // el.autoplay = true;
14320 // el.playsInline = true; 14321 // el.muted = (kind === 'audio') ? false : true; 14322 // (document.getElementById('remoteContainer') || document.body).appendChild(el); 14323 // } 14324 // el.srcObject = stream; 14325 // el.play && el.play().catch(function(){}); 14326 // } catch (_) {} 14327 14328 // Webcam room only â screen share uses chatcrack_screen_* and must not touch webcam tiles 14329 if (kind === 'video' && producerUserId && !isScreenRoom) { 14330 try { 14331 const dockTile = (typeof window._ccFindWebcamDockTile === 'function') 14332 ? window._ccFindWebcamDockTile(producerUserId) 14333 : document.querySelector(`.webcam-dock[data-username="${producerUserId}"]`); 14334 const dockedWebcam = dockTile 14335 ? (dockTile.querySelector('.webcam-video') || dockTile) 14336 : null; 14337 if (dockedWebcam) { 14338 14339 14340 // Check if video element already exists 14341 let videoEl = dockedWebcam.querySelector('video'); 14342 if (!videoEl) { 14343 // TARGETED CLEANUP: Remove only video elements from THIS specific user's container 14344 const existingVideos = dockedWebcam.querySelectorAll('video'); 14345 14346 existingVideos.forEach((video, index) => { 14347 14348 video.remove(); 14349 }); 14350 14351 // Clear the specific container completely 14352 dockedWebcam.innerHTML = ''; 14353 14354 // Wait a moment to ensure DOM cleanup 14355 await new Promise(resolve => setTimeout(resolve, 10)); 14356 14357 // Create video element with proper positioning 14358 videoEl = document.createElement('video'); 14359 videoEl.autoplay = true; 14360 videoEl.playsInline = true; 14361 videoEl.muted = !!(typeof window._ccIsOwnMediaUser === 'function' && window._ccIsOwnMediaUser(producerUserId)); 14362 videoEl.volume = 1.0; // Set volume to 100% 14363 videoEl.style.cssText = ` 14364 position: absolute; 14365 top: 0; 14366 left: 0; 14367 width: 100%; 14368 height: 100%; 14369 object-fit: cover; 14370 z-index: 1; 14371 background: #000; 14372 border: none; 14373 outline: none; 14374 `; 14375 14376 // Add a unique ID to track this video element 14377 videoEl.id = `video-${producerUserId}-${Date.now()}`; 14378 14379 // Add audio level indicator and volume controls 14380 if (window.chatCrackWebcam) { 14381 window.chatCrackWebcam.createAudioLevelIndicator(videoEl, dockedWebcam.closest('.webcam-dock'), producerUserId); 14382 window.chatCrackWebcam.createVolumeControls(videoEl, dockedWebcam.closest('.webcam-dock'), producerUserId); 14383 } 14384 14385 // Append the video element to the container 14386 dockedWebcam.appendChild(videoEl); 14387 } 14388 14389 // CRITICAL: Check if this video element already has a stream with audio 14390 if (videoEl.srcObject) { 14391 const existingAudioTracks = videoEl.srcObject.getAudioTracks(); 14392 if (existingAudioTracks.length > 0) { 14393 console.warn(`[AUDIO DEBUG] â ï¸ Video element for ${producerUserId} already has ${existingAudioTracks.length} audio track(s)!`); 14394 _chatLog(`[AUDIO DEBUG] Existing audio tracks:`, existingAudioTracks.map(t => ({ id: t.id, enabled: t.enabled }))); 14395 // Check if the new stream has the same audio tracks 14396 const newAudioTracks = stream.getAudioTracks(); 14397 const duplicateTracks = newAudioTracks.filter(newTrack => 14398 existingAudioTracks.some(existingTrack => existingTrack.id === newTrack.id) 14399 ); 14400 if (duplicateTracks.length > 0) { 14401 console.error(`[AUDIO DEBUG] â DUPLICATE AUDIO TRACKS FOUND! Same track IDs already in video element:`, 14402 duplicateTracks.map(t => t.id)); 14403 } 14404 } 14405 } 14406 14407 // CRITICAL FIX: Prevent concurrent stream attachments with a lock 14408 const attachmentLockKey = `attach_${producerUserId}`; 14409 if (window._webcamAttachmentLocks && window._webcamAttachmentLocks.has(attachmentLockKey)) { 14410 14411 return; 14412 } 14413 if (!window._webcamAttachmentLocks) { 14414 window._webcamAttachmentLocks = new Set(); 14415 } 14416 window._webcamAttachmentLocks.add(attachmentLockKey); 14417 14418 // CRITICAL FIX: Check if we're reassigning the same stream or if play() is in progress 14419 const currentStream = videoEl.srcObject; 14420 const isSameStream = currentStream && currentStream.id === stream.id; 14421 14422 // Also check if tracks are the same (more reliable than stream ID) 14423 let hasSameTracks = false;
14424 if (currentStream && stream) { 14425 const currentTracks = Array.from(currentStream.getTracks()).map(t => t.id).sort(); 14426 const newTracks = Array.from(stream.getTracks()).map(t => t.id).sort(); 14427 hasSameTracks = currentTracks.length === newTracks.length && 14428 currentTracks.every((id, i) => id === newTracks[i]); 14429 } 14430 const currentVideoTrack = currentStream && currentStream.getVideoTracks 14431 ? currentStream.getVideoTracks().find((t) => t && t.readyState === 'live') 14432 : null; 14433 const nextVideoTrack = stream.getVideoTracks 14434 ? stream.getVideoTracks().find((t) => t && t.readyState !== 'ended') 14435 : null; 14436 const sameVideoTrack = !!(currentVideoTrack && nextVideoTrack 14437 && currentVideoTrack.id === nextVideoTrack.id); 14438 14439 // Same live video track â keep srcObject (merge audio only). 14440 // Reassigning / stopping tracks here was a common black flicker. 14441 if (sameVideoTrack && currentStream && currentStream !== stream) { 14442 try { 14443 stream.getAudioTracks().forEach((at) => { 14444 if (!at || at.readyState === 'ended') return; 14445 if (!currentStream.getAudioTracks().some((t) => t.id === at.id)) { 14446 currentStream.addTrack(at); 14447 } 14448 }); 14449 } catch (_) {} 14450 } 14451 14452 // Only reassign if it's a different stream AND different video track 14453 if (!isSameStream && !hasSameTracks && !sameVideoTrack) { 14454 14455 // Do not stop the currently displayed live video track before 14456 // swap â stopping it paints black even if the next assign is immediate. 14457 14458 _chatLog(`[AUDIO DEBUG] ðº Setting stream to video element for ${producerUserId} - stream has ${stream.getTracks().length} tracks (${stream.getAudioTracks().length} audio)`); 14459 videoEl.srcObject = stream; 14460 14461 // If stream was reattached after a disconnect, hide the disconnected overlay 14462 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.hideStreamDisconnectedOverlay === 'function') { 14463 window.chatCrackWebcam.hideStreamDisconnectedOverlay(producerUserId); 14464 } 14465 14466 // Add event listeners to track video state (only once) 14467 if (!videoEl.hasAttribute('data-listeners-added')) { 14468 videoEl.setAttribute('data-listeners-added', 'true'); 14469 videoEl.addEventListener('loadedmetadata', () => { 14470 if (typeof window.ccSyncWebcamVideoAspect === 'function') { 14471 window.ccSyncWebcamVideoAspect(dockedWebcam, videoEl); 14472 } 14473 }); 14474 14475 videoEl.addEventListener('playing', () => { 14476 14477 }); 14478 14479 videoEl.addEventListener('error', (e) => { 14480 console.error(`[WebRTC] â Video error for ${producerUserId}:`, e); 14481 }); 14482 } 14483 14484 // Ensure video element is in the DOM before playing 14485 if (!dockedWebcam.contains(videoEl)) { 14486 dockedWebcam.appendChild(videoEl); 14487 } 14488 14489 // Play the video - wait for it to complete before allowing another reassignment 14490 try { 14491 // Wait for metadata to load first 14492 if (videoEl.readyState < HTMLMediaElement.HAVE_METADATA) { 14493 await new Promise((resolve, reject) => { 14494 const timeout = setTimeout(() => reject(new Error('Metadata load timeout')), 5000); 14495 videoEl.addEventListener('loadedmetadata', () => { 14496 clearTimeout(timeout); 14497 resolve(); 14498 }, { once: true }); 14499 videoEl.addEventListener('error', () => { 14500 clearTimeout(timeout); 14501 reject(new Error('Video load error')); 14502 }, { once: true }); 14503 }); 14504 } 14505 14506 await videoEl.play(); 14507 14508 } catch (e) { 14509 // Only log if it's not an AbortError (which is expected when reassigning) 14510 if (e.name !== 'AbortError') { 14511 console.warn(`[WebRTC] â ï¸ Video play failed for ${producerUserId}:`, e); 14512 } else { 14513 14514 } 14515 } finally { 14516 // Always release the lock 14517 if (window._webcamAttachmentLocks) { 14518 window._webcamAttachmentLocks.delete(attachmentLockKey); 14519 } 14520 } 14521 } else { 14522 14523 // Release lock immediately since we're not doing anything 14524 if (window._webcamAttachmentLocks) { 14525 window._webcamAttachmentLocks.delete(attachmentLockKey); 14526 } 14527 // Ensure video is playing even if stream is the same 14528 if (videoEl.paused) { 14529 videoEl.play().catch((e) => { 14530 if (e.name !== 'AbortError') { 14531 console.warn(`[WebRTC] â ï¸ Video play failed for ${producerUserId}:`, e); 14532 } 14533 }); 14534 } 14535 } 14536 14537 14538 14539 // Verify only one video element exists 14540 const finalVideos = dockedWebcam.querySelectorAll('video'); 14541 14542 // Keep this tile live: detect frozen/black frames (a "live" 14543 // track can still render black after packet loss or tab 14544 // throttling) and recover via keyframe â re-consume. 14545 try { this._startDockFrameWatchdog(videoEl, id, producerUserId, consumer.track); } catch (_) {} 14546 try { 14547 if (typeof window._ccAskDockKeyframeBurst === 'function') { 14548 window._ccAskDockKeyframeBurst(this.socket, this.roomId, id, producerUserId); 14549 } 14550 } catch (_) {} 14551 14552 } 14553 } catch (error) { 14554 console.warn(`[WebRTC] Failed to attach to docked webcam:`, error); 14555 // CRITICAL: Release lock on error 14556 const attachmentLockKey = `attach_${producerUserId}`; 14557 if (window._webcamAttachmentLocks) { 14558 window._webcamAttachmentLocks.delete(attachmentLockKey); 14559 } 14560 } 14561 } 14562 14563 // CRITICAL: Check for duplicate video elements before attaching 14564 const existingVideos = document.querySelectorAll(`.webcam-dock[data-username="${CSS.escape(producerUserId)}"] video`); 14565 if (existingVideos.length > 1) { 14566 console.error(`[AUDIO DEBUG] â MULTIPLE VIDEO ELEMENTS for ${producerUserId} before attachRemoteStream! Count: ${existingVideos.length}`); 14567 existingVideos.forEach((el, idx) => { 14568 const stream = el.srcObject; 14569 _chatLog(`[AUDIO DEBUG] Video ${idx + 1}:`, { 14570 id: el.id, 14571 hasStream: !!stream, 14572 audioTracks: stream ? stream.getAudioTracks().length : 0, 14573 allTracks: stream ? stream.getTracks().length : 0 14574 }); 14575 }); 14576 } 14577 14578 // Also delegate to existing UI attachment if present 14579 this.attachRemoteStream(producerUserId || producerId, stream); 14580 14581 // A remote audio track just arrived (mic turned on / late audio) â make 14582 // sure the docked tile plays it unmuted. The video attach block above 14583 // only runs for kind === 'video', so without this an audio-only update 14584 // leaves the tile silent even though the publisher's mic is live. 14585 if (kind === 'audio' && producerUserId && typeof window._ccEnsureDockWebcamAudioPlaying === 'function') { 14586 try { window._ccEnsureDockWebcamAudioPlaying(producerUserId, { skipIfNoTrack: false }); } catch (_) {} 14587 } 14588 14589 // Mark consumption as complete 14590 if (this.consumingProducers) { 14591 this.consumingProducers.delete(consumerKey); 14592 } 14593 14594 // Always resume client + server consumer. Server-only resume left docks 14595 // black with bitrate because mediasoup-client starts paused. 14596 try { 14597 if (typeof window._ccResumeWebcamConsumer === 'function') { 14598 window._ccResumeWebcamConsumer(consumer, this.socket, roomId, id); 14599 } else { 14600 try { consumer.resume().catch(function () {}); } catch (_) {} 14601 this.socket.emit('chatcrack:resume-consumer', { roomId, consumerId: id }, (res) => { 14602 if (!res || res.ok === false) console.warn('resume-consumer failed', res); 14603 }); 14604 } 14605 if (producerPaused === true && window.WEBCAM_DEBUG) { 14606 _chatLog('[AUDIO DEBUG] consumed paused mic for', producerUserId, 'â waiting for unmute'); 14607 } 14608 } catch (_) {} 14609 14610 return consumer; 14611 } 14612 14613 attachRemoteStream(id, stream) { 14614 // Delegate to existing attachRemoteVideo method 14615 this.attachRemoteVideo(id, stream); 14616 } 14617 14618 attachLocalPreview() { 14619 // VIDEO CALL MODAL REMOVED: No longer creating the large video call interface 14620 // The webcam will stream to the media server without showing the video call modal 14621 _chatLog('[WebRTC] ð¥ Local preview attachment - video call modal creation disabled'); 14622 14623 // The webcam stream is still attached and streaming to the media server 14624 // Just no visual interface is shown to the user 14625 } 14626 14627 attachRemoteVideo(id, stream) { 14628 // CRITICAL DEBUG: Log audio track count before attaching 14629 const audioTracks = stream.getAudioTracks(); 14630 // Only log in debug mode to reduce console clutter 14631 if (window.WEBCAM_DEBUG) { 14632 _chatLog(`[AUDIO DEBUG] attachRemoteVideo called for ${id} - stream has ${stream.getTracks().length} tracks (${audioTracks.length} audio)`); 14633 } 14634 if (audioTracks.length > 1) { 14635 console.error(`[AUDIO DEBUG] â Stream already has ${audioTracks.length} audio tracks before attachRemoteVideo!`, 14636 audioTracks.map(t => ({ id: t.id, enabled: t.enabled }))); 14637 } 14638 14639 // Ensure a dedicated <video> exists per remote user 14640 let videoElement = document.getElementById(`webcam_remote_${id}`); 14641 if (!videoElement) { 14642 const remoteVideosContainer = document.getElementById('remoteVideos'); 14643 if (!remoteVideosContainer) { 14644 // This is expected - remoteVideos container is legacy, not an error 14645 // Only log in debug mode to reduce console clutter 14646 if (window.WEBCAM_DEBUG) { 14647 14648 } 14649 return; 14650 } 14651 14652 const videoItem = document.createElement('div'); 14653 videoItem.className = 'video-item remote-video'; 14654 videoItem.dataset.userId = id; 14655 14656 videoElement = document.createElement('video'); 14657 videoElement.id = `webcam_remote_${id}`; 14658 videoElement.autoplay = true;
14659 videoElement.playsInline = true; 14660 videoElement.muted = false; 14661 14662 const label = document.createElement('div'); 14663 label.className = 'video-label'; 14664 label.textContent = id || 'Remote User'; 14665 14666 videoItem.appendChild(videoElement); 14667 videoItem.appendChild(label); 14668 remoteVideosContainer.appendChild(videoItem); 14669 14670 14671 } 14672 14673 // CRITICAL: Check if this video element already has a stream with audio 14674 if (videoElement.srcObject) { 14675 const existingStream = videoElement.srcObject; 14676 const existingAudioTracks = existingStream.getAudioTracks(); 14677 const newAudioTracks = stream.getAudioTracks(); 14678 14679 if (existingAudioTracks.length > 0) { 14680 console.warn(`[AUDIO DEBUG] â ï¸ Video element for ${id} already has ${existingAudioTracks.length} audio track(s)!`); 14681 // Check if it's the same stream 14682 if (existingStream.id === stream.id) { 14683 _chatLog(`[AUDIO DEBUG] â Same stream ID (${stream.id}), skipping duplicate assignment`); 14684 return; // Don't set the same stream again 14685 } 14686 // Check for duplicate track IDs 14687 const duplicateTracks = newAudioTracks.filter(newTrack => 14688 existingAudioTracks.some(existingTrack => existingTrack.id === newTrack.id) 14689 ); 14690 if (duplicateTracks.length > 0) { 14691 console.error(`[AUDIO DEBUG] â DUPLICATE AUDIO TRACKS in attachRemoteVideo for ${id}! Same track IDs:`, 14692 duplicateTracks.map(t => t.id)); 14693 } 14694 } 14695 } 14696 14697 // Set the stream and play â skip reassignment when the live video track 14698 // is unchanged (avoids black flicker on audio-only stream updates). 14699 const curVid = videoElement.srcObject && videoElement.srcObject.getVideoTracks 14700 ? videoElement.srcObject.getVideoTracks().find((t) => t && t.readyState === 'live') 14701 : null; 14702 const nextVid = stream.getVideoTracks 14703 ? stream.getVideoTracks().find((t) => t && t.readyState !== 'ended') 14704 : null; 14705 const sameVid = !!(curVid && nextVid && curVid.id === nextVid.id); 14706 if (!sameVid && videoElement.srcObject !== stream) { 14707 _chatLog(`[AUDIO DEBUG] ðº Setting stream to video element for ${id} - stream has ${stream.getTracks().length} tracks (${stream.getAudioTracks().length} audio)`); 14708 videoElement.srcObject = stream; 14709 } 14710 videoElement.play().catch(() => { 14711 console.warn("[WebRTC] â ï¸ Video play failed for user:", id); 14712 }); 14713 14714 // ð NEW: Start consumer-side keyframe watchdog 14715 this.startConsumerKeyframeWatchdog(videoElement, id); 14716 14717 14718 } 14719 14720 // ââ Dock frame watchdog ââââââââââââââââââââââââââââââââââââââââââââââ 14721 // Keeps docked remote cams from getting stuck on a black/frozen frame. 14722 // 14723 // The older _startDockStreamWatchdog only fires when there is NO live 14724 // track at all. But a track whose readyState is "live" can still render 14725 // black after packet loss (decoder waiting on a keyframe that never comes) 14726 // or after a background tab throttles the <video>. This watchdog watches 14727 // ACTUAL frame presentation (requestVideoFrameCallback) and, when frames 14728 // stop advancing while the tile is visible, escalates recovery: 14729 // 1. nudge play() + ask the server for a keyframe (light, debounced) 14730 // 2. fully re-consume the stream if it stays black (deep, debounced) 14731 _startDockFrameWatchdog(videoEl, consumerId, producerUserId, track, opts = {}) { 14732 if (!videoEl || !producerUserId) return; 14733 const key = String(producerUserId).toLowerCase(); 14734 if (!this._dockFrameWatchdogs) this._dockFrameWatchdogs = new Map(); 14735 const prev = this._dockFrameWatchdogs.get(key); 14736 if (prev) { try { clearInterval(prev.timer); } catch (_) {} } 14737 14738 // The keyframe request must travel on the SAME socket/room that created 14739 // the consumer. The combined-room flow uses this.socket/this.roomId; the 14740 // isolated-viewer flow (consumeUserWebcam) passes its own viewSocket and 14741 // per-user room via opts.
14742 const kfSocket = opts.socket || this.socket; 14743 const kfRoomId = opts.roomId || this.roomId; 14744 14745 const state = { 14746 videoEl, 14747 consumerId, 14748 track: track || null, 14749 lastFrameTs: performance.now(), 14750 lastKeyframeReq: 0, 14751 lastDeepRecover: 0, 14752 mutedSince: 0, 14753 timer: null, 14754 }; 14755 14756 const supportsRVFC = 'requestVideoFrameCallback' in HTMLVideoElement.prototype; 14757 if (supportsRVFC) { 14758 const onFrame = () => { 14759 // Bail if this watchdog was superseded (tile re-attached). 14760 if (this._dockFrameWatchdogs.get(key) !== state) return; 14761 state.lastFrameTs = performance.now(); 14762 try { state.videoEl.requestVideoFrameCallback(onFrame); } catch (_) {} 14763 }; 14764 try { videoEl.requestVideoFrameCallback(onFrame); } catch (_) {} 14765 } 14766 14767 // An unmute is the most common recovery point after a network blip â 14768 // resume playback and treat it as a fresh frame so we don't over-recover. 14769 if (track && !track._ccDockBound) { 14770 track._ccDockBound = true; 14771 try { 14772 track.addEventListener('unmute', () => { 14773 state.lastFrameTs = performance.now(); 14774 try { videoEl.play().catch(() => {}); } catch (_) {} 14775 }); 14776 } catch (_) {} 14777 } 14778 14779 const KEYFRAME_STALL_MS = 1200; 14780 const KEYFRAME_DEBOUNCE_MS = 2800; 14781 const BLACK_KEYFRAME_MS = 250; 14782 // Deep re-consume flashes black AND resets mic playout (crackle) â keep it 14783 // rare and only when the receiver is truly starved, not on brief stalls. 14784 const DEEP_STALL_MS = 14000; 14785 const DEEP_DEBOUNCE_MS = 28000; 14786 // Never-painted black tiles (favourite auto-open) need faster re-consume. 14787 const BLACK_DEBOUNCE_MS = 700; 14788 const BLACK_STUCK_MS = 800; 14789 // Video-track mute alone must not nuke healthy audio (was 5s â crackle). 14790 const MUTE_DEEP_MS = 14000; 14791 const MUTE_DEEP_NO_AUDIO_MS = 7000; 14792 14793 state.timer = setInterval(() => { 14794 if (this._dockFrameWatchdogs.get(key) !== state) { clearInterval(state.timer); return; } 14795 const v = state.videoEl; 14796 if (!v || !v.isConnected) { this._stopDockFrameWatchdog(producerUserId); return; } 14797 if (typeof window._ccMediaRolloverOverlapActive === 'function' 14798 && window._ccMediaRolloverOverlapActive() 14799 && (v.videoWidth | 0) > 0) { 14800 return; 14801 } 14802 if (typeof window._ccIsUserAuthoritativelyLive === 'function' 14803 && !window._ccIsUserAuthoritativelyLive(producerUserId)) { 14804 this._stopDockFrameWatchdog(producerUserId); 14805 return; 14806 } 14807 const tr = state.track; 14808 if (tr && tr.readyState === 'ended') { this._stopDockFrameWatchdog(producerUserId); return; } 14809 // Hidden tab: the browser legitimately stops painting. Don't recover. 14810 if (document.hidden) { state.lastFrameTs = performance.now(); return; } 14811 const gameUiOpen = typeof window._ccWebcamGameUiOpen === 'function' 14812 && window._ccWebcamGameUiOpen(); 14813 // A paused element never produces frames â kick it first (serialized play). 14814 if (v.paused && v.srcObject) { 14815 try { 14816 if (typeof window._ccSafeMediaPlay === 'function') window._ccSafeMediaPlay(v); 14817 else v.play().catch(() => {}); 14818 } catch (_) {} 14819 } 14820 14821 const now = performance.now();
14822 // Without rVFC, only count real decoded-frame progress. readyState>=2 14823 // on a frozen last-frame used to look healthy forever. 14824 if (!supportsRVFC) { 14825 let frames = -1; 14826 try { 14827 const q = v.getVideoPlaybackQuality && v.getVideoPlaybackQuality(); 14828 if (q && typeof q.totalVideoFrames === 'number') frames = q.totalVideoFrames; 14829 } catch (_) {} 14830 if (frames < 0 && typeof v.webkitDecodedFrameCount === 'number') { 14831 frames = v.webkitDecodedFrameCount; 14832 } 14833 if (frames >= 0) { 14834 if (state.lastDecodedFrames == null) state.lastDecodedFrames = frames; 14835 if (frames > state.lastDecodedFrames) { 14836 state.lastFrameTs = now; 14837 state.lastDecodedFrames = frames; 14838 } 14839 } 14840 } 14841 const sinceFrame = now - state.lastFrameTs; 14842 14843 // Video-track mute â audio dead. Healthy mic must not trigger tear-down. 14844 if (tr && tr.muted === true) { 14845 if (!state.mutedSince) state.mutedSince = now; 14846 } else { 14847 state.mutedSince = 0; 14848 } 14849 let audioHealthy = false; 14850 try { 14851 const stream = v.srcObject; 14852 audioHealthy = !!(stream && stream.getAudioTracks 14853 && stream.getAudioTracks().some((t) => 14854 t && t.readyState === 'live' && t.enabled !== false && t.muted !== true)); 14855 } catch (_) { audioHealthy = false; } 14856 14857 const neverPainted = (v.videoWidth | 0) === 0 && !!v.srcObject 14858 && tr && tr.readyState === 'live'; 14859 const kfStall = neverPainted ? BLACK_KEYFRAME_MS : KEYFRAME_STALL_MS; 14860 const kfDebounce = neverPainted ? 350 : KEYFRAME_DEBOUNCE_MS; 14861 if (sinceFrame > kfStall && (now - state.lastKeyframeReq) > kfDebounce) { 14862 state.lastKeyframeReq = now; 14863 try { 14864 if (kfSocket && kfSocket.connected) { 14865 kfSocket.emit('chatcrack:request-keyframe', { 14866 roomId: kfRoomId, 14867 consumerId: state.consumerId, 14868 producerUserId, 14869 }); 14870 } 14871 } catch (_) {} 14872 try { 14873 if (typeof window._ccSafeMediaPlay === 'function') window._ccSafeMediaPlay(v); 14874 else v.play().catch(() => {}); 14875 } catch (_) {} 14876 } 14877 14878 // Games already starve decode. Keyframe + play only â tearing the 14879 // socket here is what made blackjack/chase hitch with cams on. 14880 if (gameUiOpen) return; 14881 14882 // Deep recovery when frames have stopped AND the receiver reports no 14883 // media (track.muted), OR the <video> never painted (videoWidth===0) 14884 // while RTP/bitrate is still flowing â favourite docks hit that 14885 // "black tile + kbps" state after early auto-open. Low-motion cams 14886 // still present frames periodically once decode works, so videoWidth 14887 // staying 0 for the deep stall window is a real stuck decode. 14888 // Never deep-recover while the publisher is intentionally Cam Off / BRB. 14889 // Soft-first when mic audio is still healthy â hard refresh crackles mics. 14890 try { 14891 if (window.chatCrackWebcam && typeof window.chatCrackWebcam._isUserCameraOff === 'function' 14892 && window.chatCrackWebcam._isUserCameraOff(producerUserId)) { 14893 return; 14894 } 14895 } catch (_) {} 14896 const receiverStarved = tr ? tr.muted === true : true; 14897 const muteLimit = audioHealthy ? MUTE_DEEP_MS : MUTE_DEEP_NO_AUDIO_MS; 14898 const sustainedMute = state.mutedSince && (now - state.mutedSince) > muteLimit; 14899 const blackStuck = neverPainted && sinceFrame > BLACK_STUCK_MS; 14900 const deepDebounce = blackStuck ? BLACK_DEBOUNCE_MS : DEEP_DEBOUNCE_MS; 14901 if (((sinceFrame > DEEP_STALL_MS && (receiverStarved || neverPainted)) 14902 || sustainedMute || blackStuck) 14903 && (now - state.lastDeepRecover) > deepDebounce) { 14904 state.lastDeepRecover = now; 14905 const ui = window.chatCrackWebcam; 14906 if (ui && typeof ui._refreshDockedWebcamStream === 'function') { 14907 try { 14908 ui._refreshDockedWebcamStream(producerUserId, {
14909 locked: false, 14910 forceReconnect: true, 14911 hard: true, 14912 instantPaint: !!neverPainted 14913 }); 14914 } catch (_) {} 14915 } 14916 } 14917 }, 1000); 14918 14919 this._dockFrameWatchdogs.set(key, state); 14920 } 14921 14922 _stopDockFrameWatchdog(producerUserId) { 14923 if (!producerUserId || !this._dockFrameWatchdogs) return; 14924 const key = String(producerUserId).toLowerCase(); 14925 const st = this._dockFrameWatchdogs.get(key); 14926 if (st) { try { clearInterval(st.timer); } catch (_) {} this._dockFrameWatchdogs.delete(key); } 14927 } 14928 14929 // ð NEW: Consumer-side keyframe watchdog 14930 startConsumerKeyframeWatchdog(videoElement, consumerId) { 14931 if (!('requestVideoFrameCallback' in HTMLVideoElement.prototype)) { 14932 _chatLog('[WATCHDOG] requestVideoFrameCallback not supported, skipping keyframe watchdog'); 14933 return; 14934 } 14935 14936 let lastFrameTime = 0; 14937 let keyframeRequested = false; 14938 let lastRequestTime = 0; 14939 const FRAME_TIMEOUT = 2000; // 2000ms timeout 14940 const REQUEST_DEBOUNCE = 3000; // 3s debounce between requests 14941 14942 const onFrame = (now, metadata) => { 14943 lastFrameTime = now; 14944 keyframeRequested = false; // Reset flag when frame arrives 14945 }; 14946 14947 const checkForStall = () => { 14948 const now = performance.now(); 14949 const timeSinceLastFrame = now - lastFrameTime; 14950 14951 // Check if video is paused or hidden 14952 if (videoElement.paused || videoElement.hidden || videoElement.offsetParent === null) { 14953 // Video is paused/hidden, don't request keyframes 14954 videoElement.requestVideoFrameCallback(checkForStall); 14955 return; 14956 } 14957 14958 // If no frames for 2000ms and we haven't requested recently 14959 if (timeSinceLastFrame > FRAME_TIMEOUT && !keyframeRequested && (now - lastRequestTime) > REQUEST_DEBOUNCE) { 14960 console.log(`[WATCHDOG] Consumer ${consumerId} stalled for ${timeSinceLastFrame}ms, requesting keyframe`); 14961 14962 // Emit keyframe request to server 14963 this.socket.emit('chatcrack:request-keyframe', { 14964 roomId: this.roomId, 14965 consumerId: consumerId 14966 }); 14967 14968 keyframeRequested = true; 14969 lastRequestTime = now; 14970 } 14971 14972 // Continue monitoring 14973 videoElement.requestVideoFrameCallback(checkForStall); 14974 }; 14975 14976 // Start the monitoring loop 14977 videoElement.requestVideoFrameCallback(onFrame); 14978 videoElement.requestVideoFrameCallback(checkForStall); 14979 14980 console.log(`[WATCHDOG] Consumer keyframe watchdog started for ${consumerId}`); 14981 } 14982 14983 showVideoContainer() { 14984 // Create video container if it doesn't exist 14985 if (!document.getElementById('videoContainer')) { 14986 const container = document.createElement('div'); 14987 container.id = 'videoContainer'; 14988 container.className = 'video-container'; 14989 container.innerHTML = ` 14990 <div class="video-grid"> 14991 <div class="video-item local-video"> 14992 <video id="localVideo" autoplay muted playsinline></video> 14993 <div class="video-label">You</div> 14994 </div> 14995 <div id="remoteVideos" class="remote-videos"></div> 14996 </div> 14997 <div class="video-controls"> 14998 <button id="muteBtn">ð Mute</button> 14999 <button id="videoBtn">ð¹ Video</button> 15000 <button id="screenShareBtn">ð¥ï¸ Share Screen</button> 15001 <button id="stopVideoCallBtn">â¹ï¸ Stop Call</button> 15002 </div> 15003 `; 15004 15005 document.body.appendChild(container); 15006 15007 // Bind control events 15008 this.bindVideoControls(); 15009 } 15010 15011 // Show container and set local video 15012 const container = document.getElementById('videoContainer'); 15013 container.style.display = 'block'; 15014 15015 const localVideo = document.getElementById('localVideo'); 15016 if (localVideo && this.localStream) { 15017 localVideo.srcObject = this.localStream; 15018 } 15019 } 15020 15021 bindVideoControls() { 15022 const muteBtn = document.getElementById('muteBtn'); 15023 const videoBtn = document.getElementById('videoBtn'); 15024 const screenShareBtn = document.getElementById('screenShareBtn'); 15025 const stopBtn = document.getElementById('stopVideoCallBtn'); 15026 15027 if (muteBtn) { 15028 muteBtn.onclick = () => this.toggleMute(); 15029 } 15030 15031 if (videoBtn) { 15032 videoBtn.onclick = () => this.toggleVideo(); 15033 } 15034 15035 if (screenShareBtn) { 15036 screenShareBtn.onclick = () => this.toggleScreenShare(); 15037 } 15038 15039 if (stopBtn) { 15040 stopBtn.onclick = () => this.stopVideoCall(); 15041 } 15042 } 15043 15044 toggleMute() { 15045 const muteBtn = document.getElementById('muteBtn'); 15046 const audioTrack = this.localStream?.getAudioTracks()[0]; 15047 15048 if (audioTrack) { 15049 audioTrack.enabled = !audioTrack.enabled; 15050 muteBtn.textContent = audioTrack.enabled ? 'ð Mute' : 'ð Unmute'; 15051 } 15052 } 15053 15054 toggleVideo() { 15055 const videoBtn = document.getElementById('videoBtn'); 15056 const videoTrack = this.localStream?.getVideoTracks()[0]; 15057 15058 if (videoTrack) { 15059 videoTrack.enabled = !videoTrack.enabled; 15060 videoBtn.textContent = videoTrack.enabled ? 'ð¹ Video' : 'ð« Video Off'; 15061 } 15062 } 15063 15064 async toggleScreenShare() { 15065 try { 15066 const stream = await navigator.mediaDevices.getDisplayMedia({ 15067 video: true, 15068 audio: false 15069 }); 15070
15071 _chatLog('[WebRTC] Screen share started'); 15072 15073 // Create screen share producer - let mediasoup handle codec selection 15074 const videoTrack = stream.getVideoTracks()[0]; 15075 if (videoTrack && this.sendTransport) { 15076 const screenProducer = await this.sendTransport.produce({ 15077 track: videoTrack 15078 // No manual codec selection - let mediasoup negotiate 15079 }); 15080 15081 this.producers.set('screen', screenProducer); 15082 console.log('[producer] Screen share created:', screenProducer.id, screenProducer.kind, 15083 (screenProducer.rtpParameters?.codecs || []).map(c => c.mimeType)); 15084 15085 // Update button text 15086 const screenShareBtn = document.getElementById('screenShareBtn'); 15087 if (screenShareBtn) { 15088 screenShareBtn.textContent = 'ð¥ï¸ Stop Sharing'; 15089 screenShareBtn.onclick = () => this.stopScreenShare(); 15090 } 15091 15092 // Handle track ending 15093 videoTrack.onended = () => { 15094 _chatLog('[WebRTC] Screen share track ended'); 15095 this.stopScreenShare(); 15096 }; 15097 } 15098 15099 } catch (error) { 15100 console.error('[WebRTC] Screen share failed:', error); 15101 } 15102 } 15103 15104 stopScreenShare() { 15105 const screenProducer = this.producers.get('screen'); 15106 if (screenProducer) { 15107 screenProducer.close(); 15108 this.producers.delete('screen'); 15109 _chatLog('[WebRTC] Screen share stopped'); 15110 15111 // Update button text 15112 const screenShareBtn = document.getElementById('screenShareBtn'); 15113 if (screenShareBtn) { 15114 screenShareBtn.textContent = 'ð¥ï¸ Share Screen'; 15115 screenShareBtn.onclick = () => this.toggleScreenShare(); 15116 } 15117 } 15118 } 15119 15120 stopVideoCall() { 15121 // Stop all tracks 15122 if (this.localStream) { 15123 this.localStream.getTracks().forEach(track => track.stop()); 15124 } 15125 15126 // Close producers 15127 this.producers.forEach(producer => { 15128 if (producer.track) { 15129 producer.track.stop(); 15130 } 15131 }); 15132 15133 // Close consumers 15134 this.consumers.forEach(consumer => { 15135 consumer.close(); 15136 }); 15137 15138 // Close transports 15139 if (this.sendTransport) this.sendTransport.close(); 15140 if (this.recvTransport) this.recvTransport.close(); 15141 15142 // Close socket 15143 if (this.socket) this.socket.close(); 15144 15145 // Hide video container 15146 const container = document.getElementById('videoContainer'); 15147 if (container) { 15148 container.style.display = 'none'; 15149 } 15150 15151 // Clean up 15152 this.cleanup(); 15153 15154 _chatLog('[WebRTC] Video call stopped'); 15155 } 15156 15157 async _reopenPinnedMic(opts) { 15158 const wc = window.chatCrackWebcam; 15159 const prevSwap = !!(wc && wc._settingsDeviceSwapInProgress); 15160 try { 15161 if (wc) wc._settingsDeviceSwapInProgress = true; 15162 this._forceMicDeviceReopen = true; 15163 this._micReleaseBeforeReopen = !!(opts && opts.releaseFirst); 15164 return await this.applyAudioDeviceFromSettings(); 15165 } finally { 15166 this._forceMicDeviceReopen = false; 15167 this._micReleaseBeforeReopen = false; 15168 if (wc) wc._settingsDeviceSwapInProgress = prevSwap; 15169 } 15170 } 15171 15172 async _reopenPinnedMicForUnmute() { 15173 return this._reopenPinnedMic(); 15174 } 15175 15176 _webcamMicPrefKey() { 15177 let u = String(window.me?.username || window.currentUsername || '').trim().toLowerCase(); 15178 if (!u) { 15179 try { u = String(localStorage.getItem('current_username') || '').trim().toLowerCase(); } catch (_) {} 15180 } 15181 return u ? ('cc_webcam_mic_unmuted:' + u) : 'cc_webcam_mic_unmuted'; 15182 } 15183
15184 /** Last cam-mic choice: true = unmuted, false = muted, null = never saved (default muted). */ 15185 _readWebcamMicPrefUnmuted() { 15186 try { 15187 const key = this._webcamMicPrefKey(); 15188 let v = localStorage.getItem(key); 15189 if (v == null && key !== 'cc_webcam_mic_unmuted') { 15190 v = localStorage.getItem('cc_webcam_mic_unmuted'); 15191 } 15192 if (v === '1') return true; 15193 if (v === '0') return false; 15194 } catch (_) {} 15195 return null; 15196 } 15197 15198 _persistWebcamMicPref(unmuted) { 15199 if (this !== window.chatCrackWebRTC 15200 && !String(this.roomId || '').startsWith('chatcrack_webcam_')) { 15201 return; 15202 } 15203 try { 15204 const val = unmuted ? '1' : '0'; 15205 localStorage.setItem(this._webcamMicPrefKey(), val); 15206 localStorage.setItem('cc_webcam_mic_unmuted', val); 15207 } catch (_) {} 15208 } 15209 15210 _applyWebcamMicPref() { 15211 const unmuted = this._readWebcamMicPrefUnmuted(); 15212 if (unmuted === null) return; 15213 this.isMicMuted = !unmuted; 15214 this._pendingMicUnmute = !!unmuted; 15215 } 15216 15217 _syncMicToggleButton(willEnable) { 15218 const btn = document.querySelector('[data-action="toggle-audio"], #toggle-audio, .btn-toggle-audio, #webcamAudioBtn'); 15219 if (!btn) return; 15220 btn.setAttribute('aria-pressed', String(willEnable)); 15221 btn.classList.toggle('is-muted', !willEnable); 15222 btn.title = willEnable ? 'Mute microphone' : 'Unmute microphone'; 15223 btn.textContent = willEnable ? 'ð' : 'ð'; 15224 btn.style.background = ''; 15225 btn.style.color = ''; 15226 btn.classList.toggle('audio-muted', !willEnable); 15227 } 15228 15229 async toggleAudio() { 15230 try { 15231 const omLive = !!(window.chatCrackOpenMic && window.chatCrackOpenMic.isPublishing); 15232 if (omLive && this.isMicMuted) { 15233 try { 15234 if (window.chatCrackOpenMic && typeof window.chatCrackOpenMic.stopPublishing === 'function') { 15235 await window.chatCrackOpenMic.stopPublishing(true); 15236 } 15237 } catch (_) {} 15238 await new Promise(function (resolve) { setTimeout(resolve, 200); }); 15239 } 15240 let track = this.localStream?.getAudioTracks?.()[0] 15241 || window.chatCrackWebcam?.localStream?.getAudioTracks?.()[0]; 15242 let prod = this.producers?.get?.('mic') || this.audioProducer; 15243 const camLive = !!(this.cameraProducer && !this.cameraProducer.closed) 15244 || !!(window.chatCrackWebcam && window.chatCrackWebcam.isWebcamOn); 15245 15246 // isMicMuted is the UI truth â track.enabled can disagree with a paused producer. 15247 const willEnable = !!this.isMicMuted; 15248 15249 // Producer not ready yet (cam still connecting) â latch desired state. 15250 if (!prod || prod.closed) { 15251 this.isMicMuted = !willEnable; 15252 this._pendingMicUnmute = !!willEnable; 15253 if (track && track.readyState !== 'ended') { 15254 try { track.enabled = willEnable; } catch (_) {} 15255 } 15256 let produced = false; 15257 if (willEnable && camLive && this.sendTransport && typeof this._produceMicStable === 'function') { 15258 try { 15259 if (!track || track.readyState === 'ended') { 15260 const wc = window.chatCrackWebcam; 15261 if (wc && typeof wc._remapSavedDeviceIds === 'function' 15262 && navigator.mediaDevices?.enumerateDevices) { 15263 try { 15264 const devices = await navigator.mediaDevices.enumerateDevices(); 15265 if (typeof wc._deviceListIsTrustworthy !== 'function' 15266 || wc._deviceListIsTrustworthy(devices)) { 15267 wc._remapSavedDeviceIds(devices); 15268 } 15269 } catch (_) {} 15270 } 15271 const ac = this._stripMediaBitrate((wc && wc.getAudioConstraints && wc.getAudioConstraints()) || {}); 15272 const deviceId = wc && wc.webcamSettings && wc.webcamSettings.audioDeviceId; 15273 const fresh = await this._getUserMediaWithDevice('audio', ac, deviceId); 15274 track = fresh && fresh.getAudioTracks && fresh.getAudioTracks()[0]; 15275 if (track && this.localStream && !this.localStream.getAudioTracks().includes(track)) { 15276 this.localStream.addTrack(track); 15277 } else if (track && !this.localStream) { 15278 this.localStream = fresh; 15279 } 15280 } 15281 if (track && track.readyState === 'live') { 15282 track.enabled = true; 15283 this.isMicMuted = false;
15284 this._pendingMicUnmute = false; 15285 this._micNeedsFreshOpenOnUnmute = false; 15286 const aProd = await this._produceMicStable(track, false); 15287 this.producers.set('mic', aProd); 15288 this.audioProducer = aProd; 15289 try { await aProd.resume(); } catch (_) {} 15290 try { await this._serverResumeMicProducer(aProd); } catch (_) {} 15291 this._bindMicTrackDeviceRecovery(track); 15292 this.startMicHealthWatchdog(); 15293 this.startAudioBitrateMonitoring(aProd); 15294 produced = true; 15295 showToast('ðï¸ Mic unmuted', 'success', { ttlMs: 2200 }); 15296 this.sendUnmuteSystemMessage(); 15297 } 15298 } catch (prodErr) { 15299 console.warn('[WebRTC] unmute produce failed:', prodErr?.message || prodErr); 15300 } 15301 } 15302 if (!produced) { 15303 showToast( 15304 willEnable 15305 ? 'ðï¸ Mic will unmute when cam finishes connectingâ¦' 15306 : 'ð Mic muted', 15307 'info', 15308 { ttlMs: 2200 } 15309 ); 15310 } 15311 this._syncMicToggleButton(!this.isMicMuted); 15312 _chatLog('[WebRTC] ð Audio desired ' + (willEnable ? 'unmuted' : 'muted') 15313 + (produced ? ' (produced now)' : ' (producer pending)')); 15314 this._refreshOwnMicMeter(); 15315 return; 15316 } 15317 15318 if (!willEnable) { 15319 if (track) try { track.enabled = false; } catch (_) {} 15320 this._pendingMicUnmute = false; 15321 await prod.pause(); 15322 try { await this._serverPauseMicProducer(prod); } catch (_) {} 15323 this.isMicMuted = true; 15324 showToast('ð Mic muted', 'info', { ttlMs: 2200 }); 15325 this._syncMicToggleButton(false); 15326 _chatLog('[WebRTC] ð Audio muted'); 15327 this._refreshOwnMicMeter(); 15328 return; 15329 } 15330 15331 // Unmute: open the pinned live mic the same way SettingsâSave does 15332 // when CableâFifine is toggled â resume alone often published silence. 15333 this.isMicMuted = false; 15334 this._pendingMicUnmute = false; 15335 const trackDead = !track || track.readyState === 'ended'; 15336 let reopened = false; 15337 try { 15338 const needsFresh = !!this._micNeedsFreshOpenOnUnmute 15339 || trackDead 15340 || (await this._ensureMicTrackIsPinnedAndLive()) === 'reopen'; 15341 if (needsFresh) { 15342 reopened = await this._reopenPinnedMicForUnmute(); 15343 track = this.localStream?.getAudioTracks?.()[0] || track; 15344 prod = this.producers?.get?.('mic') || this.audioProducer; 15345 } 15346 } catch (pinErr) { 15347 console.warn('[WebRTC] pinned mic reopen failed:', pinErr?.message || pinErr); 15348 } 15349 this._micNeedsFreshOpenOnUnmute = false; 15350 15351 if (!track || track.readyState === 'ended' || !prod || prod.closed) { 15352 showToast('Mic reconnect failed â try Webcam Settings', 'error'); 15353 this.isMicMuted = true; 15354 this._syncMicToggleButton(false); 15355 return; 15356 } 15357 15358 try { track.enabled = true; } catch (_) {} 15359 try { 15360 if (prod.track !== track) await prod.replaceTrack({ track }); 15361 } catch (_) {} 15362 try { await prod.resume(); } catch (_) {} 15363 try { await this._serverResumeMicProducer(prod); } catch (_) {} 15364 // Resume can race replaceTrack â re-assert shortly after. 15365 setTimeout(() => { 15366 try { 15367 const p2 = this.producers?.get?.('mic') || this.audioProducer; 15368 if (this.isMicMuted === false && p2 && !p2.closed && p2.paused) { 15369 p2.resume().catch(() => {}); 15370 this._serverResumeMicProducer(p2).catch(() => {}); 15371 } 15372 } catch (_) {} 15373 }, 400); 15374 15375 showToast('ðï¸ Mic unmuted', 'success', { ttlMs: 2200 }); 15376 this.sendUnmuteSystemMessage(); 15377 this._syncMicToggleButton(true); 15378 _chatLog('[WebRTC] ð Audio unmuted' + (reopened ? ' (fresh pinned mic)' : '')); 15379 this._refreshOwnMicMeter(); 15380 } catch (e) { 15381 console.error('[WebRTC] toggleAudio error:', e); 15382 showToast('Mic toggle failed: ' + (e?.message || e), 'error'); 15383 } finally { 15384 this._persistWebcamMicPref(this.isMicMuted === false); 15385 } 15386 } 15387 15388 async setCameraEnabled(enabled) { 15389 const track = this.localStream?.getVideoTracks?.()[0]; 15390 const prod = this.cameraProducer; 15391 if (!track) { 15392 console.warn('[WebRTC] setCameraEnabled: no video track'); 15393 return; 15394 } 15395 track.enabled = !!enabled; 15396 if (prod && !prod.closed) { 15397 if (enabled) await prod.resume(); 15398 else await prod.pause(); 15399 } 15400 // Recovery/watchdog must not undo an intentional Cam Off. 15401 if (!enabled) this._reassertCameraOffState(); 15402 // Cam On after BRB: local preview must play; remotes get keyframe via webcam:update. 15403 if (enabled) { 15404 try { 15405 document.querySelectorAll('.webcam-dock video, .webcam-preview-container video, #webcamPreview video').forEach((v) => { 15406 try { v.play().catch(() => {}); } catch (_) {} 15407 }); 15408 } catch (_) {} 15409 } 15410 _chatLog('[WebRTC] ð· Camera ' + (enabled ? 'enabled' : 'disabled (mic still live)')); 15411 } 15412 15413 // Send system message when microphone is unmuted (with 180 second cooldown) 15414 async sendUnmuteSystemMessage() { 15415 try { 15416 const now = Date.now(); 15417 const timeSinceLastMessage = now - this._lastUnmuteMessageTime; 15418 15419 // Check cooldown (180 seconds) 15420 if (timeSinceLastMessage < this._unmuteMessageCooldown) { 15421 const remainingSeconds = Math.ceil((this._unmuteMessageCooldown - timeSinceLastMessage) / 1000); 15422 console.log(`[MIC UNMUTE] â° Cooldown active: ${remainingSeconds} seconds remaining before next unmute message`); 15423 return; 15424 } 15425 15426 // Update last message time
15427 this._lastUnmuteMessageTime = now; 15428 15429 // Get current username 15430 const username = window.currentUsername || window.me?.username || 'User'; 15431 15432 // Send system message via addWebcamSystemMessage 15433 if (typeof window.addWebcamSystemMessage === 'function') { 15434 // Use a custom action for unmute 15435 await window.addWebcamSystemMessage('mic_unmute', false); 15436 console.log(`[MIC UNMUTE] ð¢ Sent unmute system message for ${username}`); 15437 } else { 15438 // Fallback: send directly via API 15439 const formData = new FormData(); 15440 formData.append('action', 'webcam_system_message'); 15441 formData.append('webcam_action', 'mic_unmute'); 15442 formData.append('is_locked', '0'); 15443 formData.append('room_id', String(window.currentRoomId != null ? window.currentRoomId : 1)); 15444 15445 const response = await ccAuthenticatedFetch('api.php', { 15446 method: 'POST', 15447 body: formData 15448 }); 15449 15450 if (response.ok) { 15451 const data = await response.json(); 15452 if (data.ok) { 15453 console.log(`[MIC UNMUTE] ð¢ Sent unmute system message for ${username}`); 15454 } else { 15455 console.error('[MIC UNMUTE] â Failed to send system message:', data.error); 15456 } 15457 } else { 15458 console.error('[MIC UNMUTE] â Request failed:', response.status); 15459 } 15460 } 15461 } catch (error) { 15462 console.error('[MIC UNMUTE] â Error sending unmute system message:', error); 15463 } 15464 } 15465 15466 cleanup() { 15467 this.producers.clear(); 15468 this.consumers.clear(); 15469 this.device = null; 15470 this.sendTransport = null; 15471 this.recvTransport = null; 15472 this.socket = null; 15473 this.localStream = null; 15474 this.roomId = null; 15475 this.userId = null; 15476 } 15477} 15478 15479// Global structures 15480// Main publisher context (for your own webcam) 15481window.chatCrackWebRTC = window.chatCrackWebRTC || null; 15482 15483// Map of per-user consumer contexts (each remote webcam gets its own) 15484window.remoteWebcamViewers = window.remoteWebcamViewers || {}; 15485// Track who is currently docked (source of truth for UI gating / redock) 15486window.dockedWebcams = window.dockedWebcams || new Set(); 15487 15488/** Keep docked remote cam audio playing unmuted after late mic attach / autoplay mute. */ 15489window._ccEnsureDockWebcamAudioPlaying = function (username, opts) { 15490 try { 15491 if (window.chatCrackWebcam && typeof window.chatCrackWebcam.ensureDockWebcamAudioPlaying === 'function') { 15492 return window.chatCrackWebcam.ensureDockWebcamAudioPlaying(username, opts); 15493 } 15494 } catch (_) {} 15495 return false; 15496}; 15497 15498// Initialize WebRTC module when page loads 15499document.addEventListener('DOMContentLoaded', () => { 15500 try { 15501 window.chatCrackWebRTC = new ChatCrackWebRTC(); 15502 15503 // Global test functions for watchdog restart 15504 window.testWatchdogRestart = () => { 15505 if (window.chatCrackWebRTC && window.chatCrackWebRTC.testWatchdogRestart) { 15506 window.chatCrackWebRTC.testWatchdogRestart(); 15507 } else { 15508 console.warn('[WATCHDOG] 𧪠TEST: WebRTC instance not available'); 15509 } 15510 }; 15511 15512 window.testWatchdogRestartHard = () => { 15513 if (window.chatCrackWebRTC && window.chatCrackWebRTC.testWatchdogRestartHard) { 15514 window.chatCrackWebRTC.testWatchdogRestartHard(); 15515 } else { 15516 console.warn('[WATCHDOG] 𧪠TEST: WebRTC instance not available'); 15517 } 15518 }; 15519 15520 } catch (error) { 15521 console.error('[WebRTC] Failed to initialize WebRTC module:', error); 15522 console.error('[WebRTC] Error stack:', error.stack); 15523 } 15524}); 15525 15526// ð NEW: Global function for runtime bitrate switching (can be called from UI) 15527window.updateWebcamBitrate = async function(newQuality) { 15528 try { 15529 if (window.chatCrackWebRTC && typeof window.chatCrackWebRTC.updateBitrate === 'function') { 15530 await window.chatCrackWebRTC.updateBitrate(newQuality); 15531 console.log('[GLOBAL] Webcam bitrate updated to:', newQuality); 15532 } else { 15533 console.warn('[GLOBAL] WebRTC module or updateBitrate method not available'); 15534 } 15535 } catch (error) { 15536 console.error('[GLOBAL] Failed to update webcam bitrate:', error); 15537 } 15538}; 15539 15540// ð NEW: Debug hooks for testing and monitoring 15541window.__cc_forceStallRecover = async function() { 15542 try { 15543 if (window.chatCrackWebRTC && typeof window.chatCrackWebRTC.attemptPublisherRecovery === 'function') { 15544 console.log('[DEBUG] Forcing stall recovery...'); 15545 await window.chatCrackWebRTC.attemptPublisherRecovery(); 15546 console.log('[DEBUG] Stall recovery completed'); 15547 } else { 15548 console.warn('[DEBUG] WebRTC module or attemptPublisherRecovery method not available'); 15549 } 15550 } catch (error) { 15551 console.error('[DEBUG] Failed to force stall recovery:', error); 15552 } 15553}; 15554
15555// ð NEW: Debug hook to check watchdog status 15556window.__cc_getWatchdogStatus = function() { 15557 if (window.chatCrackWebRTC) { 15558 const status = { 15559 qualityMode: window.chatCrackWebRTC.qualityMode, 15560 currentQuality: window.chatCrackWebRTC.currentQuality, 15561 lastQualityChange: window.chatCrackWebRTC.lastQualityChange, 15562 healthyFramesCount: window.chatCrackWebRTC.healthyFramesCount, 15563 watchdogTimer: window.chatCrackWebRTC.watchdogTimer ? 'active' : 'inactive', 15564 cameraProducer: window.chatCrackWebRTC.cameraProducer ? 'active' : 'inactive', 15565 sendTransport: window.chatCrackWebRTC.sendTransport ? 'active' : 'inactive' 15566 }; 15567 console.log('[DEBUG] Watchdog Status:', status); 15568 return status; 15569 } else { 15570 console.warn('[DEBUG] WebRTC module not available'); 15571 return null; 15572 } 15573}; 15574 15575// ð NEW: Debug hook to manually trigger ICE restart 15576window.__cc_forceIceRestart = async function() { 15577 try { 15578 if (window.chatCrackWebRTC && typeof window.chatCrackWebRTC.restartIceSafely === 'function') { 15579 console.log('[DEBUG] Forcing ICE restart...'); 15580 await window.chatCrackWebRTC.restartIceSafely(); 15581 console.log('[DEBUG] ICE restart completed'); 15582 } else { 15583 console.warn('[DEBUG] WebRTC module or restartIceSafely method not available'); 15584 } 15585 } catch (error) { 15586 console.error('[DEBUG] Failed to force ICE restart:', error); 15587 } 15588}; 15589 15590// ð NEW: Debug hook to toggle quality mode 15591window.__cc_toggleQualityMode = function() { 15592 if (window.chatCrackWebRTC) { 15593 const newMode = window.chatCrackWebRTC.qualityMode === 'auto' ? 'manual' : 'auto'; 15594 window.chatCrackWebRTC.qualityMode = newMode; 15595 console.log(`[DEBUG] Quality mode changed to: ${newMode}`); 15596 return newMode; 15597 } else { 15598 console.warn('[DEBUG] WebRTC module not available'); 15599 return null; 15600 } 15601}; 15602 15603// ð NEW: Enhanced logging for all bitrate changes 15604const originalUpdateBitrate = window.chatCrackWebRTC?.updateBitrate; 15605if (originalUpdateBitrate) { 15606 window.chatCrackWebRTC.updateBitrate = async function(newQuality) { 15607 console.log(`[WATCHDOG] Bitrate change requested: ${this.currentQuality} â ${newQuality}`); 15608 const result = await originalUpdateBitrate.call(this, newQuality); 15609 console.log(`[WATCHDOG] Bitrate change completed: ${this.currentQuality}`); 15610 return result; 15611 }; 15612} 15613 15614// ð NEW: Debug function to check current bitrate settings 15615window.checkCurrentBitrate = function() { 15616 if (!window.chatCrackWebRTC) { 15617 console.log('[DEBUG] WebRTC module not available'); 15618 return; 15619 } 15620 15621 const webrtc = window.chatCrackWebRTC; 15622 console.log('[DEBUG] Current WebRTC state:', { 15623 currentQuality: webrtc.currentQuality, 15624 hasSender: !!webrtc.sender, 15625 hasProducer: !!webrtc.cameraProducer 15626 }); 15627 15628 if (webrtc.sender) { 15629 try { 15630 const params = webrtc.sender.getParameters(); 15631 console.log('[DEBUG] Current RTCRtpSender parameters:', { 15632 encodings: params.encodings?.map(enc => ({ 15633 maxBitrate: `${(enc.maxBitrate / 1000).toFixed(0)}kbps`, 15634 maxFramerate: `${enc.maxFramerate}fps`, 15635 scaleResolutionDownBy: enc.scaleResolutionDownBy 15636 })), 15637 degradationPreference: params.degradationPreference 15638 }); 15639 } catch (error) { 15640 console.error('[DEBUG] Failed to get sender parameters:', error); 15641 } 15642 } 15643 15644 // Show quality presets 15645 const qualityPresets = { 15646 low: webrtc.getSendEncodingFor('low')[0], 15647 medium: webrtc.getSendEncodingFor('medium')[0], 15648 high: webrtc.getSendEncodingFor('high')[0], 15649 hd: webrtc.getSendEncodingFor('hd')[0] 15650 }; 15651 15652 console.log('[DEBUG] Available quality presets:', Object.entries(qualityPresets).map(([name, enc]) => ({ 15653 [name]: `${(enc.maxBitrate / 1000).toFixed(0)}kbps @ ${enc.maxFramerate}fps` 15654 }
15654))); 15655}; 15656 15657// ð NEW: Force bitrate function for testing 15658window.forceBitrate = async function(quality = 'low') { 15659 if (!window.chatCrackWebRTC) { 15660 console.log('[FORCE] WebRTC module not available'); 15661 return; 15662 } 15663 15664 console.log(`[FORCE] ð§ Forcing bitrate to ${quality}...`); 15665 15666 const webrtc = window.chatCrackWebRTC; 15667 15668 // Try multiple approaches 15669 try { 15670 // 1. Update via updateBitrate method 15671 await webrtc.updateBitrate(quality); 15672 console.log(`[FORCE] â Method 1: updateBitrate(${quality}) completed`); 15673 } catch (error) { 15674 console.warn(`[FORCE] â ï¸ Method 1 failed:`, error); 15675 } 15676 15677 // 2. Try direct RTCRtpSender manipulation 15678 if (webrtc.sender) { 15679 try { 15680 const enc = webrtc.getSendEncodingFor(quality)[0]; 15681 const params = webrtc.sender.getParameters(); 15682 params.encodings = params.encodings || [{}]; 15683 params.encodings[0].maxBitrate = enc.maxBitrate; 15684 params.encodings[0].maxFramerate = enc.maxFramerate; 15685 params.degradationPreference = 'maintain-framerate'; 15686 15687 console.log(`[FORCE] ð§ Direct sender params:`, { 15688 maxBitrate: `${(enc.maxBitrate / 1000).toFixed(0)}kbps`, 15689 maxFramerate: `${enc.maxFramerate}fps` 15690 }); 15691 15692 await webrtc.sender.setParameters(params); 15693 console.log(`[FORCE] â Method 2: Direct sender.setParameters() completed`); 15694 } catch (error) { 15695 console.warn(`[FORCE] â ï¸ Method 2 failed:`, error); 15696 } 15697 } 15698 15699 // 3. Check current state 15700 setTimeout(() => { 15701 console.log(`[FORCE] ð Final state check:`); 15702 checkCurrentBitrate(); 15703 }, 500); 15704}; 15705 15706 15707// ===== BEGIN BLACKJACK JS [BJ_JS_V1] ===== 15708(function(){ 15709 let bjSock = null; 15710 let currentBalance = 0; 15711 let bjLastKnownBalance = 0; // snapshot used to compute end-of-hand delta 15712 let bjRoundActive = false; // track round lifecycle 15713 // Persist last shown hands until next bet or modal close 15714 let bjLastPlayerCards = []; 15715 let bjLastDealerCards = []; 15716 let bjLastDealerUp = null; 15717 let bjLastPlayerTotal = null; 15718 let bjLastDealerShownTotal = null; 15719 // Backoff for retries 15720 let bjRetryDelayMs = 1000; 15721 let bjRetryTimer = null; 15722 const bjRetryMaxMs = 15000; 15723 let bjSyncingBalance = false; 15724 let bjJoined = false; 15725 let bjBetPendingTimer = null; 15726 let bjActionPendingTimer = null; 15727 let bjPendingBetAmount = null; 15728 let bjLastRoundId = null; 15729 let bjMode = null; // 'solo' | 'pvp' 15730 let bjWaitingOpponent = false; 15731 let bjLastOpponentName = null; 15732 let bjInPvpMatch = false; 15733 let bjPvpAwaitingPlayAgain = false; 15734 const $ = (id)=>document.getElementById(id); 15735 15736 function showBjModePicker() { 15737 $('bjModePick')?.classList.remove('hidden'); 15738 $('bjLobbyPanel')?.classList.add('hidden'); 15739 $('bjGamePanel')?.classList.add('hidden'); 15740 $('blackjackModal')?.classList.remove('bj-pvp-active'); 15741 bjMode = null; 15742 bjInPvpMatch = false; 15743 bjPvpAwaitingPlayAgain = false; 15744 bjLastOpponentName = null; 15745 } 15746 15747 function showBjLobbyPanel() { 15748 $('bjModePick')?.classList.add('hidden'); 15749 $('bjLobbyPanel')?.classList.remove('hidden'); 15750 $('bjGamePanel')?.classList.add('hidden'); 15751 $('blackjackModal')?.classList.add('bj-pvp-active'); 15752 bjInPvpMatch = false; 15753 } 15754 15755 function showBjGamePanel(opponentName) { 15756 $('bjModePick')?.classList.add('hidden'); 15757 $('bjLobbyPanel')?.classList.add('hidden'); 15758 $('bjGamePanel')?.classList.remove('hidden'); 15759 if (bjMode === 'pvp') { 15760 $('blackjackModal')?.classList.add('bj-pvp-active'); 15761 if (opponentName) bjLastOpponentName = opponentName; 15762 updateBjOpponentLabel(bjLastOpponentName); 15763 } else { 15764 $('blackjackModal')?.classList.remove('bj-pvp-active'); 15765 updateBjOpponentLabel(); 15766 } 15767 } 15768 15769 function setLobbyStatus(t) { 15770 const el = $('bjLobbyStatus'); 15771 if (el) el.textContent = t || ''; 15772 } 15773 15774 function renderBjLobby(payload) { 15775 const list = $('bjLobbyList'); 15776 if (!list) return; 15777 const players = Array.isArray(payload?.players) ? payload.players : []; 15778 const myId = window.me?.id != null ? Number(window.me.id) : 0; 15779 if (!players.length) { 15780 const waiting = bjSock && bjSock.connected; 15781 list.innerHTML = waiting 15782 ? '<div class="bj-lobby-empty">No one in the lobby yet â you\'re first!</div>' 15783 : '<div class="bj-lobby-empty">Connecting to lobbyâ¦</div>'; 15784 return; 15785 } 15786 list.innerHTML = players.map((p) => { 15787 const isYou = Number(p.userId) === myId; 15788 let meta = 'In lobby'; 15789 if (p.status === 'waiting' && p.bet) meta = `Finding match (${Number(p.bet).toLocaleStr
15789ing()} ð°)`; 15790 else if (p.status === 'in_match' && p.opponentName) meta = `Playing vs ${p.opponentName}`; 15791 else if (p.status === 'in_match') meta = 'In a hand'; 15792 const youTag = isYou ? ' (you)' : ''; 15793 return `<div class="bj-lobby-row${isYou ? ' is-you' : ''}"><span class="bj-lobby-name">${escapeHtml(p.username || 'Player')}${youTag}</span><span class="bj-lobby-meta">${escapeHtml(meta)}</span></div>`; 15794 }).join(''); 15795 } 15796 15797 function escapeHtml(s) { 15798 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); 15799 } 15800 15801 function updateBjOpponentLabel(name) { 15802 const el = $('bjOpponentLabel'); 15803 if (!el) return; 15804 if (bjMode === 'pvp') { 15805 const label = (name && name !== 'Opponent' && name !== 'unknown') ? name : (bjLastOpponentName || 'â¦'); 15806 el.textContent = label; 15807 } else { 15808 el.textContent = 'Dealer'; 15809 } 15810 } 15811 15812 function showBjMatchedBanner(name) { 15813 const banner = $('bjMatchedBanner'); 15814 if (!banner) return; 15815 if (name) { 15816 banner.textContent = 'Matched vs ' + name + '!'; 15817 banner.classList.add('visible'); 15818 } else { 15819 banner.classList.remove('visible'); 15820 banner.textContent = ''; 15821 } 15822 } 15823 15824 window.bjPickMode = async function(mode) { 15825 if (mode !== 'solo' && mode !== 'pvp') return; 15826 bjMode = mode; 15827 if (mode === 'pvp') { 15828 showBjLobbyPanel(); 15829 } else { 15830 showBjGamePanel(); 15831 } 15832 await openBJGame(); 15833 }; 15834 15835 window.bjLobbyBet = function(amt) { 15836 window.bjBet(amt); 15837 }; 15838 15839 window.bjLeaveLobby = function() { 15840 if (bjSock && bjSock.connected) { 15841 if (bjWaitingOpponent) { 15842 bjSock.emit('bj:leave_queue', { 15843 userId: window.me?.id, 15844 username: window.me?.username || 'unknown' 15845 }); 15846 } 15847 bjSock.emit('bj:leave_lobby', { 15848 userId: window.me?.id, 15849 username: window.me?.username || 'unknown' 15850 }); 15851 } 15852 bjWaitingOpponent = false; 15853 bjLastOpponentName = null; 15854 const cancelBtn = $('bjQueueCancel'); 15855 if (cancelBtn) cancelBtn.style.display = 'none'; 15856 showBjModePicker(); 15857 }; 15858 15859 window.bjCancelQueue = function() { 15860 if (!bjSock || !bjSock.connected) return; 15861 bjSock.emit('bj:leave_queue', { 15862 userId: window.me?.id, 15863 username: window.me?.username || 'unknown' 15864 }); 15865 bjWaitingOpponent = false; 15866 const cancelBtn = $('bjQueueCancel'); 15867 if (cancelBtn) cancelBtn.style.display = 'none'; 15868 setLobbyStatus('Matchmaking cancelled. Pick a bet to try again.'); 15869 }; 15870 15871 window.bjPlayAgain = async function() { 15872 if (bjMode !== 'pvp') return; 15873 bjInPvpMatch = false; 15874 bjRoundActive = false; 15875 bjPvpAwaitingPlayAgain = false; 15876 bjLastRoundId = null; 15877 showBjMatchedBanner(null); 15878 $('bjPlayAgain')?.classList.add('hidden'); 15879 showBjLobbyPanel(); 15880 setLobbyStatus('Pick a bet to find an opponent.'); 15881 try { await refreshBalance(); } catch (_) {} 15882 if (bjSock && bjSock.connected) { 15883 emitBjJoin('play-again'); 15884 } 15885 }; 15886 15887 function clearBjBetPendingTimer() { 15888 if (bjBetPendingTimer) { 15889 clearTimeout(bjBetPendingTimer); 15890 bjBetPendingTimer = null; 15891 } 15892 } 15893 15894 function clearBjActionPendingTimer() { 15895 if (bjActionPendingTimer) { 15896 clearTimeout(bjActionPendingTimer); 15897 bjActionPendingTimer = null; 15898 } 15899 } 15900 15901 /** Pull wallet from api.php â never request client-side BJ refunds (server rejects them). */ 15902 function refreshBjBalanceFromServer() { 15903 api('ping', 'POST', {}).then((pr) => { 15904 if (pr?.ok && typeof pr.shekels === 'number') { 15905 setPoints(pr.shekels); 15906 currentBalance = pr.shekels; 15907 bjLastKnownBalance = pr.shekels; 15908 const el = $('bjBalance'); 15909 if (el) { 15910 if (typeof window.ccRefreshCasinoChipBalances === 'function') { 15911 window.ccRefreshCasinoChipBalances('blackjack').then(function () { paintBlackjackChipHud(pr.shekels); }); 15912 } else { 15913 el.textContent = pr.shekels.toLocaleString(); 15914 } 15915 } 15916 } 15917 }).catch(() => {}); 15918 } 15919 15920 function emitBjJoin(reason) { 15921 if (!bjSock) return; 15922 const uid = window.me && window.me.id != null ? Number(window.me.id) : 0; 15923 if (!uid) { 15924 setStatus('Waiting for your account â try again in a moment.'); 15925 return; 15926 } 15927 const joinData = { 15928 userId: uid, 15929 username: window.me?.username || 'unknown', 15930 balance: currentBalance, 15931 mode: bjMode || 'solo' 15932 }; 15933 bjSock.emit('bj:join', joinData); 15934 console.log('[BLACKJACK] Sent join request (' + (reason || 'manual') + '):', joinData); 15935 } 15936 15937 function getBjMediaOrigin(){ 15938 if (typeof window.ccGameMediaOrigin === 'function') return window.ccGameMediaOrigin(); 15939 try { 15940 if (window._webcamSocket?.io?.uri) { 15941 return String(window._webcamSocket.io.uri) 15942 .replace(/\/socket\.io.*$/, '') 15943 .replace(/\/+$/, '') 15944 .replace(/^wss:/i, 'https:') 15945 .replace(/^ws:/i, 'http:'); 15946 } 15947 } catch(_) {} 15948 return String(window.CC_MEDIA_ORIGIN || 'https://media.chatcrack.us').replace(/\/+$/, ''); 15949 } 15950 15951 function getBjIoOpts(){ 15952 return (typeof window.ccGameSocketDefaults === 'function') 15953 ? window.ccGameSocketDefaults() 15954 : { transports: ['polling', 'websocket'], tryAllTransports: true, path: '/socket.io', timeout: 12000, reconnection: true }; 15955 } 15956 15957 function bjModalIsOpen(){ 15958 return !!($('blackjackModal')?.classList.contains('open')); 15959 } 15960 15961 function setBjConnStatus(msg){ 15962 if (!bjModalIsOpen()) return; 15963 if (bjMode === 'pvp' && !bjInPvpMatch) setLobbyStatus(msg); 15964 else setStatus(msg); 15965 } 15966 15967 let bjSocketWired = false;
15968 15969 function wireBjSocketOnce(){ 15970 if (bjSocketWired || !bjSock) return; 15971 bjSocketWired = true; 15972 15973 bjSock.on('connect', () => { 15974 console.log('[BLACKJACK] â Connected to media server'); 15975 console.log('[BLACKJACK] ð Socket ID:', bjSock.id); 15976 bjJoined = true; 15977 if (!bjModalIsOpen()) return; 15978 setBjConnStatus(bjMode === 'pvp' && !bjInPvpMatch ? 'Pick a bet to find an opponent.' : 'Place a bet to start.'); 15979 emitBjJoin('connect'); 15980 refreshBalance(); 15981 if (bjPendingBetAmount != null) { 15982 const pending = bjPendingBetAmount; 15983 bjPendingBetAmount = null; 15984 window.bjBet(pending); 15985 } 15986 }); 15987 15988 bjSock.on('disconnect', (reason) => { 15989 console.log('[BLACKJACK] â Disconnected from media server:', reason); 15990 bjJoined = false; 15991 clearBjBetPendingTimer(); 15992 setBjConnStatus('Disconnected from server.'); 15993 }); 15994 15995 bjSock.on('connect_error', (error) => { 15996 console.error('[BLACKJACK] â Connection error:', error); 15997 bjJoined = false; 15998 setBjConnStatus('Connection error: ' + (error && error.message ? error.message : 'failed')); 15999 }); 16000 16001 bjSock.on('bj:refund_ok', (payload) => { 16002 console.log('[BLACKJACK] Refund ack (legacy):', payload); 16003 refreshBjBalanceFromServer(); 16004 }); 16005 16006 bjSock.on('bj:error', async (m) => { 16007 console.error('[BLACKJACK] â Game error received:', m); 16008 clearBjBetPendingTimer(); 16009 clearBjActionPendingTimer(); 16010 refreshBjBalanceFromServer(); 16011 16012 const msg = m?.msg || m; 16013 // Mid-hand / settle errors must NOT wipe the round â server will re-push bj:state. 16014 const softErrors = new Set([ 16015 'action_failed', 'round_active', 'bet_not_ready', 'double_not_allowed', 16016 'wallet_timeout', 'adjust_failed', 'already_queued' 16017 ]);
16018 if (!softErrors.has(msg)) { 16019 bjRoundActive = false; 16020 } 16021 16022 if (msg === 'balance_fetch_failed') { 16023 showBjBanner(); 16024 } else if (msg === 'insufficient') { 16025 setBjConnStatus('Insufficient funds for this bet.'); 16026 } else if (msg === 'already_queued') { 16027 setLobbyStatus('Already waiting for an opponent at this bet.'); 16028 } else if (msg === 'wallet_timeout') { 16029 setBjConnStatus('Wallet timed out â retrying table syncâ¦'); 16030 emitBjJoin('error-recover'); 16031 } else if (msg === 'action_failed' || msg === 'round_active' || msg === 'bet_not_ready') { 16032 setBjConnStatus(msg === 'round_active' 16033 ? 'Hand still finishing â syncingâ¦' 16034 : 'Action hiccup â syncing tableâ¦'); 16035 emitBjJoin('error-recover'); 16036 } else if (msg === 'not_joined') { 16037 bjJoined = false; 16038 setBjConnStatus('Not joined to table â reconnectingâ¦'); 16039 emitBjJoin('error-recover'); 16040 } else { 16041 setBjConnStatus('Error: ' + msg); 16042 } 16043 }); 16044 16045 bjSock.on('bj:state', (payload) => { 16046 clearBjBetPendingTimer(); 16047 clearBjActionPendingTimer(); 16048 console.log('[BLACKJACK] ð State update received:', payload); 16049 renderBJState(payload); 16050 }); 16051 16052 bjSock.on('bj:lobby', (payload) => { 16053 renderBjLobby(payload); 16054 }); 16055 16056 bjSock.on('bj:matched', (payload) => { 16057 const name = payload?.opponentName; 16058 if (name && name !== 'Opponent' && name !== 'unknown') { 16059 bjPvpAwaitingPlayAgain = false; 16060 $('bjPlayAgain')?.classList.add('hidden'); 16061 bjLastOpponentName = name; 16062 bjInPvpMatch = true; 16063 showBjGamePanel(name); 16064 showBjMatchedBanner(name); 16065 setStatus('Matched vs ' + name + ' â good luck!'); 16066 } 16067 }); 16068 16069 bjSock.on('bj:join_ack', (data) => { 16070 console.log('[BLACKJACK] â Join acknowledged:', data); 16071 bjJoined = true; 16072 if (data && typeof data.balance === 'number' && data.balance > 0) { 16073 currentBalance = data.balance; 16074 if (typeof window.ccRefreshCasinoChipBalances === 'function') { 16075 window.ccRefreshCasinoChipBalances('blackjack').then(function () { paintBlackjackChipHud(); }); 16076 } else { 16077 $('bjBalance').textContent = data.balance.toLocaleString(); 16078 } 16079 } 16080 if (!bjRoundActive && bjModalIsOpen()) { 16081 setBjConnStatus(bjMode === 'pvp' && !bjInPvpMatch ? 'Pick a bet to find an opponent.' : 'Place a bet to start.'); 16082 } 16083 if (bjPendingBetAmount != null) { 16084 const pending = bjPendingBetAmount; 16085 bjPendingBetAmount = null; 16086 window.bjBet(pending); 16087 } 16088 }); 16089
16090 bjSock.on('bj:bet_ack', (data) => { 16091 console.log('[BLACKJACK] â Bet acknowledged:', data); 16092 setStatus('Dealing cardsâ¦'); 16093 }); 16094 16095 bjSock.on('bj:action_ack', (data) => { 16096 console.log('[BLACKJACK] â Action acknowledged:', data); 16097 }); 16098 } 16099 16100 function connectBjSocket(){ 16101 if (typeof window.io !== 'function') return; 16102 if (bjSock && bjSock.connected) return; 16103 if (bjSock) { 16104 wireBjSocketOnce(); 16105 try { if (!bjSock.connected) bjSock.connect(); } catch (_) {} 16106 return; 16107 } 16108 try { 16109 const base = getBjMediaOrigin(); 16110 console.log('[BLACKJACK] Connecting to:', base + '/blackjack'); 16111 bjSock = io(base + '/blackjack', getBjIoOpts()); 16112 window.bjSocket = bjSock; 16113 wireBjSocketOnce(); 16114 } catch (error) { 16115 console.error('[BLACKJACK] Setup error:', error); 16116 setBjConnStatus('Failed to connect to Blackjack server.'); 16117 } 16118 } 16119 16120 function paintBlackjackChipHud(shekels) { 16121 var text = (typeof window.ccGameChipBalanceText === 'function') 16122 ? window.ccGameChipBalanceText('blackjack', shekels) 16123 : ('You have ' + Number(shekels || 0).toLocaleString() + ' shekels'); 16124 var a = $('bjBalance'); 16125 var b = $('bjLobbyBalance'); 16126 if (a) a.textContent = text; 16127 if (b) b.textContent = text; 16128 } 16129 16130 async function getCurrentBalance(){ 16131 let shek = 0; 16132 try { 16133 const result = await api('ping', 'POST', {}); 16134 if (result?.ok && typeof result.shekels === 'number') { 16135 shek = result.shekels; 16136 if (typeof setPoints === 'function') setPoints(result.shekels); 16137 } 16138 } catch (e) { 16139 console.error('Failed to get balance:', e); 16140 } 16141 try { 16142 if (typeof window.ccRefreshCasinoChipBalances === 'function') { 16143 await window.ccRefreshCasinoChipBalances('blackjack'); 16144 } 16145 } catch (_) {} 16146 currentBalance = (typeof window.ccGameSpendable === 'function') 16147 ? window.ccGameSpendable('blackjack', shek) 16148 : shek; 16149 paintBlackjackChipHud(shek); 16150 return currentBalance; 16151 } 16152 16153 // Shared shell wiring so inline games (Blackjack / Hi-Low) get the same 16154 // draggable + resizable window with the standard casino default size. 16155 function ccWireInlineGameWindow(modalId, dialogSel, headerSel) { 16156 const modal = document.getElementById(modalId); 16157 if (!modal) return; 16158 const dlg = modal.querySelector(dialogSel); 16159 const hdr = modal.querySelector(headerSel); 16160 if (!dlg || !hdr) return; 16161 const storageKey = modalId === 'hiLowModal' ? 'hilow' : String(modalId || '').replace(/Modal$/i, '').toLowerCase(); 16162 const layoutOpts = { 16163 storageKey: storageKey, 16164 defaultWidth: 740, 16165 defaultHeight: 640, 16166 minWidth: 360, 16167 minHeight: 420 16168 }; 16169 function wire() { 16170 if (!window.CCGameModalShell) return; 16171 window.CCGameModalShell.setupWindow(dlg, hdr, layoutOpts); 16172 window.CCGameModalShell.fitToViewport(dlg, layoutOpts); 16173 } 16174 if (window.CCGameModalShell) { wire(); return; } 16175 if (typeof window.ccEnsureHeavyScriptBySkipKey === 'function') { 16176 window.ccEnsureHeavyScriptBySkipKey('skip_script_game_modal_shell').then(wire).catch(function () {}); 16177 } 16178 } 16179 window.ccWireInlineGameWindow = ccWireInlineGameWindow; 16180 16181 async function openBJ(){ 16182 if (typeof window.ccCasinoGameEnabledById === 'function' && !window.ccCasinoGameEnabledById('blackjack')) { 16183 if (typeof showToast === 'function') showToast('Blackjack is temporarily unavailable.', 'info'); 16184 return; 16185 } 16186 const modal = $('blackjackModal'); 16187 if (!modal) return; 16188 modal.classList.add("open"); 16189 ccWireInlineGameWindow('blackjackModal', '.bj-dialog', '.bj-header'); 16190 if (typeof ccEnsureAboveShekelsModal === 'function') ccEnsureAboveShekelsModal(modal); 16191 if (!bjMode) { 16192 showBjModePicker(); 16193 return; 16194 } 16195 await openBJGame(); 16196 } 16197 16198 async function openBJGame(){ 16199 const modal = $('blackjackModal'); 16200 if (!modal) return; 16201 if (bjMode === 'pvp') showBjLobbyPanel(); 16202 else showBjGamePanel(); 16203 ensureBjBanner(modal); 16204 16205 // Get current balance from internal Shekels system 16206 const balance = await getCurrentBalance(); 16207 currentBalance = balance; 16208 bjLastKnownBalance = balance; 16209 bjRoundActive = false;
16210 bjLastPlayerCards = []; 16211 bjLastDealerCards = []; 16212 bjLastDealerUp = null; 16213 bjLastPlayerTotal = null; 16214 bjLastDealerShownTotal = null; 16215 paintBlackjackChipHud(); 16216 console.log('[BLACKJACK] Initial balance loaded:', balance); 16217 16218 connectBjSocket(); 16219 16220 modal.classList.add("open"); 16221 if (typeof ccEnsureAboveShekelsModal === 'function') ccEnsureAboveShekelsModal(modal); 16222 if (!bjSock || !bjSock.connected) { 16223 if (bjMode === 'pvp') setLobbyStatus('Connectingâ¦'); 16224 else setStatus('Connectingâ¦'); 16225 } else if (!bjRoundActive && !bjInPvpMatch) { 16226 if (bjMode === 'pvp') setLobbyStatus('Pick a bet to find an opponent.'); 16227 else setStatus('Place a bet to start.'); 16228 emitBjJoin('reopen'); 16229 } 16230 if (bjPendingBetAmount != null && bjSock && bjSock.connected && bjJoined) { 16231 const pending = bjPendingBetAmount; 16232 bjPendingBetAmount = null; 16233 window.bjBet(pending); 16234 } 16235 } 16236 16237 // Function to refresh balance from web API 16238 async function refreshBalance() { 16239 try { 16240 const balance = await getCurrentBalance(); 16241 if (balance !== currentBalance) { 16242 currentBalance = balance; 16243 paintBlackjackChipHud(); 16244 // Update main chat points display when balance is refreshed 16245 setPoints(balance); 16246 console.log('[BLACKJACK] Balance refreshed:', balance); 16247 } 16248 } catch (e) { 16249 console.error('[BLACKJACK] Failed to refresh balance:', e); 16250 } 16251 } 16252 16253 function closeBJ(){ 16254 if (bjSock && bjSock.connected) { 16255 if (bjWaitingOpponent) { 16256 bjSock.emit('bj:leave_queue', { 16257 userId: window.me?.id, 16258 username: window.me?.username || 'unknown' 16259 }); 16260 } 16261 if (bjMode === 'pvp') { 16262 bjSock.emit('bj:leave_lobby', { 16263 userId: window.me?.id, 16264 username: window.me?.username || 'unknown' 16265 }); 16266 } 16267 } 16268 bjWaitingOpponent = false; 16269 bjInPvpMatch = false; 16270 bjPvpAwaitingPlayAgain = false; 16271 bjLastOpponentName = null; 16272 const cancelBtn = $('bjQueueCancel'); 16273 if (cancelBtn) cancelBtn.style.display = 'none'; 16274 showBjMatchedBanner(null); 16275 $('blackjackModal').classList.remove("open"); 16276 showBjModePicker(); 16277 // Clear persistent result indicators when closing 16278 const balBox = document.querySelector('#blackjackModal .bj-balance'); 16279 const statusBox = document.querySelector('#bjStatus'); 16280 if (balBox) { balBox.classList.remove('win','loss'); } 16281 if (statusBox) { statusBox.classList.remove('win','loss'); statusBox.textContent=''; } 16282 // Clear last hands on close 16283 bjLastPlayerCards = []; 16284 bjLastDealerCards = []; 16285 bjLastDealerUp = null; 16286 bjLastPlayerTotal = null; 16287 bjLastDealerShownTotal = null; 16288 const pc=$('bjPlayerCards'), du=$('bjDealerUp'); 16289 const pt=$('bjPlayerTotal'), dt=$('bjDealerShownTotal'); 16290 if(pc) pc.textContent = 'â'; 16291 if(du) du.textContent = 'â'; 16292 if(pt) pt.textContent = ''; 16293 if(dt) dt.textContent = ''; 16294 } 16295 16296 function setStatus(t){ const el=$('bjStatus'); if(el) el.textContent = t || ''; } 16297 16298 // actions 16299 window.bjBet = function(amt){ 16300 const uid = window.me && window.me.id != null ? Number(window.me.id) : 0; 16301 if (!uid) { 16302 setStatus('Account not loaded yet â wait a moment and try again.'); 16303 return; 16304 } 16305 if (!bjMode) { 16306 bjPendingBetAmount = amt; 16307 openBJ(); 16308 return; 16309 } 16310 if (bjMode === 'pvp' && !bjInPvpMatch && !bjSock) { 16311 bjPendingBetAmount = amt; 16312 openBJ(); 16313 return; 16314 } 16315 if (!bjSock) { 16316 bjPendingBetAmount = amt; 16317 openBJ(); 16318 return; 16319 } 16320 if (!bjSock.connected) { 16321 bjPendingBetAmount = amt; 16322 if (bjMode === 'pvp') setLobbyStatus('Connectingâ¦'); 16323 else setStatus('Connectingâ¦'); 16324 return; 16325 } 16326 if (!bjJoined) emitBjJoin('with-bet'); 16327 if (amt > (typeof window.ccGameSpendable === 'function' ? window.ccGameSpendable('blackjack', currentBalance) : currentBalance)) { 16328 console.error('[BLACKJACK] â Insufficient funds:', { bet: amt, balance: currentBalance }); 16329 const msg = 'Insufficient funds! You have ' + (typeof window.ccGameChipBalanceText === 'function' ? window.ccGameChipBalanceText('blackjack') : (currentBalance + ' shekels')) + '.'; 16330 if (bjMode === 'pvp' && !bjInPvpMatch) setLobbyStatus(msg); 16331 else setStatus(msg); 16332 return; 16333 } 16334 // New round: clear previous result indicators and snapshot starting balance 16335 const balBox = document.querySelector('#blackjackModal .bj-balance'); 16336 const statusBox = document.querySelector('#bjStatus'); 16337 if (balBox) balBox.classList.remove('win','loss'); 16338 if (statusBox) statusBox.classList.remove('win','loss'); 16339 bjLastKnownBalance = currentBalance; 16340 // Round starts when server sends bj:state â do not set bjRoundActive here 16341 // Clear persisted last hands now that a new bet starts 16342 bjLastPlayerCards = []; 16343 bjLastDealerCards = []; 16344 bjLastDealerUp = null; 16345 bjLastPlayerTotal = null; 16346 bjLastDealerShownTotal = null; 16347 const pc=$('bjPlayerCards'), du=$('bjDealerUp'); 16348 const pt=$('bjPlayerTotal'), dt=$('bjDealerShownTotal'); 16349 if(pc) pc.textContent = 'â'; 16350 if(du) du.textContent = 'â'; 16351 if(pt) pt.textContent = ''; 16352 if(dt) dt.textContent = ''; 16353 if (bjMode === 'pvp' && !bjInPvpMatch) { 16354 setLobbyStatus('Finding opponent (' + amt + ' shekels)â¦'); 16355 } else { 16356 setStatus(bjMode === 'pvp' ? ('Finding opponent (' + amt + ' shekels)â¦') : ('Betting ' + amt + 'â¦')); 16357 } 16358 console.log('[BLACKJACK] ð² Placing bet:', amt); 16359 console.log('[BLACKJACK] ð Current balance:', currentBalance); 16360 console.log('[BLACKJACK] ð Socket connected:', bjSock.connected); 16361 console.log('[BLACKJACK] ð Socket ID:', bjSock.id); 16362 16363 window.__bjLastBetAmount = amt; 16364 clearBjBetPendingTimer(); 16365 bjBetPendingTimer = setTimeout(function () { 16366 bjBetPendingTimer = null; 16367 if (!bjRoundActive) { 16368 setStatus('Bet timed out â no response from server. Try again or refresh.'); 16369 refreshBjBalanceFromServer(); 16370 } 16371 }, 15000); 16372 var betBody = { 16373 amount: amt, 16374 userId: uid, 16375 username: window.me?.username || 'unknown', 16376 balance: currentBalance 16377 }; 16378 if (typeof window.ccWithPayMode === 'function') { 16379 Promise.resolve(window.ccWithPayMode('blackjack', amt, betBody)).then(function (paid) { 16380 if (!paid) { 16381 clearBjBetPendingTimer(); 16382 setStatus(''); 16383 return; 16384 } 16385 bjSock.emit('bj:bet', paid); 16386 }); 16387 return; 16388 } 16389 bjSock.emit('bj:bet', betBody); 16390 console.log('[BLACKJACK] ð¤ Bet event sent to server'); 16391 }; 16392 16393 window.bjAct = function(t){ 16394 if (!bjSock) { 16395 console.error('[BLACKJACK] â Cannot act - not connected to server'); 16396 setStatus("Not connected to Blackjack server."); 16397 return; 16398 } 16399 if (!bjSock.connected) { 16400 setStatus('Reconnecting to Blackjackâ¦'); 16401 connectBjSocket(); 16402 return; 16403 } 16404 setStatus("Action: " + t + "â¦"); 16405 console.log('[BLACKJACK] ð Taking action:', t); 16406 clearBjActionPendingTimer(); 16407 bjActionPendingTimer = setTimeout(function () { 16408 bjActionPendingTimer = null; 16409 setStatus('Action slow â syncing tableâ¦'); 16410 emitBjJoin('action-timeout'); 16411 }, 10000); 16412 bjSock.emit("bj:action", { type: t, roundId: bjLastRoundId || undefined }); 16413 console.log('[BLACKJACK] ð¤ Action event sent to server'); 16414 }; 16415 16416 window.openBJ = openBJ; 16417 window.closeBJ = closeBJ; 16418 16419 function renderCards(list){ 16420 if (!Array.isArray(list) || !list.length) return "â"; 16421 // Render each card with explicit red (H,D) or white (S,C) for consistent visibility on dark green 16422 const suitSym = s => ({S:'â ',H:'â¥',D:'â¦',C:'â£'})[s] || s; 16423 const safe = list.filter(c => !!c && (typeof c === 'string')); 16424 if (!safe.length) return "â"; 16425 return safe.map(c => { 16426 const str = String(c).trim(); 16427 const suitChar = str.slice(-1).toUpperCase(); 16428 const rank = str.slice(0, -1); 16429 if (!/^[SHDC]$/.test(suitChar)) return str; 16430 const sym = suitSym(suitChar); 16431 const isRed = suitChar === 'H' || suitChar === 'D'; 16432 const cls = isRed ? 'bj-card-red' : 'bj-card-white'; 16433 return '<span class="' + cls + '">' + rank + sym + '</span>'; 16434 }).join('\u00A0\u00A0');
16434 // non-breaking spaces between cards 16435 } 16436 16437 function renderBJState(payload){ 16438 try { 16439 console.log('[BLACKJACK] Rendering state:', payload); 16440 16441 // Don't trust server balance if it's 0 - get fresh balance from web API 16442 if (payload?.balance !== undefined && payload.balance > 0) { 16443 currentBalance = payload.balance; 16444 console.log('[BLACKJACK] Balance updated from server:', currentBalance); 16445 // Update main chat points display when balance changes 16446 setPoints(currentBalance); 16447 } else if (payload?.balance === 0) { 16448 console.log('[BLACKJACK] Server sent 0 balance, keeping local balance:', currentBalance); 16449 // Keep current balance, don't override with 0 16450 // Also try to refresh balance from web API as fallback 16451 refreshBalance(); 16452 } 16453 16454 const bal = currentBalance || 0; 16455 const r = payload?.round || null; 16456 if (r && r.roundId) bjLastRoundId = r.roundId; 16457 16458 paintBlackjackChipHud(); 16459 16460 const isWaiting = r && r.state === 'WAITING_OPPONENT'; 16461 const isPvpHand = r && r.mode === 'pvp' && r.state && r.state !== 'WAITING_OPPONENT'; 16462 16463 if (r && r.mode === 'pvp' && r.opponentName && r.opponentName !== 'Opponent' && r.opponentName !== 'unknown') { 16464 bjLastOpponentName = r.opponentName; 16465 } 16466 16467 if (isWaiting) { 16468 bjInPvpMatch = false; 16469 showBjLobbyPanel(); 16470 $('bjPlayAgain')?.classList.add('hidden'); 16471 if (r.statusText) setLobbyStatus(r.statusText); 16472 } else if (isPvpHand) { 16473 bjInPvpMatch = true; 16474 showBjGamePanel(bjLastOpponentName); 16475 } 16476 16477 if (r && r.mode === 'pvp') updateBjOpponentLabel(bjLastOpponentName); 16478 else if (!r || r.mode === 'solo') updateBjOpponentLabel(); 16479 16480 const queueBtn = $('bjQueueCancel'); 16481 bjWaitingOpponent = !!isWaiting; 16482 if (queueBtn) queueBtn.style.display = isWaiting ? 'block' : 'none'; 16483 16484 const can = r?.can || []; 16485 let customResultSet = false; 16486 16487 // Optional: show server-provided end-of-round banner if present 16488 const playAgainBtn = $('bjPlayAgain'); 16489 if (r && r.mode === 'pvp' && r.state === 'ROUND_COMPLETE') { 16490 bjPvpAwaitingPlayAgain = true; 16491 } 16492 if (bjPvpAwaitingPlayAgain && bjMode === 'pvp') { 16493 playAgainBtn?.classList.remove('hidden'); 16494 } else { 16495 playAgainBtn?.classList.add('hidden'); 16496 } 16497 16498 if (r && r.state === 'ROUND_COMPLETE') { 16499 if (payload?.round?.statusText) { 16500 setStatus(payload.round.statusText); 16501 customResultSet = true; 16502 } else { 16503 const map = {win:'You win!', push:'Push.', blackjack:'Blackjack!', dealer_blackjack:'Dealer Blackjack.', player_bust:'Bust.', lose:'You lose.'}; 16504 const txt = map[r.result] || 'Hand complete.'; 16505 const nd = (payload?.round?.netDelta != null) ? ` ${payload.round.netDelta >= 0 ? '+' : ''}${payload.round.netDelta}` : ''; 16506 setStatus(`${txt}${nd}`); 16507 customResultSet = true; 16508 } 16509 } 16510 16511 // Update snapshots if round payload present 16512 if (r) { 16513 // CRITICAL FIX: Only save cards when they are valid (no null/undefined from exhausted shoe) 16514 const validPlayerCards = Array.isArray(r.player) ? r.player.filter(c => !!c && typeof c === 'string') : []; 16515 if (validPlayerCards.length) { 16516 if ((r.state === 'PLAYER_TURN' || r.state === 'DEAL') && validPlayerCards.length < 2) { 16517 if (!bjLastPlayerCards || bjLastPlayerCards.length < 2) { 16518 console.warn('[BLACKJACK] â ï¸ Incomplete player cards in active round', { state: r.state, roundId: r.roundId }); 16519 } 16520 } else { 16521 bjLastPlayerCards = validPlayerCards.slice(); 16522 } 16523 } else if (Array.isArray(r.player) && r.player.length && r.player.every(c => c == null)) { 16524 console.warn('[BLACKJACK] â ï¸ Server sent null cards (shoe bug) - keeping previous display', { roundId: r.roundId }); 16525 } else if (r.player && !Array.isArray(r.player)) { 16526 console.warn('[BLACKJACK] â ï¸ Player cards not an array:', r.player); 16527 }
16528 // For dealer cards: save only valid cards (ignore null from exhausted shoe) 16529 const validDealerCards = Array.isArray(r.dealer) ? r.dealer.filter(c => !!c && typeof c === 'string') : []; 16530 if (validDealerCards.length) { 16531 bjLastDealerCards = validDealerCards.slice(); 16532 } else if ((r.state === 'ROUND_COMPLETE' || r.state === 'DEALER_TURN') && r.dealerUp) { 16533 // Round complete but dealer array not sent - reconstruct from dealerUp if available 16534 // This handles edge cases where snapshot might not include full dealer array 16535 if (!bjLastDealerCards || !bjLastDealerCards.length) { 16536 bjLastDealerCards = [r.dealerUp]; 16537 } 16538 } 16539 if (r.dealerUp) { 16540 bjLastDealerUp = r.dealerUp; 16541 } 16542 if (r.playerTotal != null) { 16543 bjLastPlayerTotal = r.playerTotal; 16544 } 16545 if (r.dealerShownTotal != null) { 16546 bjLastDealerShownTotal = r.dealerShownTotal; 16547 } 16548 // CRITICAL: Also save dealerTotal when round completes 16549 if (r.dealerTotal != null) { 16550 bjLastDealerShownTotal = r.dealerTotal; 16551 } 16552 } 16553 // Helper: only use round cards if they have at least one valid (non-null) card 16554 const validCards = (arr) => Array.isArray(arr) && arr.some(c => !!c && typeof c === 'string'); 16555 // Render using current round if available, otherwise persistent snapshots 16556 let playerCardsToShow = null; 16557 if (validCards(r?.player)) { 16558 playerCardsToShow = r.player.filter(c => !!c && typeof c === 'string'); 16559 } 16560 if (!playerCardsToShow && bjLastPlayerCards && Array.isArray(bjLastPlayerCards) && bjLastPlayerCards.length > 0) { 16561 playerCardsToShow = bjLastPlayerCards; 16562 } 16563 if (!playerCardsToShow && r && r.state !== 'WAITING_BET' && bjLastPlayerCards && bjLastPlayerCards.length > 0) { 16564 playerCardsToShow = bjLastPlayerCards; 16565 } 16566 16567 let dealerCardsToShow = null; 16568 if (validCards(r?.dealer)) { 16569 dealerCardsToShow = r.dealer.filter(c => !!c && typeof c === 'string'); 16570 } 16571 if (!dealerCardsToShow && bjLastDealerCards && Array.isArray(bjLastDealerCards) && bjLastDealerCards.length) { 16572 dealerCardsToShow = bjLastDealerCards; 16573 } 16574 if (!dealerCardsToShow && (r?.dealerUp && typeof r.dealerUp === 'string')) { 16575 dealerCardsToShow = [r.dealerUp]; 16576 } 16577 if (!dealerCardsToShow && bjLastDealerUp) { 16578 dealerCardsToShow = [bjLastDealerUp]; 16579 } 16580 16581 const playerTotalToShow = (r && r.playerTotal != null) ? r.playerTotal : bjLastPlayerTotal; 16582 16583 // CRITICAL FIX: Use dealerTotal when round is complete, dealerShownTotal otherwise 16584 const isRoundComplete = r?.state === 'ROUND_COMPLETE' || r?.state === 'DEALER_TURN'; 16585 const dealerShownTotalToShow = isRoundComplete && r?.dealerTotal != null 16586 ? r.dealerTotal 16587 : ((r && r.dealerShownTotal != null) ? r.dealerShownTotal : bjLastDealerShownTotal); 16588 16589 // CRITICAL FIX: Always render cards if available, even if round is null 16590 // This ensures cards persist after round completion 16591 // DEBUG: Log what we're rendering to track missing cards 16592 if (playerCardsToShow) { 16593 console.log('[BLACKJACK] ð´ Rendering player cards:', { 16594 cardCount: playerCardsToShow.length, 16595 cards: playerCardsToShow, 16596 fromRound: !!r, 16597 fromPersistent: !r && !!bjLastPlayerCards 16598 }); 16599 } 16600 $('bjPlayerCards').innerHTML = playerCardsToShow ? renderCards(playerCardsToShow) : 'â'; 16601 $('bjDealerUp').innerHTML = dealerCardsToShow ? renderCards(dealerCardsToShow) : 'â'; 16602 $('bjPlayerTotal').textContent = (playerTotalToShow != null) ? ("Total: " + playerTotalToShow) : ''; 16603 // Show "Total:" when round complete, "Showing:" during play 16604 const dealerTotalLabel = isRoundComplete ? "Total: " : "Showing: "; 16605 $('bjDealerShownTotal').textContent = (dealerShownTotalToShow != null) ? (dealerTotalLabel + dealerShownTotalToShow) : ''; 16606 16607 // enable/disable actions based on r.can 16608 const on = (id, ok)=>{ const el=$(id); if(el){ el.disabled = !ok; } }; 16609 on('bjHit', can.includes('hit')); 16610 on('bjStand', can.includes('stand')); 16611 on('bjDouble',can.includes('double')); 16612 16613 // round lifecycle + persistent result messaging 16614 const statusBox = $('bjStatus'); 16615 const balBox = document.querySelector('#blackjackModal .bj-balance'); 16616 const state = r?.state || 'WAITING_BET'; 16617 const isActive = !!r && state !== 'WAITING_BET' && state !== 'WAITING_OPPONENT'; 16618 16619 // detect round start / update live status during play 16620 if (isActive && !customResultSet) { 16621 if (state === 'DEAL') setStatus('Dealing cardsâ¦'); 16622 else if (state === 'PLAYER_TURN') { 16623 if (r?.statusText) setStatus(r.statusText); 16624 else setStatus('Your turn - Hit or Stand?'); 16625 } 16626 else if (state === 'DEALER_TURN' && r?.mode !== 'pvp') setStatus('Dealer is drawing cardsâ¦'); 16627 else if (state === 'WAITING_OPPONENT' && r?.statusText) setStatus(r.statusText); 16628 } 16629 if (!bjRoundActive && isActive) { 16630 bjRoundActive = true; 16631 bjLastKnownBalance = bal; 16632 } 16633 16634 // detect round end (transition to waiting/no round) 16635 if (bjRoundActive && !isActive) { 16636 const delta = bal - bjLastKnownBalance; 16637 if (balBox) balBox.classList.remove('win','loss'); 16638 if (statusBox) statusBox.classList.remove('win','loss'); 16639 if (delta > 0) { 16640 if (!customResultSet) setStatus(`You won +${delta} shekels! ð`); 16641 if (balBox) balBox.classList.add('win'); 16642 if (statusBox) statusBox.classList.add('win'); 16643 } else if (delta < 0) { 16644 if (!customResultSet) setStatus(`You lost ${Math.abs(delta)} shekels.`); 16645 if (balBox) balBox.classList.add('loss'); 16646 if (statusBox) statusBox.classList.add('loss'); 16647 } else { 16648 if (!customResultSet) setStatus('Push! Your bet is returned.'); 16649 } 16650 16651 // Update the main chat points display when round completes 16652 setPoints(bal); 16653 16654 bjRoundActive = false; // persist message until next bet or close 16655 } 16656 16657 // If no round has started yet, show prompt (but do not clear a result message) 16658 if (!bjRoundActive && !r) { 16659 if (!statusBox?.classList.contains('win') && !statusBox?.classList.contains('loss')) { 16660 setStatus('Place a bet to start.'); 16661 } 16662 } 16663 16664 // Dealer total display rule: if dealer revealed, prefer full total 16665 if (r && (r.state === 'ROUND_COMPLETE' || (Array.isArray(r.dealer) && r.dealer.length) || (r.dealerTotal != null))) { 16666 if (r.dealerTotal != null) { 16667 $('bjDealerShownTotal').textContent = 'Total: ' + r.dealerTotal; 16668 } else if (bjLastDealerCards && bjLastDealerCards.length && bjLastDealerShownTotal != null) { 16669 $('bjDealerShownTotal').textContent = 'Total: ' + bjLastDealerShownTotal; 16670 } 16671 } 16672 16673 console.log('[BLACKJACK] State rendered successfully'); 16674 } catch (e) { 16675 console.error('[BLACKJACK] Render error:', e); 16676 setStatus('Render error: ' + e.message); 16677 } 16678 } 16679 16680 // --- Banner / Retry / Health Check helpers --- 16681 function ensureBjBanner(modal){ 16682 let banner = modal.querySelector('.bj-banner'); 16683 if (banner) return banner; 16684 banner = document.createElement('div'); 16685 banner.className = 'bj-banner'; 16686 banner.innerHTML = ` 16687 <div><strong>Canât reach wallet API</strong></div> 16688 <div>This is usually due to a WAF/Cloudflare challenge on /api.php. You can retry, or run an API health check to confirm.</div> 16689 <div class="bj-banner-actions"> 16690 <button class="bj-retry">Retry</button> 16691 <button class="bj-health">Run API Health Check</button> 16692 <button class="bj-dismiss">Dismiss</button> 16693 </div> 16694 <div class="bj-health-out" style="margin-top:6px; font-size:12px; color:#bcd; white-space:pre-wrap;"></div> 16695 `; 16696 modal.querySelector('.bj-body')?.prepend(banner); 16697 banner.querySelector('.bj-dismiss')?.addEventListener('click', ()=> hideBjBanner()); 16698 banner.querySelector('.bj-retry')?.addEventListener('click', ()=> retryBjJoin(true)); 16699 banner.querySelector('.bj-health')?.addEventListener('click', runApiHealthCheck); 16700 return banner; 16701 } 16702 16703 function showBjBanner(){ 16704 const modal = $('blackjackModal'); 16705 const banner = ensureBjBanner(modal); 16706 if (banner) banner.style.display = 'block'; 16707 } 16708 function hideBjBanner(){ 16709 const modal = $('blackjackModal'); 16710 const banner = modal?.querySelector('.bj-banner'); 16711 if (banner) banner.style.display = 'none'; 16712 } 16713
16714 async function runApiHealthCheck(){ 16715 const out = $('blackjackModal')?.querySelector('.bj-health-out'); 16716 try{ 16717 const r = await fetch('/api_health.php', { credentials:'omit' }); 16718 const ct = r.headers.get('content-type') || ''; 16719 if (!ct.includes('application/json')) { 16720 out.textContent = 'Health check returned non-JSON. Likely challenged.'; 16721 return; 16722 } 16723 const j = await r.json(); 16724 out.textContent = JSON.stringify(j, null, 2); 16725 }catch(e){ 16726 out.textContent = 'Health check failed: ' + e.message; 16727 } 16728 } 16729 16730 function retryBjJoin(manual){ 16731 if (!bjSock) return; 16732 if (manual){ 16733 bjRetryDelayMs = 1000; 16734 emitBjJoin('manual-retry'); 16735 return; 16736 } 16737 clearTimeout(bjRetryTimer); 16738 bjRetryTimer = setTimeout(()=>{ 16739 emitBjJoin('auto-retry'); 16740 bjRetryDelayMs = Math.min(bjRetryMaxMs, bjRetryDelayMs * 2); 16741 }, bjRetryDelayMs); 16742 } 16743 16744 // Set up the button click handler 16745 function setupBlackjackButton(){ 16746 if (typeof window.ccWireCasinoGameButtons === 'function') { 16747 window.ccWireCasinoGameButtons(); 16748 return; 16749 } 16750 const btn = $('openBlackjackButton'); 16751 if (btn) { 16752 btn.onclick = openBJ; 16753 } 16754 } 16755 16756 // mount once DOM is ready 16757 if (document.readyState !== 'loading') setupBlackjackButton(); 16758 else document.addEventListener('DOMContentLoaded', setupBlackjackButton); 16759 16760 if (typeof window.ccRegisterGameReconnect === 'function') { 16761 window.ccRegisterGameReconnect(function () { 16762 // Only keep BJ alive while the modal is open â every chat tab used to open 16763 // a persistent /blackjack socket at boot and thrash on media reconnect. 16764 if (!bjModalIsOpen()) return; 16765 connectBjSocket(); 16766 if (bjSock && bjSock.connected) emitBjJoin('media-reconnect'); 16767 }); 16768 } 16769 16770 // Do NOT connect blackjack on page load â connect inside openBJGame() only. 16771})(); 16772/// ===== END BLACKJACK JS [BJ_JS_V1] ===== 16773 16774// ===== BEGIN HI/LOW JS [HILOW_JS_V1] ===== 16775(function(){ 16776 let currentBalance = 0; 16777 let hilowShekels = 0; 16778 let selectedChoice = null; 16779 let currentBet = 100; 16780 const $ = (id)=>document.getElementById(id); 16781 16782 function hilowChipBal() { 16783 return (typeof window.ccGameChipBal === 'function') 16784 ? window.ccGameChipBal('hilow') 16785 : Math.max(0, Number((typeof window.ccGameChipCount === 'function' && window.ccGameChipCount('hilow')) || 0)); 16786 } 16787 function hilowRemainingShekels() { 16788 return parseInt(String(($('hilowRemaining') && $('hilowRemaining').textContent) || '0').replace(/,/g, ''), 10) || 0; 16789 } 16790 function hilowFillSplit(betAmount) { 16791 betAmount = Math.max(0, Math.floor(Number(betAmount) || 0)); 16792 const chips = hilowChipBal(); 16793 const rem = hilowRemainingShekels(); 16794 const shekAllowed = Math.min(hilowShekels, rem); 16795 let s = Math.min(shekAllowed, betAmount); 16796 let c = betAmount - s; 16797 if (c > chips) { 16798 c = chips; 16799 s = Math.max(0, betAmount - c); 16800 } 16801 return { chips: Math.max(0, c), shekels: Math.max(0, s) }; 16802 } 16803 function hilowPayChoice(betAmount) { 16804 betAmount = Math.max(0, Math.floor(Number(betAmount) || 0)); 16805 const fill = hilowFillSplit(betAmount); 16806 const mode = (fill.shekels === betAmount) ? 'shekels' : ((fill.shekels === 0) ? 'chips' : 'mix'); 16807 return { pay_mode: mode, pay_chips: fill.chips, pay_shekels: fill.shekels }; 16808 } 16809 function syncHilowPayUi(opts) { 16810 opts = opts || {}; 16811 const betAmount = parseInt(($('hilowBet') && $('hilowBet').value) || '0', 10) || 0; 16812 const sel = $('hilowPayMode'); 16813 const split = $('hilowPaySplit'); 16814 const hint = $('hilowPaySplitHint'); 16815 const pay = hilowPayChoice(betAmount); 16816 if (sel) sel.style.display = 'none'; 16817 if (split) split.style.display = 'none'; 16818 const rowLabel = document.querySelector('label[for="hilowPayMode"]'); 16819 if (rowLabel) rowLabel.textContent = 'Payment'; 16820 if (hint) { 16821 if (pay.pay_shekels && pay.pay_chips) { 16822 hint.textContent = pay.pay_shekels.toLocaleString() + ' shekels + ' + pay.pay_chips.toLocaleString() + ' chips (shekels first).'; 16823 } else if (pay.pay_shekels) { 16824 hint.textContent = 'Paying ' + pay.pay_shekels.toLocaleStr
16824ing() + ' shekels.'; 16825 } else if (pay.pay_chips) { 16826 hint.textContent = 'Paying ' + pay.pay_chips.toLocaleString() + ' chips (no shekels left for this bet).'; 16827 } else { 16828 hint.textContent = ''; 16829 } 16830 hint.style.color = '#9aa4bb'; 16831 } 16832 return pay; 16833 } 16834 16835 function paintHilowChipHud(shekels) { 16836 var el = $('hilowBalance'); 16837 if (!el) return; 16838 el.textContent = (typeof window.ccGameChipBalanceText === 'function') 16839 ? window.ccGameChipBalanceText('hilow', shekels) 16840 : ('You have ' + Number(shekels || 0).toLocaleString() + ' shekels'); 16841 } 16842 16843 async function getCurrentBalance(){ 16844 // Prefer wallet balance (same as header / Shekels modal). Ping-fast can 16845 // echo raw users.points which may disagree with the shared wallet. 16846 let shek = 0; 16847 try { 16848 const result = await api('get_shekels'); 16849 if (result?.ok && typeof result.shekels === 'number') { 16850 shek = result.shekels; 16851 } 16852 } catch (e) { 16853 console.error('Failed to get_shekels for Hi/Low:', e); 16854 } 16855 if (!shek) { 16856 try { 16857 if (window.me && typeof window.me.shekels === 'number') { 16858 shek = window.me.shekels | 0; 16859 } 16860 } catch (_) {} 16861 } 16862 try { 16863 if (typeof window.ccRefreshCasinoChipBalances === 'function') { 16864 await window.ccRefreshCasinoChipBalances('hilow'); 16865 } 16866 } catch (_) {} 16867 currentBalance = (typeof window.ccGameSpendable === 'function') 16868 ? window.ccGameSpendable('hilow', shek) 16869 : shek; 16870 hilowShekels = shek; 16871 paintHilowChipHud(shek); 16872 return currentBalance; 16873 } 16874 16875 async function getDailyGambled(){ 16876 try { 16877 const response = await fetch('/api.php?action=daily_gambled', { 16878 method: 'GET', 16879 credentials: 'same-origin' 16880 }); 16881 const result = await response.json(); 16882 if (result?.ok && typeof result.daily_gambled === 'number') { 16883 return result.daily_gambled; 16884 } 16885 } catch (e) { 16886 console.error('Failed to get daily gambled amount:', e); 16887 } 16888 return 0; 16889 } 16890 16891 async function getDailyGambledData(){ 16892 try { 16893 const response = await fetch('/api.php?action=daily_gambled', { 16894 method: 'GET', 16895 credentials: 'same-origin' 16896 }); 16897 const result = await response.json(); 16898 if (result?.ok) { 16899 return { 16900 daily_gambled: result.daily_gambled || 0, 16901 remaining: result.remaining || 0, 16902 limit_resets_at_unix: result.limit_resets_at_unix || null 16903 }; 16904 } 16905 } catch (e) { 16906 console.error('Failed to get daily gambled data:', e); 16907 } 16908 return { daily_gambled: 0, remaining: 2000, limit_resets_at_unix: null }; 16909 } 16910 16911 // --- Hi/Low countdown timer --- 16912 let _hilowCountdownInterval = null; 16913 function startHilowCountdown(resetsAtUnix) { 16914 stopHilowCountdown(); 16915 const row = $('hilowResetRow'); 16916 const el = $('hilowResetCountdown'); 16917 if (!row || !el) return; 16918 if (!resetsAtUnix) { row.style.display = 'none'; return; } 16919 function tick() { 16920 const secsLeft = resetsAtUnix - Math.floor(Date.now() / 1000); 16921 if (secsLeft <= 0) { 16922 el.textContent = 'now'; 16923 stopHilowCountdown(); 16924 // Refresh data so UI updates 16925 getDailyGambledData().then(d => { 16926 $('hilowDailyGambled').textContent = (d.daily_gambled || 0).toLocaleString(); 16927 $('hilowRemaining').textContent = (d.remaining || 0).toLocaleString(); 16928 startHilowCountdown(d.limit_resets_at_unix); 16929 }); 16930 return; 16931 } 16932 const h = Math.floor(secsLeft / 3600); 16933 const m = Math.floor((secsLeft % 3600) / 60); 16934 const s = secsLeft % 60; 16935 el.textContent = (h > 0 ? String(h).padStart(2,'0') + ':' : '') + 16936 String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0'); 16937 } 16938 tick(); 16939 _hilowCountdownInterval = setInterval(tick, 1000); 16940 row.style.display = 'block'; 16941 } 16942 function stopHilowCountdown() { 16943 if (_hilowCountdownInterval) { clearInterval(_hilowCountdownInterval); _hilowCountdownInterval = null; } 16944 const row = $('hilowResetRow'); 16945 if (row) row.style.display = 'none'; 16946 } 16947 16948 function _applyHilowDailyData(dailyData) { 16949 $('hilowDailyGambled').textContent = (dailyData.daily_gambled || 0).toLocaleStr
16949ing(); 16950 $('hilowRemaining').textContent = (dailyData.remaining || 0).toLocaleString(); 16951 if (dailyData.remaining < 2000 && dailyData.limit_resets_at_unix) { 16952 startHilowCountdown(dailyData.limit_resets_at_unix); 16953 } else { 16954 stopHilowCountdown(); 16955 } 16956 } 16957 window._applyHilowDailyData = _applyHilowDailyData; 16958 16959 async function openHiLow(){ 16960 if (typeof window.ccCasinoGameEnabledById === 'function' && !window.ccCasinoGameEnabledById('hilow')) { 16961 if (typeof showToast === 'function') showToast('Hi/Low is temporarily unavailable.', 'info'); 16962 return; 16963 } 16964 const modal = $('hiLowModal'); 16965 if (!modal) return; 16966 16967 // Show the window first â it used to stay hidden until balance + daily 16968 // limit both came back, so the click felt dead for a whole round-trip. 16969 modal.classList.add("open"); 16970 if (typeof window.ccWireInlineGameWindow === 'function') window.ccWireInlineGameWindow('hiLowModal', '.hilow-dialog', '.hilow-header'); 16971 if (typeof ccEnsureAboveShekelsModal === 'function') ccEnsureAboveShekelsModal(modal); 16972 if ($('hilowResult')) $('hilowResult').style.display = 'none'; 16973 if ($('hilowChooseHi')) $('hilowChooseHi').classList.remove('selected'); 16974 if ($('hilowChooseLow')) $('hilowChooseLow').classList.remove('selected'); 16975 16976 const balance = await getCurrentBalance(); 16977 currentBalance = balance; 16978 16979 const dailyData = await getDailyGambledData(); 16980 _applyHilowDailyData(dailyData); 16981 16982 // Reset game state â clamp bet to what they can actually afford. 16983 selectedChoice = null; 16984 const remainingToday = hilowRemainingShekels(); 16985 const chips = hilowChipBal(); 16986 const shekAllowed = Math.min(hilowShekels, remainingToday); 16987 const maxAffordable = Math.max(0, Math.min(2000, chips + shekAllowed)); 16988 currentBet = maxAffordable >= 100 ? Math.min(100, maxAffordable) : 100; 16989 if ($('hilowBet')) $('hilowBet').value = currentBet; 16990 if ($('hilowResult')) $('hilowResult').style.display = 'none'; 16991 if ($('hilowChooseHi')) $('hilowChooseHi').classList.remove('selected'); 16992 if ($('hilowChooseLow')) $('hilowChooseLow').classList.remove('selected'); 16993 setupHiLowHandlers(); 16994 syncHilowPayUi({ resetSplit: true }); 16995 updateRollButton(); 16996 } 16997 16998 function closeHiLow(){ 16999 stopHilowCountdown(); 17000 $('hiLowModal').classList.remove("open"); 17001 } 17002 17003 function updateRollButton(){ 17004 const rollBtn = $('hilowRoll'); 17005 if (!rollBtn) return; 17006 const betAmount = parseInt(($('hilowBet') && $('hilowBet').value) || '0', 10) || 0; 17007 const remainingToday = hilowRemainingShekels(); 17008 const chips = hilowChipBal(); 17009 const shekAllowed = Math.min(hilowShekels, remainingToday); 17010 const spendable = chips + shekAllowed; 17011 const pay = syncHilowPayUi(); 17012 let reason = ''; 17013 if (!selectedChoice) reason = 'Pick Hi or Low to enable Roll.'; 17014 else if (spendable < 100) reason = 'You need at least 100 chips or shekels to play.'; 17015 else if (betAmount < 100) reason = 'Minimum bet is 100.'; 17016 else if (betAmount > 2000) reason = 'Max bet is 2,000.'; 17017 else if (betAmount > spendable) reason = 'Bet is higher than chips + remaining shekels (' + spendable.toLocaleString() + ').'; 17018 else if (pay.pay_mode === 'chips' && pay.pay_chips > chips) reason = 'Not enough Hi/Low chips.'; 17019 else if (pay.pay_mode === 'shekels' && pay.pay_shekels > remainingToday) reason = 'Shekel portion exceeds your remaining daily shekel limit.'; 17020 else if (pay.pay_mode === 'shekels' && pay.pay_shekels > hilowShekels) reason = 'Not enough shekels.'; 17021 else if (pay.pay_mode === 'mix' && (pay.pay_chips + pay.pay_shekels !== betAmount)) reason = 'Chips + shekels must equal ' + betAmount.toLocaleString() + '.'; 17022 else if (pay.pay_chips > chips) reason = 'Not enough Hi/Low chips.'; 17023 else if (pay.pay_shekels > remainingToday) reason = 'Shekel portion exceeds your remaining daily shekel limit. Use more chips.'; 17024 else if (pay.pay_shekels > hilowShekels) reason = 'Not enough shekels.'; 17025 const ok = !reason; 17026 rollBtn.disabled = !ok; 17027 const hint = $('hilowRollHint'); 17028 if (hint) { 17029 hint.textContent = reason; 17030 hint.style.display = reason ? 'block' : 'none'; 17031 } 17032 const splitHint = $('hilowPaySplitHint'); 17033 if (splitHint && reason && (pay.pay_chips > chips || pay.pay_shekels > remainingToday || pay.pay_shekels > hilowShekels)) { 17034 splitHint.textContent = reason; 17035 splitHint.style.color = '#ffb4b4'; 17036 } 17037 } 17038 17039 function selectChoice(choice){ 17040 selectedChoice = choice; 17041 if ($('hilowChooseHi')) $('hilowChooseHi').classList.toggle('selected', choice === 'hi'); 17042 if ($('hilowChooseLow')) $('hilowChooseLow').classList.toggle('selected', choice === 'low'); 17043 updateRollButton(); 17044 } 17045 17046 async function rollDice(){ 17047 const betAmount = parseInt($('hilowBet').value) || 0; 17048 const pay = syncHilowPayUi(); 17049 const chips = hilowChipBal(); 17050 const remainingToday = hilowRemainingShekels(); 17051 const spendable = chips + Math.min(hilowShekels, remainingToday); 17052 17053 if (betAmount < 100 || betAmount > 2000 || betAmount > spendable) { 17054 alert('Invalid bet amount. Max bet is 2,000. Daily shekel limit does not apply to chips.'); 17055 return; 17056 } 17057 if (pay.pay_chips + pay.pay_shekels !== betAmount || pay.pay_chips > chips || pay.pay_shekels > remainingToday || pay.pay_shekels > hilowShekels) { 17058 alert('Choose chips, shekels, or both so the amounts add up to your bet. Only the shekel part counts toward the daily limit.'); 17059 return; 17060 } 17061 17062 $('hilowRoll').disabled = true; 17063 $('hilowRoll').textContent = 'ð² Rolling...'; 17064 17065 try { 17066 const paid = typeof window.ccWithPayMode === 'function' 17067 ? await window.ccWithPayMode('hilow', betAmount, Object.assign({ game: 'hi_low', bet: betAmount, choice: selectedChoice }, pay)) 17068 : Object.assign({ game: 'hi_low', bet: betAmount, choice: selectedChoice }, pay); 17069 if (!paid) { 17070 $('hilowRoll').disabled = false;
17071 $('hilowRoll').textContent = 'ð² Roll'; 17072 return; 17073 } 17074 const result = await api('gamble', 'POST', paid); 17075 console.log('[HI/LOW] ð gamble API result:', result); 17076 17077 if (!result?.ok) { 17078 const resultDiv = $('hilowResult'); 17079 resultDiv.style.display = 'block'; 17080 resultDiv.className = 'hilow-result lose'; 17081 if (result?.error === 'daily_limit_exceeded') { 17082 resultDiv.innerHTML = `<div>ð« Daily limit exceeded: ${result?.message || 'You have reached your daily gambling limit.'}</div>`; 17083 } else { 17084 resultDiv.innerHTML = `<div>â ï¸ Bet failed: ${result?.error || result?.message || 'Unknown error'}</div>`; 17085 } 17086 return; 17087 } 17088 17089 const roll = result.roll; 17090 const isWin = !!result.won; 17091 const winnings = result.winnings || 0; 17092 const multiplier = result.multiplier || 1; 17093 17094 const resultDiv = $('hilowResult'); 17095 resultDiv.style.display = 'block'; 17096 resultDiv.className = 'hilow-result ' + (isWin ? 'win' : 'lose'); 17097 if (isWin) { 17098 resultDiv.innerHTML = ` 17099 <div>ð You won!</div> 17100 <div>Rolled: ${roll}</div> 17101 <div>Winnings: ${winnings.toLocaleString()} shekels</div> 17102 ${multiplier === 2 ? '<div>ð Double win on 50!</div>' : ''} 17103 `; 17104 } else { 17105 resultDiv.innerHTML = ` 17106 <div>ð You lost!</div> 17107 <div>Rolled: ${roll}</div> 17108 <div>Lost: ${betAmount.toLocaleString()} shekels</div> 17109 `; 17110 } 17111 17112 const newBalance = typeof result.new_balance === 'number' 17113 ? result.new_balance 17114 : await getCurrentBalance(); 17115 currentBalance = newBalance; 17116 try { 17117 if (typeof window.ccRefreshCasinoChipBalances === 'function') { 17118 await window.ccRefreshCasinoChipBalances('hilow'); 17119 } 17120 } catch (_) {} 17121 paintHilowChipHud(typeof result.new_balance === 'number' ? result.new_balance : undefined); 17122 const dailyData = await getDailyGambledData(); 17123 _applyHilowDailyData(dailyData); 17124 refreshMainShekelsBalance(); 17125 17126 } catch (e) { 17127 console.error('Roll error:', e); 17128 $('hilowResult').innerHTML = '<div>â Error occurred</div>'; 17129 } finally { 17130 if ($('hilowRoll')) $('hilowRoll').textContent = 'ð² Roll 1D100'; 17131 updateRollButton(); 17132 } 17133 } 17134 17135 // Set up event handlers (safe to call more than once) 17136 function setupHiLowHandlers(){ 17137 const openBtn = $('openHiLowButton'); 17138 const closeBtn = $('hilowClose'); 17139 const hiBtn = $('hilowChooseHi'); 17140 const lowBtn = $('hilowChooseLow'); 17141 const rollBtn = $('hilowRoll'); 17142 const betInput = $('hilowBet'); 17143 const paySel = $('hilowPayMode'); 17144 const payChips = $('hilowPayChips'); 17145 const payShek = $('hilowPayShekels'); 17146 17147 if (typeof window.ccWireCasinoGameButtons === 'function') { 17148 window.ccWireCasinoGameButtons(); 17149 } else if (openBtn) { 17150 openBtn.onclick = openHiLow; 17151 } 17152 if (closeBtn) closeBtn.onclick = closeHiLow; 17153 if (hiBtn) hiBtn.onclick = () => selectChoice('hi'); 17154 if (lowBtn) lowBtn.onclick = () => selectChoice('low'); 17155 if (rollBtn) rollBtn.onclick = rollDice; 17156 if (betInput) { 17157 betInput.oninput = function () { 17158 currentBet = parseInt(betInput.value) || 100; 17159 syncHilowPayUi({ resetSplit: true }); 17160 updateRollButton(); 17161 }; 17162 betInput.onchange = () => { 17163 currentBet = parseInt(betInput.value) || 100; 17164 syncHilowPayUi({ resetSplit: true }); 17165 updateRollButton(); 17166 }; 17167 } 17168 if (paySel) paySel.onchange = function () { 17169 syncHilowPayUi({ resetSplit: true }); 17170 updateRollButton(); 17171 }; 17172 if (payChips) payChips.oninput = function () { 17173 const betAmount = parseInt(($('hilowBet') && $('hilowBet').value) || '0', 10) || 0; 17174 const c = Math.max(0, Math.floor(Number(payChips.value) || 0)); 17175 if (payShek) payShek.value = String(Math.max(0, betAmount - c)); 17176 updateRollButton(); 17177 }; 17178 if (payShek) payShek.oninput = function () { 17179 const betAmount = parseInt(($('hilowBet') && $('hilowBet').value) || '0', 10) || 0; 17180 const s = Math.max(0, Math.floor(Number(payShek.value) || 0));
17181 if (payChips) payChips.value = String(Math.max(0, betAmount - s)); 17182 updateRollButton(); 17183 }; 17184 } 17185 17186 // Function to refresh main Shekels balance display 17187 function refreshMainShekelsBalance() { 17188 try { 17189 // Update the top header "Shekels: N" count (and the Shekels modal) instantly from the 17190 // freshly-fetched balance â setPoints() drives both #pts and #shekelsBalance. 17191 if (typeof setPoints === 'function' && typeof currentBalance === 'number') { 17192 setPoints(currentBalance); 17193 return; 17194 } 17195 const balanceEl = document.getElementById('shekelsBalance'); 17196 if (balanceEl) { 17197 api('get_shekels').then(r => { 17198 if (r?.ok && typeof r.shekels === 'number') { 17199 if (typeof setPoints === 'function') setPoints(r.shekels); 17200 else balanceEl.textContent = r.shekels.toLocaleString(); 17201 } 17202 }).catch(e => { 17203 console.error('[HI/LOW] Failed to refresh main balance:', e); 17204 }); 17205 } 17206 } catch (e) { 17207 console.error('[HI/LOW] Error refreshing main balance:', e); 17208 } 17209 } 17210 17211 // Global functions 17212 window.openHiLow = openHiLow; 17213 window.closeHiLow = closeHiLow; 17214 17215 // mount once DOM is ready 17216 if (document.readyState !== 'loading') setupHiLowHandlers(); 17217 else document.addEventListener('DOMContentLoaded', setupHiLowHandlers); 17218 if (typeof window.ccWireCasinoGameButtons === 'function') window.ccWireCasinoGameButtons(); 17219})(); 17220/// ===== END HI/LOW JS [HILOW_JS_V1] ===== 17221
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.