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})();