PageSourceSearch

https://chatcrack.us/cc-chat-core-3.js?v=20260930-camhold1

js chatcrack.us collected 2026-10-02 02:11:47 UTC 811,459 bytes, 17,221 lines download raw bytes

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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.