PageSourceSearch

https://pupwife.com/projects/chatroom/scripts/dmCalls.js

js pupwife.com collected 2026-09-30 15:07:12 UTC 31,444 bytes, 853 lines download raw bytes

1/**
2 * Direct Message Call System
3 * Handles 1-on-1 voice calls between users via DMs
4 */
5
6window.ChatroomDMCalls = (function() {
7    'use strict';
8
9    let currentCall = null; // { username, connection, localStream, isIncoming }
10    let currentUsername = null;
11    let peer = null; // PeerJS instance
12    let callListeners = new Map();
13    let isInCall = false;
14    let callPhase = null; // 'ringing' | 'connected'
15    let callStartedAt = null;
16    let callTimerInterval = null;
17    let isDmCallMuted = false;
18    let dmCallControlsBound = false;
19
20    /**
21     * Initialize DM call system
22     */
23    function init() {
24        if (typeof firebase === 'undefined' || !firebase.database) {
25            console.error('Firebase not available for DM calls');
26            return false;
27        }
28
29        // Wait for username
30        const checkUsername = setInterval(() => {
31            if (window.__sharedUsername) {
32                currentUsername = window.__sharedUsername.get();
33                if (currentUsername) {
34                    clearInterval(checkUsername);
35                    setupPeerJS();
36                    setupFirebaseListeners();
37                }
38            }
39        }, 100);
40
41        setTimeout(() => {
42            clearInterval(checkUsername);
43            if (!currentUsername) {
44                currentUsername = 'guest_' + Date.now();
45                setupPeerJS();
46                setupFirebaseListeners();
47            }
48        }, 5000);
49
50        return true;
51    }
52
53    /**
54     * Setup PeerJS instance for DM calls
55     */
56    function setupPeerJS() {
57        if (typeof Peer === 'undefined') {
58            console.error('📞 DM Calls: PeerJS not loaded');
59            return;
60        }
61
62        // Always try to reuse voice chat's PeerJS instance (same peer ID = same username)
63        // This ensures both users can call each other using the same peer IDs
64        const tryGetPeer = setInterval(() => {
65            if (window.ChatroomVoiceChat && window.ChatroomVoiceChat.getPeer) {
66                const voiceChatPeer = window.ChatroomVoiceChat.getPeer();
67                if (voiceChatPeer) {
68                    peer = voiceChatPeer;
69                    clearInterval(tryGetPeer);
70                    console.log('📞 DM Calls: Reusing voice chat PeerJS instance');
71                    
72                    // If peer is already open, set up handler immediately
73                    if (peer.open) {
74                        setupCallHandler();
75                    } else {
76                        // Wait for peer to open
77                        peer.on('open', () => {
78                            console.log('📞 DM Calls: Voice chat PeerJS opened, setting up call handler');
79                            setupCallHandler();
80                        });
81                    }
82                    return;
83                }
84            }
85        }, 100);
86
87        // Don't create a fallback - we MUST use the voice chat peer to ensure peer IDs match
88        // If voice chat isn't initialized after 10 seconds, log a warning
89        setTimeout(() => {
90            if (!peer) {
91                clearInterval(tryGetPeer);
92                console.warn('📞 DM Calls: Voice chat PeerJS not available yet. DM calls will not work until voice chat is initialized.');
93                console.warn('📞 DM Calls: To enable DM calls, please join voice chat first, or wait for voice chat to initialize.');
94            }
95        }, 10000);
96    }
97
98    /**
99     * Setup call handler for incoming calls
100     */
101    function setupCallHandler() {
102        if (peer && !peer._dmCallHandlerSetup) {
103            peer.on('call', (call) => {
104                handleIncomingCall(call);
105            });
106            peer._dmCallHandlerSetup = true;
107        }
108    }
109
110    /**
111     * Setup Firebase listeners for call signaling
112     */
113    function setupFirebaseListeners() {
114        if (!currentUsername || !firebase || !firebase.database) return;
115
116        const callsRef = firebase.database().ref('chatroom/dmCalls');
117        
118        // Listen for incoming call requests
119        callsRef.on('child_added', (snapshot) => {
120            const callData = snapshot.val();
121            if (!callData) return;
122
123            // Only handle calls TO us
124            if (callData.to === currentUsername && callData.from !== currentUsername) {
125                handleIncomingCallRequest(callData);
126            }
127        });
128
129        // Listen for call cancellations/endings
130        callsRef.on('child_removed', (snapshot) => {
131            const callData = snapshot.val();
132            if (callData && callData.to === currentUsername && callData.from !== currentUsername) {
133                // Call was cancelled by the other person
134                if (isInCall && currentCall && currentCall.username === callData.from) {
135                    endCall();
136                }
137            }
138        });
139
140        // Listen for call state changes
141        callsRef.on('child_changed', (snapshot) => {
142            const callData = snapshot.val();
143            if (!callData) return;
144
145            // If call was accepted/rejected
146            if (callData.to === currentUsername && callData.from !== currentUsername) {
147                if (callData.status === 'rejected') {
148                    // Other person rejected our call
149                    if (currentCall && currentCall.username === callData.from) {
150                        endCall();
151                        showNotification('Call rejected', `${callData.from} rejected your call`);
152                    }
153                }
154            }
155        });
156    }
157
158    /**
159     * Handle incoming call request from Firebase
160     */
161    function handleIncomingCallRequest(callData) {
162        if (isInCall) {
163            // Already in a call, reject automatically
164            rejectCall(callData.from);
165            return;
166        }
167
168        // Show incoming call notification
169        const accept = confirm(`${callData.from} is calling you. Accept?`);
170        
171        if (accept) {
172            acceptCall(callData.from);
173        } else {
174            rejectCall(callData.from);
175        }
176    }
177
178    /**
179     * Handle incoming PeerJS call
180     */
181    async function handleIncomingCall(call) {
182        if (isInCall) {
183            console.log('📞 Already in a call, rejecting incoming call');
184            return;
185        }
186
187        // Get the caller's username from the call metadata or Firebase
188        const callerId = call.peer;
189        
190        // Check Firebase for call data
191        const callsRef = firebase.database().ref('chatroom/dmCalls');
192        callsRef.once('value', (snapshot) => {
193            const calls = snapshot.val();
194            if (calls) {
195                for (const [key, callData] of Object.entries(calls)) {
196                    if (callData.from === callerId && callData.to === currentUsername && callData.status === 'ringing') {
197                        // This is the call we're accepting
198                        answerCall(call, callerId);
199                        return;
200                    }
201                }
202            }
203        });
204    }
205
206    /**
207     * Answer an incoming call
208     */
209    async function answerCall(call, callerUsername) {
210        try {
211            // Get user's microphone
212            const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
213            
214            // Answer the call with audio stream
215            call.answer(stream);
216            
217            // Set up call handlers
218            call.on('stream', (remoteStream) => {
219                handleRemoteStream(remoteStream, callerUsername);
220            });
221
222            call.on('close', () => {
223                endCall();
224            });
225
226            call.on('error', (err) => {
227                console.error('📞 Call error:', err);
228                endCall();
229            });
230
231            // Update call state
232            currentCall = {
233                username: callerUsername,
234                connection: call,
235                localStream: stream,
236                isIncoming: true
237            };
238            isInCall = true;
239
240            // Update Firebase call status
241            const callsRef = firebase.database().ref('chatroom/dmCalls');
242            callsRef.once('value', (snapshot) => {
243                const calls = snapshot.val();
244                if (calls) {
245                    for (const [key, callData] of Object.entries(calls)) {
246                        if (callData.from === callerUsername && callData.to === currentUsername) {
247                            callsRef.child(key).update({ status: 'accepted' });
248                            break;
249                        }
250                    }
251                }
252            });
253
254            updateCallUI(callerUsername, true, 'connected');
255            if (window.ChatroomDMs && window.ChatroomDMs.getCurrentDM() === callerUsername) {
256                appendCallSystemMessage(callerUsername + ' started a call.');
257            }
258            showNotification('Call connected', 'Connected to ' + callerUsername);
259        } catch (error) {
260            console.error('📞 Error answering call:', error);
261            rejectCall(callerUsername);
262        }
263    }
264
265    /**
266     * Accept a call request
267     */
268    async function acceptCall(callerUsername) {
269        // Wait for PeerJS call event
270        // The actual connection will be handled in handleIncomingCall
271        const callsRef = firebase.database().ref('chatroom/dmCalls');
272        callsRef.once('value', (snapshot) => {
273            const calls = snapshot.val();
274            if (calls) {
275                for (const [key, callData] of Object.entries(calls)) {
276                    if (callData.from === callerUsername && callData.to === currentUsername) {
277                        callsRef.child(key).update({ status: 'accepted' });
278                        break;
279                    }
280                }
281            }
282        });
283    }
284
285    /**
286     * Reject a call
287     */
288    function rejectCall(callerUsername) {
289        const callsRef = firebase.database().ref('chatroom/dmCalls');
290        callsRef.once('value', (snapshot) => {
291            const calls = snapshot.val();
292            if (calls) {
293                for (const [key, callData] of Object.entries(calls)) {
294                    if (callData.from === callerUsername && callData.to === currentUsername) {
295                        callsRef.child(key).update({ status: 'rejected' });
296                        // Remove after a delay
297                        setTimeout(() => {
298                            callsRef.child(key).remove();
299                        }, 5000);
300                        break;
301                    }
302                }
303            }
304        });
305    }
306
307    /**
308     * Show confirmation dialog before starting a call
309     */
310    function startCallWithConfirmation(otherUsername) {
311        try {
312            const skipConfirm = localStorage.getItem('chatroom_confirm_dm_call') === 'false';
313            if (skipConfirm) {
314                startCall(otherUsername);
315                return;
316            }
317        } catch (e) { /* ignore */ }
318
319        if (!window.ChatroomConfirm) {
320            startCall(otherUsername);
321            return;
322        }
323
324        window.ChatroomConfirm.show({
325            title: 'Call ' + otherUsername + '?',
326            message: 'Start a voice call with this user?',
327            confirmText: 'Call',
328            cancelText: 'Cancel',
329            confirmVariant: 'primary',
330            dontShowKey: 'chatroom_confirm_dm_call'
331        }).then(function (confirmed) {
332            if (confirmed) startCall(otherUsername);
333        });
334    }
335
336    /**
337     * Start a call with another user
338     */
339    async function startCall(otherUsername) {
340        if (isInCall) {
341            showNotification('Already in call', 'Please end the current call first');
342            return;
343        }
344
345        // Wait for peer to be ready and open
346        const waitForPeer = new Promise((resolve, reject) => {
347            // First, try to get peer from voice chat if we don't have one
348            if (!peer) {
349                if (window.ChatroomVoiceChat && window.ChatroomVoiceChat.getPeer) {
350                    peer = window.ChatroomVoiceChat.getPeer();
351                }
352            }
353
354            // If still no peer, wait for it to be created
355            if (!peer) {
356                const checkPeer = setInterval(() => {
357                    if (window.ChatroomVoiceChat && window.ChatroomVoiceChat.getPeer) {
358                        peer = window.ChatroomVoiceChat.getPeer();
359                    }
360                    if (peer && peer.open) {
361                        clearInterval(checkPeer);
362                        resolve();
363                    }
364                }, 100);
365                
366                setTimeout(() => {
367                    clearInterval(checkPeer);
368                    if (!peer || !peer.open) {
369                        reject(new Error('PeerJS not available. Please join voice chat first to enable DM calls.'));
370                    }
371                }, 10000);
372                return;
373            }
374
375            // Peer exists, wait for it to be open
376            if (peer.open) {
377                resolve();
378            } else {
379                const openHandler = () => {
380                    if (peer) {
381                        peer.off('open', openHandler);
382                    }
383                    resolve();
384                };
385                peer.on('open', openHandler);
386                setTimeout(() => {
387                    if (peer) {
388                        peer.off('open', openHandler);
389                    }
390                    reject(new Error('PeerJS timeout - peer did not open within 10 seconds'));
391                }, 10000);
392            }
393        });
394
395        try {
396            await waitForPeer;
397
398            // Get user's microphone
399            const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
400
401            // Create call in Firebase
402            const callsRef = firebase.database().ref('chatroom/dmCalls');
403            const callKey = callsRef.push({
404                from: currentUsername,
405                to: otherUsername,
406                status: 'ringing',
407                timestamp: Date.now()
408            }).key;
409
410            // Call the other user using PeerJS
411            const call = peer.call(otherUsername, stream);
412
413            if (!call) {
414                throw new Error('Failed to create call');
415            }
416
417            // Set up call handlers
418            call.on('stream', (remoteStream) => {
419                handleRemoteStream(remoteStream, otherUsername);
420            });
421
422            call.on('close', () => {
423                endCall();
424            });
425
426            call.on('error', (err) => {
427                console.error('📞 Call error:', err);
428                endCall();
429            });
430
431            // Update call state
432            currentCall = {
433                username: otherUsername,
434                connection: call,
435                localStream: stream,
436                isIncoming: false,
437                callKey: callKey
438            };
439            isInCall = true;
440
441            updateCallUI(otherUsername, true, 'ringing');
442            if (window.ChatroomDMs && window.ChatroomDMs.getCurrentDM() === otherUsername) {
443                appendCallSystemMessage((currentUsername || 'Someone') + ' started a call.');
444            }
445
446            // Check if call was accepted (wait a bit for response)
447            setTimeout(() => {
448                callsRef.child(callKey).once('value', (snapshot) => {
449                    const callData = snapshot.val();
450                    if (callData && callData.status === 'rejected') {
451                        endCall();
452                        showNotification('Call rejected', `${otherUsername} rejected your call`);
453                    } else if (callData && callData.status === 'accepted') {
454                        setCallPhase('connected');
455                        updateCallUI(otherUsername, true, 'connected');
456                        showNotification('Call connected', 'Connected to ' + otherUsername);
457                    }
458                });
459            }, 1000);
460
461        } catch (error) {
462            console.error('📞 Error starting call:', error);
463            showNotification('Call failed', 'Could not start call. Please check your microphone permissions.');
464            endCall();
465        }
466    }
467
468    /**
469     * End the current call
470     */
471    function endCall() {
472        if (!isInCall) return;
473
474        // Close PeerJS connection
475        if (currentCall && currentCall.connection) {
476            currentCall.connection.close();
477        }
478
479        // Stop local stream
480        if (currentCall && currentCall.localStream) {
481            currentCall.localStream.getTracks().forEach(track => track.stop());
482        }
483
484        // Remove from Firebase
485        if (currentCall && currentCall.callKey) {
486            const callsRef = firebase.database().ref('chatroom/dmCalls');
487            callsRef.child(currentCall.callKey).remove();
488        } else if (currentCall) {
489            // Find and remove call from Firebase
490            const callsRef = firebase.database().ref('chatroom/dmCalls');
491            callsRef.once('value', (snapshot) => {
492                const calls = snapshot.val();
493                if (calls) {
494                    for (const [key, callData] of Object.entries(calls)) {
495                        if ((callData.from === currentCall.username && callData.to === currentUsername) ||
496                            (callData.to === currentCall.username && callData.from === currentUsername)) {
497                            callsRef.child(key).remove();
498                            break;
499                        }
500                    }
501                }
502            });
503        }
504
505        // Clear call state
506        const previousUsername = currentCall ? currentCall.username : null;
507        currentCall = null;
508        isInCall = false;
509
510        if (previousUsername && window.ChatroomDMs && window.ChatroomDMs.getCurrentDM() === previousUsername) {
511            appendCallSystemMessage('Call ended.');
512        }
513
514        updateCallUI(previousUsername, false, null);
515    }
516
517    /**
518     * Handle remote audio stream
519     */
520    function handleRemoteStream(remoteStream, username) {
521        const audio = new Audio();
522        audio.srcObject = remoteStream;
523        audio.autoplay = true;
524        audio.play().catch(function (err) {
525            console.error('📞 Error playing remote audio:', err);
526        });
527
528        if (currentCall) {
529            currentCall.remoteAudio = audio;
530        }
531
532        if (callPhase !== 'connected') {
533            setCallPhase('connected');
534            updateCallUI(username, true, 'connected');
535        }
536    }
537
538    function escapeHtml(text) {
539        const div = document.createElement('div');
540        div.textContent = text;
541        return div.innerHTML;
542    }
543
544    function getAvatarEmoji(username) {
545        const avatars = ['🐾', '🐶', '🐕', '🦴', '🎾', '🧸', '⭐', '💫', '✨', '🌟'];
546        const hash = (username || '').split('').reduce(function (acc, char) {
547            return acc + char.charCodeAt(0);
548        }, 0);
549        return avatars[hash % avatars.length];
550    }
551
552    function fillAvatarElement(container, username) {
553        if (!container || !username) return;
554        const pic = window.ChatroomProfilePicture && window.ChatroomProfilePicture.getProfilePicture(username);
555        if (pic) {
556            container.innerHTML = '<img src="' + escapeHtml(pic) + '" alt="' + escapeHtml(username) + '" class="profile-picture-avatar">';
557        } else {
558            container.textContent = getAvatarEmoji(username);
559        }
560    }
561
562    function formatCallDuration(ms) {
563        const totalSec = Math.max(0, Math.floor(ms / 1000));
564        const h = Math.floor(totalSec / 3600);
565        const m = Math.floor((totalSec % 3600) / 60);
566        const s = totalSec % 60;
567        if (h > 0) {
568            return h + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
569        }
570        return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
571    }
572
573    function stopCallTimer() {
574        if (callTimerInterval) {
575            clearInterval(callTimerInterval);
576            callTimerInterval = null;
577        }
578        callStartedAt = null;
579    }
580
581    function startCallTimer() {
582        stopCallTimer();
583        callStartedAt = Date.now();
584        const tick = function () {
585            const elapsed = Date.now() - callStartedAt;
586            const text = formatCallDuration(elapsed);
587            const sidebarTimer = document.getElementById('dm-call-sidebar-timer');
588            const stageStatus = document.getElementById('dm-call-stage-status');
589            if (sidebarTimer) sidebarTimer.textContent = text;
590            if (stageStatus && callPhase === 'connected') {
591                stageStatus.textContent = text;
592            }
593        };
594        tick();
595        callTimerInterval = setInterval(tick, 1000);
596    }
597
598    function setCallPhase(phase) {
599        callPhase = phase;
600        const stageStatus = document.getElementById('dm-call-stage-status');
601        if (stageStatus) {
602            if (phase === 'ringing') {
603                stageStatus.textContent = 'Ringing…';
604            } else if (phase === 'connected') {
605                startCallTimer();
606            }
607        }
608    }
609
610    function bindDmCallControlsOnce() {
611        if (dmCallControlsBound) return;
612        dmCallControlsBound = true;
613
614        function wireEnd(btn) {
615            if (!btn) return;
616            btn.addEventListener('click', function (e) {
617                e.preventDefault();
618                e.stopPropagation();
619                endCall();
620            });
621        }
622
623        function wireMute(btn) {
624            if (!btn) return;
625            btn.addEventListener('click', function (e) {
626                e.preventDefault();
627                e.stopPropagation();
628                toggleDmCallMute();
629            });
630        }
631
632        wireEnd(document.getElementById('dm-call-stage-end-btn'));
633        wireEnd(document.getElementById('dm-call-disconnect-btn'));
634        wireMute(document.getElementById('dm-call-stage-mute-btn'));
635        wireMute(document.getElementById('dm-call-mute-btn'));
636
637        if (window.ChatroomIcons) {
638            window.ChatroomIcons.set(document.getElementById('dm-call-disconnect-btn'), 'phoneHangup');
639            window.ChatroomIcons.set(document.getElementById('dm-call-stage-end-btn'), 'phoneHangup');
640            window.ChatroomIcons.setVoiceMute(document.getElementById('dm-call-mute-btn'), false);
641            window.ChatroomIcons.setVoiceMute(document.getElementById('dm-call-stage-mute-btn'), false);
642        }
643    }
644
645    function toggleDmCallMute() {
646        isDmCallMuted = !isDmCallMuted;
647        if (currentCall && currentCall.localStream) {
648            currentCall.localStream.getAudioTracks().forEach(function (track) {
649                track.enabled = !isDmCallMuted;
650            });
651        }
652        ['dm-call-mute-btn', 'dm-call-stage-mute-btn'].forEach(function (id) {
653            const btn = document.getElementById(id);
654            if (!btn) return;
655            btn.classList.toggle('muted', isDmCallMuted);
656            if (window.ChatroomIcons) {
657                window.ChatroomIcons.setVoiceMute(btn, isDmCallMuted);
658            }
659            btn.title = isDmCallMuted ? 'Unmute' : 'Mute';
660        });
661    }
662
663    function appendCallSystemMessage(text) {
664        const messagesList = document.getElementById('messages-list');
665        if (!messagesList) return;
666
667        const msg = document.createElement('div');
668        msg.className = 'message dm-call-system-message';
669        const time = new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
670        const icon = window.ChatroomIcons ? window.ChatroomIcons.icon('phone') : '📞';
671        msg.innerHTML =
672            '<div class="dm-call-system-inner">' +
673            icon +
674            '<span>' + escapeHtml(text) + '<span class="dm-call-system-time">' + escapeHtml(time) + '</span></span>' +
675            '</div>';
676        messagesList.appendChild(msg);
677        messagesList.scrollTop = messagesList.scrollHeight;
678    }
679
680    function dispatchCallStateChange(username, inCall) {
681        try {
682            window.dispatchEvent(new CustomEvent('chatroomDmCallChanged', {
683                detail: { inCall: inCall, username: inCall ? username : null, phase: callPhase }
684            }));
685        } catch (e) { /* ignore */ }
686    }
687
688    function refreshDmCallUi() {
689        if (isInCall && currentCall && currentCall.username) {
690            updateCallUI(currentCall.username, true, callPhase);
691        } else {
692            updateCallUI(null, false, null);
693        }
694    }
695
696    /**
697     * Update call UI — header, stage, sidebar, DM list (Discord-style)
698     */
699    function updateCallUI(username, inCall, phase) {
700        bindDmCallControlsOnce();
701
702        if (inCall && phase) {
703            callPhase = phase;
704        } else if (!inCall) {
705            callPhase = null;
706            stopCallTimer();
707            isDmCallMuted = false;
708        }
709
710        const currentDM = window.ChatroomDMs && window.ChatroomDMs.getCurrentDM
711            ? window.ChatroomDMs.getCurrentDM()
712            : null;
713        const viewingPartner = inCall && username && currentDM === username;
714
715        const callBtn = document.getElementById('dm-call-btn');
716        const callStatus = document.getElementById('dm-call-status');
717        const stage = document.getElementById('dm-call-stage');
718        const dmCallCategory = document.getElementById('dm-call-category');
719        const chatArea = document.querySelector('.chat-area');
720        const channelIcon = document.getElementById('channel-icon-header');
721        const channelTopic = document.getElementById('channel-topic');
722
723        if (callBtn) {
724            if (inCall) {
725                if (window.ChatroomIcons) window.ChatroomIcons.set(callBtn, 'phoneSlash');
726                callBtn.title = 'End call';
727                callBtn.classList.add('active');
728            } else {
729                if (window.ChatroomIcons) window.ChatroomIcons.set(callBtn, 'phone');
730                callBtn.title = 'Call';
731                callBtn.classList.remove('active');
732            }
733        }
734
735        if (callStatus) {
736            if (inCall && username) {
737                callStatus.textContent = callPhase === 'connected' ? 'In voice' : 'Calling…';
738                callStatus.style.display = 'block';
739                callStatus.style.color = 'var(--online)';
740            } else {
741                callStatus.style.display = 'none';
742            }
743        }
744
745        if (dmCallCategory) {
746            dmCallCategory.style.display = inCall ? 'block' : 'none';
747        }
748
749        const sidebarName = document.getElementById('dm-call-sidebar-name');
750        if (sidebarName && inCall && username) {
751            sidebarName.textContent = username;
752        }
753
754        if (stage) {
755            if (viewingPartner) {
756                stage.hidden = false;
757                fillAvatarElement(document.getElementById('dm-call-avatar-self'), currentUsername);
758                fillAvatarElement(document.getElementById('dm-call-avatar-other'), username);
759                const labelSelf = document.getElementById('dm-call-label-self');
760                const labelOther = document.getElementById('dm-call-label-other');
761                if (labelSelf) labelSelf.textContent = currentUsername ? currentUsername + ' (you)' : 'You';
762                if (labelOther) labelOther.textContent = username;
763                const stageStatus = document.getElementById('dm-call-stage-status');
764                if (stageStatus) {
765                    if (callPhase === 'ringing') {
766                        stageStatus.textContent = 'Ringing…';
767                    } else if (callPhase === 'connected' && callStartedAt) {
768                        stageStatus.textContent = formatCallDuration(Date.now() - callStartedAt);
769                    } else if (callPhase === 'connected') {
770                        stageStatus.textContent = 'Connected';
771                    }
772                }
773            } else {
774                stage.hidden = true;
775            }
776        }
777
778        if (chatArea) {
779            chatArea.classList.toggle('dm-call-active', !!viewingPartner);
780        }
781
782        if (currentDM === username || !inCall) {
783            if (channelIcon && inCall && currentDM === username) {
784                channelIcon.innerHTML = window.ChatroomIcons
785                    ? window.ChatroomIcons.icon('phone')
786                    : '📞';
787            } else if (channelIcon && currentDM) {
788                channelIcon.textContent = '💌';
789            }
790            if (channelTopic && currentDM === username) {
791                if (inCall) {
792                    channelTopic.textContent = callPhase === 'connected'
793                        ? 'In a call · ' + username
794                        : 'Calling ' + username + '…';
795                    channelTopic.style.color = 'var(--online)';
796                } else {
797                    channelTopic.textContent = 'Direct message';
798                    channelTopic.style.color = '';
799                }
800            }
801        }
802
803        dispatchCallStateChange(username, inCall);
804    }
805
806    /**
807     * Show notification
808     */
809    function showNotification(title, message) {
810        if (window.ChatroomNotifications && window.ChatroomNotifications.showNotification) {
811            window.ChatroomNotifications.showNotification(title, message);
812        } else {
813            console.log(`📞 ${title}: ${message}`);
814        }
815    }
816
817    /**
818     * Check if currently in a call
819     */
820    function getIsInCall() {
821        return isInCall;
822    }
823
824    /**
825     * Get current call info
826     */
827    function getCurrentCall() {
828        return currentCall ? { ...currentCall } : null;
829    }
830
831    // Initialize on load
832    if (document.readyState === 'loading') {
833        document.addEventListener('DOMContentLoaded', () => {
834            console.log('📞 DM Calls: DOM loaded, initializing...');
835            init();
836        });
837    } else {
838        console.log('📞 DM Calls: DOM already loaded, initializing...');
839        init();
840    }
841
842    // Public API
843    return {
844        init: init,
845        startCall: startCall,
846        startCallWithConfirmation: startCallWithConfirmation,
847        endCall: endCall,
848        getIsInCall: getIsInCall,
849        getCurrentCall: getCurrentCall,
850        refreshUi: refreshDmCallUi
851    };
852})();
853

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.