Whimsical Video Call

Add video call functionality to Whimsical boards for real-time collaboration

Size

21.0 KB

Version

1.1.10

Created

Nov 7, 2025

Updated

about 1 month ago

1// ==UserScript==
2// @name		Whimsical Video Call
3// @description		Add video call functionality to Whimsical boards for real-time collaboration
4// @version		1.1.10
5// @match		https://*.whimsical.com/*
6// @icon		data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgdmlld0JveD0iMCAwIDE2IDE2IiBoZWlnaHQ9IjE2Ij48Zz48ZGVmcz48Y2xpcHBhdGggaWQ9ImNsaXAwXzk4M18xMTgiPjxyZWN0IHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0id2hpdGUiLz48L2NsaXBwYXRoPjwvZGVmcz48ZyBjbGlwLXBhdGg9InVybCgjY2xpcDBfOTgzXzExOCkiPjxwYXRoIGQ9Ik0xMiAwQzE0LjIwOTEgMS4yODg1M2UtMDcgMTYgMS43OTA4NiAxNiA0VjEwQzE2IDEyLjIwOTEgMTQuMjA5MSAxNCAxMiAxNEgxMS42MTgyTDExLjg5NDUgMTQuNTUyN0MxMi4xNDE1IDE1LjA0NjcgMTEuOTQxMiAxNS42NDc1IDExLjQ0NzMgMTUuODk0NUMxMC45NTMzIDE2LjE0MTUgMTAuMzUyNSAxNS45NDEyIDEwLjEwNTUgMTUuNDQ3M0w5LjM4MTg0IDE0SDYuNjE4MTZMNS44OTQ1MyAxNS40NDczQzUuNjQ3NTQgMTUuOTQxMiA1LjA0NjcxIDE2LjE0MTUgNC41NTI3MyAxNS44OTQ1QzQuMDU4NzYgMTUuNjQ3NSAzLjg1ODQ4IDE1LjA0NjcgNC4xMDU0NyAxNC41NTI3TDQuMzgxODQgMTRINEMxLjc5MDg2IDE0IDAgMTIuMjA5MSAwIDEwVjRDMCAxLjc5MDg2IDEuNzkwODYgMS42MTA2NGUtMDggNCAwSDEyWk00IDJDMi44OTU0MyAyIDIgMi44OTU0MyAyIDRWMTBDMiAxMS4xMDQ2IDIuODk1NDMgMTIgNCAxMkgxMkMxMy4xMDQ2IDEyIDE0IDExLjEwNDYgMTQgMTBWNEMxNCAyLjg5NTQzIDEzLjEwNDYgMiAxMiAySDRaTTUuNSA4QzUuNzc2MTQgOCA2IDguMjIzODYgNiA4LjVWOS41QzYgOS43NzYxNCA1Ljc3NjE0IDEwIDUuNSAxMEg0LjVDNC4yMjM4NiAxMCA0IDkuNzc2MTQgNCA5LjVWOC41QzQgOC4yMjM4NiA0LjIyMzg2IDggNC41IDhINS41Wk01LjUgNEM1Ljc3NjE0IDQgNiA0LjIyMzg2IDYgNC41VjUuNUM2IDUuNzc2MTQgNS43NzYxNCA2IDUuNSA2SDQuNUM0LjIyMzg2IDYgNCA1Ljc3NjE0IDQgNS41VjQuNUM0IDQuMjIzODYgNC4yMjM4NiA0IDQuNSA0SDUuNVpNOS41IDRDOS43NzYxNCA0IDEwIDQuMjIzODYgMTAgNC41VjUuNUMxMCA1Ljc3NjE0IDkuNzc2MTQgNiA5LjUgNkg4LjVDOC4yMjM4NiA2IDggNS43NzYxNCA4IDUuNVY0LjVDOCA0LjIyMzg2IDguMjIzODYgNCA4LjUgNEg5LjVaIiBmaWxsPSIjNzMwRkMzIi8+PC9nPjwvZz48L3N2Zz4=
7// @require		https://cdn.jsdelivr.net/npm/peerjs@1.5.2/dist/peerjs.min.js
8// ==/UserScript==
9(function() {
10    'use strict';
11
12    console.log('Whimsical Video Call extension loaded');
13
14    let localStream = null;
15    let peer = null;
16    let currentCall = null;
17    let roomId = null;
18    let isCallActive = false;
19    let myPeerId = null;
20    let isFirstPeer = false;
21
22    // Debounce function for MutationObserver
23    function debounce(func, wait) {
24        let timeout;
25        return function executedFunction(...args) {
26            const later = () => {
27                clearTimeout(timeout);
28                func(...args);
29            };
30            clearTimeout(timeout);
31            timeout = setTimeout(later, wait);
32        };
33    }
34
35    // Initialize the extension
36    async function init() {
37        console.log('Initializing Whimsical Video Call');
38        
39        // Wait for the page to be fully loaded
40        if (document.readyState === 'loading') {
41            document.addEventListener('DOMContentLoaded', addVideoCallButton);
42        } else {
43            addVideoCallButton();
44        }
45
46        // Watch for dynamic changes
47        const observer = new MutationObserver(debounce(() => {
48            if (!document.querySelector('#whimsical-video-call-btn')) {
49                addVideoCallButton();
50            }
51        }, 500));
52
53        observer.observe(document.body, {
54            childList: true,
55            subtree: true
56        });
57    }
58
59    // Add video call button
60    function addVideoCallButton() {
61        if (document.querySelector('#whimsical-video-call-btn')) {
62            console.log('Video call button already exists');
63            return;
64        }
65
66        console.log('Adding video call button to bottom-left corner');
67
68        const videoCallBtn = document.createElement('button');
69        videoCallBtn.id = 'whimsical-video-call-btn';
70        videoCallBtn.innerHTML = `
71            <svg width="20" height="20" viewBox="0 0 16 16" fill="white">
72                <path d="M12 0C14.2091 0 16 1.79086 16 4V10C16 12.2091 14.2091 14 12 14H11.6182L11.8945 14.5527C12.1415 15.0467 11.9412 15.6475 11.4473 15.8945C10.9533 16.1415 10.3525 15.9412 10.1055 15.4473L9.38184 14H6.61816L5.89453 15.4473C5.64754 15.9412 5.04671 16.1415 4.55273 15.8945C4.05876 15.6475 3.85848 15.0467 4.10547 14.5527L4.38184 14H4C1.79086 14 0 12.2091 0 10V4C0 1.79086 1.79086 0 4 0H12ZM4 2C2.89543 2 2 2.89543 2 4V10C2 11.1046 2.89543 12 4 12H12C13.1046 12 14 11.1046 14 10V4C14 2.89543 13.1046 2 12 2H4ZM5.5 8C5.77614 8 6 8.22386 6 8.5V9.5C6 9.77614 5.77614 10 5.5 10H4.5C4.22386 10 4 9.77614 4 9.5V8.5C4 8.22386 4.22386 8 4.5 8H5.5ZM5.5 4C5.77614 4 6 4.22386 6 4.5V5.5C6 5.77614 5.77614 6 5.5 6H4.5C4.22386 6 4 5.77614 4 5.5V4.5C4 4.22386 4.22386 4 4.5 4H5.5ZM9.5 4C9.77614 4 10 4.22386 10 4.5V5.5C10 5.77614 9.77614 6 9.5 6H8.5C8.22386 6 8 5.77614 8 5.5V4.5C8 4.22386 8.22386 4 8.5 4H9.5Z"/>
73            </svg>
74            <span id="video-call-btn-text">Video Call</span>
75        `;
76        videoCallBtn.style.cssText = `
77            position: fixed;
78            bottom: 20px;
79            left: 20px;
80            z-index: 999998;
81            background: #730FC3;
82            color: white;
83            border: none;
84            border-radius: 50px;
85            padding: 14px 24px;
86            font-size: 15px;
87            font-weight: 600;
88            cursor: pointer;
89            display: inline-flex;
90            align-items: center;
91            justify-content: center;
92            gap: 8px;
93            box-shadow: 0 4px 12px rgba(115, 15, 195, 0.4);
94            transition: all 0.2s ease;
95            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
96        `;
97
98        videoCallBtn.addEventListener('click', toggleVideoCall);
99        
100        videoCallBtn.addEventListener('mouseenter', () => {
101            if (!isCallActive) {
102                videoCallBtn.style.transform = 'scale(1.05)';
103                videoCallBtn.style.boxShadow = '0 6px 16px rgba(115, 15, 195, 0.5)';
104            }
105        });
106        
107        videoCallBtn.addEventListener('mouseleave', () => {
108            videoCallBtn.style.transform = 'scale(1)';
109            videoCallBtn.style.boxShadow = '0 4px 12px rgba(115, 15, 195, 0.4)';
110        });
111
112        document.body.appendChild(videoCallBtn);
113        console.log('Video call button added successfully');
114    }
115
116    // Toggle video call on/off
117    async function toggleVideoCall() {
118        if (isCallActive) {
119            endCall();
120        } else {
121            await startCall();
122        }
123    }
124
125    // Start video call
126    async function startCall() {
127        try {
128            console.log('Starting video call...');
129            
130            // Get room ID from URL
131            const urlParts = window.location.pathname.split('-');
132            roomId = 'whimsical-' + urlParts[urlParts.length - 1];
133            console.log('Room ID:', roomId);
134
135            // Request camera and microphone access
136            localStream = await navigator.mediaDevices.getUserMedia({
137                video: true,
138                audio: true
139            });
140
141            console.log('Got local stream');
142
143            // Create video call UI
144            createVideoCallUI();
145
146            // Display local video
147            const localVideo = document.getElementById('local-video');
148            if (localVideo) {
149                localVideo.srcObject = localStream;
150            }
151
152            // Initialize PeerJS
153            initializePeer();
154
155            // Update button state
156            isCallActive = true;
157            updateButtonState();
158
159            console.log('Video call started successfully');
160
161        } catch (error) {
162            console.error('Error starting video call:', error);
163            alert('Could not access camera/microphone. Please check permissions.');
164        }
165    }
166
167    // Initialize PeerJS
168    function initializePeer() {
169        console.log('Initializing PeerJS...');
170        
171        // Try to be the first peer with the room ID
172        peer = new Peer(roomId, {
173            debug: 2
174        });
175
176        peer.on('open', (id) => {
177            myPeerId = id;
178            isFirstPeer = true;
179            console.log('I am the FIRST peer in the room');
180            console.log('My peer ID:', myPeerId);
181            console.log('Waiting for others to join...');
182        });
183
184        peer.on('call', (call) => {
185            console.log('Receiving call from:', call.peer);
186            
187            // Answer the call with our stream
188            call.answer(localStream);
189            
190            call.on('stream', (remoteStream) => {
191                console.log('Received remote stream');
192                displayRemoteStream(remoteStream);
193            });
194
195            call.on('close', () => {
196                console.log('Call closed');
197            });
198
199            currentCall = call;
200        });
201
202        peer.on('error', (err) => {
203            console.error('PeerJS error:', err);
204            
205            // If peer ID is taken, it means someone else is already in the room
206            if (err.type === 'unavailable-id') {
207                console.log('Room already occupied, I am the SECOND peer');
208                isFirstPeer = false;
209                
210                // Destroy the failed peer
211                if (peer) {
212                    peer.destroy();
213                }
214                
215                // Create a new peer with a unique ID
216                const myUniquePeerId = roomId + '-peer2-' + Math.random().toString(36).substr(2, 9);
217                peer = new Peer(myUniquePeerId, {
218                    debug: 2
219                });
220                
221                peer.on('open', (id) => {
222                    myPeerId = id;
223                    console.log('My peer ID:', myPeerId);
224                    console.log('Calling the first peer:', roomId);
225                    
226                    // Call the first peer
227                    setTimeout(() => {
228                        callPeer(roomId);
229                    }, 1000);
230                });
231                
232                peer.on('call', (call) => {
233                    console.log('Receiving call from:', call.peer);
234                    
235                    // Answer the call with our stream
236                    call.answer(localStream);
237                    
238                    call.on('stream', (remoteStream) => {
239                        console.log('Received remote stream');
240                        displayRemoteStream(remoteStream);
241                    });
242
243                    call.on('close', () => {
244                        console.log('Call closed');
245                    });
246
247                    currentCall = call;
248                });
249                
250                peer.on('error', (err2) => {
251                    console.error('Second peer error:', err2);
252                });
253            }
254        });
255
256        peer.on('disconnected', () => {
257            console.log('Peer disconnected');
258        });
259    }
260
261    // Call a specific peer
262    function callPeer(peerId) {
263        console.log('Calling peer:', peerId);
264        
265        const call = peer.call(peerId, localStream);
266        
267        if (!call) {
268            console.error('Failed to create call');
269            return;
270        }
271        
272        call.on('stream', (remoteStream) => {
273            console.log('Received remote stream from call');
274            displayRemoteStream(remoteStream);
275        });
276
277        call.on('close', () => {
278            console.log('Call closed');
279        });
280
281        call.on('error', (err) => {
282            console.error('Call error:', err);
283        });
284
285        currentCall = call;
286    }
287
288    // Display remote stream
289    function displayRemoteStream(stream) {
290        const remoteVideo = document.getElementById('remote-video');
291        const waitingMessage = document.getElementById('waiting-message');
292        
293        if (remoteVideo) {
294            remoteVideo.srcObject = stream;
295            if (waitingMessage) {
296                waitingMessage.style.display = 'none';
297            }
298        }
299    }
300
301    // End video call
302    function endCall() {
303        console.log('Ending video call...');
304
305        // Stop local stream
306        if (localStream) {
307            localStream.getTracks().forEach(track => track.stop());
308            localStream = null;
309        }
310
311        // Close current call
312        if (currentCall) {
313            currentCall.close();
314            currentCall = null;
315        }
316
317        // Destroy peer
318        if (peer) {
319            peer.destroy();
320            peer = null;
321        }
322
323        // Remove video call UI
324        const videoCallContainer = document.getElementById('whimsical-video-call-container');
325        if (videoCallContainer) {
326            videoCallContainer.remove();
327        }
328
329        // Update button state
330        isCallActive = false;
331        updateButtonState();
332
333        console.log('Video call ended');
334    }
335
336    // Update button state
337    function updateButtonState() {
338        const btn = document.getElementById('whimsical-video-call-btn');
339        const btnText = document.getElementById('video-call-btn-text');
340        
341        if (btn && btnText) {
342            if (isCallActive) {
343                btnText.textContent = 'End Call';
344                btn.style.backgroundColor = '#dc3545';
345                btn.style.color = 'white';
346            } else {
347                btnText.textContent = 'Video Call';
348                btn.style.backgroundColor = '';
349                btn.style.color = '';
350            }
351        }
352    }
353
354    // Create video call UI
355    function createVideoCallUI() {
356        if (document.getElementById('whimsical-video-call-container')) {
357            return;
358        }
359
360        const container = document.createElement('div');
361        container.id = 'whimsical-video-call-container';
362        container.innerHTML = `
363            <div id="video-call-header">
364                <span>Video Call</span>
365                <button id="minimize-video-call" title="Minimize"></button>
366                <button id="maximize-video-call" title="Maximize"></button>
367                <button id="close-video-call" title="End Call">×</button>
368            </div>
369            <div id="video-call-content">
370                <div id="remote-video-container">
371                    <video id="remote-video" autoplay playsinline></video>
372                    <div id="waiting-message">Waiting for others to join...</div>
373                </div>
374                <div id="local-video-container">
375                    <video id="local-video" autoplay playsinline muted></video>
376                </div>
377                <div id="video-call-controls">
378                    <button id="toggle-mic" class="control-btn" title="Mute/Unmute">
379                        <svg width="20" height="20" viewBox="0 0 24 24" fill="white">
380                            <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
381                            <path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
382                        </svg>
383                    </button>
384                    <button id="toggle-camera" class="control-btn" title="Turn Camera On/Off">
385                        <svg width="20" height="20" viewBox="0 0 24 24" fill="white">
386                            <path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/>
387                        </svg>
388                    </button>
389                </div>
390            </div>
391        `;
392
393        const style = document.createElement('style');
394        style.textContent = `
395            #whimsical-video-call-container {
396                position: fixed;
397                bottom: 20px;
398                right: 20px;
399                width: 320px;
400                background: #1a1a1a;
401                border-radius: 12px;
402                box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
403                z-index: 999999;
404                overflow: hidden;
405                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
406                transition: all 0.3s ease;
407            }
408
409            #whimsical-video-call-container.minimized #video-call-content {
410                display: none;
411            }
412
413            #whimsical-video-call-container.maximized {
414                width: 90vw;
415                height: 90vh;
416                bottom: 5vh;
417                right: 5vw;
418                border-radius: 16px;
419            }
420
421            #whimsical-video-call-container.maximized #remote-video-container {
422                height: calc(90vh - 120px);
423            }
424
425            #whimsical-video-call-container.maximized #local-video-container {
426                width: 200px;
427                height: 150px;
428                bottom: 90px;
429                right: 24px;
430            }
431
432            #video-call-header {
433                background: #2d2d2d;
434                color: white;
435                padding: 10px 12px;
436                display: flex;
437                align-items: center;
438                justify-content: space-between;
439                font-weight: 600;
440                font-size: 13px;
441            }
442
443            #video-call-header button {
444                background: none;
445                border: none;
446                color: white;
447                font-size: 18px;
448                cursor: pointer;
449                padding: 0 6px;
450                opacity: 0.8;
451                transition: opacity 0.2s;
452            }
453
454            #video-call-header button:hover {
455                opacity: 1;
456            }
457
458            #video-call-content {
459                position: relative;
460                background: #000;
461            }
462
463            #remote-video-container {
464                position: relative;
465                width: 100%;
466                height: 240px;
467                background: #000;
468                display: flex;
469                align-items: center;
470                justify-content: center;
471                transition: height 0.3s ease;
472            }
473
474            #remote-video {
475                width: 100%;
476                height: 100%;
477                object-fit: cover;
478            }
479
480            #waiting-message {
481                position: absolute;
482                color: white;
483                font-size: 13px;
484                text-align: center;
485                padding: 20px;
486            }
487
488            #local-video-container {
489                position: absolute;
490                bottom: 60px;
491                right: 12px;
492                width: 100px;
493                height: 75px;
494                border-radius: 8px;
495                overflow: hidden;
496                border: 2px solid white;
497                box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
498                transition: all 0.3s ease;
499            }
500
501            #local-video {
502                width: 100%;
503                height: 100%;
504                object-fit: cover;
505                transform: scaleX(-1);
506            }
507
508            #video-call-controls {
509                position: absolute;
510                bottom: 12px;
511                left: 50%;
512                transform: translateX(-50%);
513                display: flex;
514                gap: 10px;
515            }
516
517            .control-btn {
518                width: 42px;
519                height: 42px;
520                border-radius: 50%;
521                background: rgba(255, 255, 255, 0.2);
522                border: none;
523                cursor: pointer;
524                display: flex;
525                align-items: center;
526                justify-content: center;
527                transition: background 0.2s;
528            }
529
530            .control-btn:hover {
531                background: rgba(255, 255, 255, 0.3);
532            }
533
534            .control-btn.muted {
535                background: #dc3545;
536            }
537
538            .control-btn svg {
539                width: 18px;
540                height: 18px;
541            }
542        `;
543
544        document.head.appendChild(style);
545        document.body.appendChild(container);
546
547        // Add event listeners
548        document.getElementById('minimize-video-call').addEventListener('click', () => {
549            container.classList.toggle('minimized');
550        });
551
552        document.getElementById('maximize-video-call').addEventListener('click', () => {
553            container.classList.toggle('maximized');
554            const btn = document.getElementById('maximize-video-call');
555            if (container.classList.contains('maximized')) {
556                btn.textContent = '▢';
557                btn.title = 'Restore';
558            } else {
559                btn.textContent = '□';
560                btn.title = 'Maximize';
561            }
562        });
563
564        document.getElementById('close-video-call').addEventListener('click', endCall);
565
566        document.getElementById('toggle-mic').addEventListener('click', () => {
567            if (localStream) {
568                const audioTrack = localStream.getAudioTracks()[0];
569                if (audioTrack) {
570                    audioTrack.enabled = !audioTrack.enabled;
571                    document.getElementById('toggle-mic').classList.toggle('muted', !audioTrack.enabled);
572                }
573            }
574        });
575
576        document.getElementById('toggle-camera').addEventListener('click', () => {
577            if (localStream) {
578                const videoTrack = localStream.getVideoTracks()[0];
579                if (videoTrack) {
580                    videoTrack.enabled = !videoTrack.enabled;
581                    document.getElementById('toggle-camera').classList.toggle('muted', !videoTrack.enabled);
582                }
583            }
584        });
585    }
586
587    // Start the extension
588    init();
589
590})();
Whimsical Video Call | Robomonkey