From 24d88f4754f88dd60477b86b22ca2bccafab1cfc Mon Sep 17 00:00:00 2001 From: Spencer Date: Sat, 23 May 2026 22:22:19 -0400 Subject: [PATCH] Initial commit: SyncWatch Chrome extension + WebSocket server --- extension/background.js | 127 +++++++++++++++++++++++++++++++ extension/content.js | 66 ++++++++++++++++ extension/manifest.json | 26 +++++++ extension/popup.css | 164 ++++++++++++++++++++++++++++++++++++++++ extension/popup.html | 44 +++++++++++ extension/popup.js | 109 ++++++++++++++++++++++++++ server/package.json | 15 ++++ server/server.js | 98 ++++++++++++++++++++++++ 8 files changed, 649 insertions(+) create mode 100644 extension/background.js create mode 100644 extension/content.js create mode 100644 extension/manifest.json create mode 100644 extension/popup.css create mode 100644 extension/popup.html create mode 100644 extension/popup.js create mode 100644 server/package.json create mode 100644 server/server.js diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..f418333 --- /dev/null +++ b/extension/background.js @@ -0,0 +1,127 @@ +let ws = null; +let roomCode = null; +let serverUrl = null; +let connectState = 'disconnected'; // 'disconnected' | 'connecting' | 'connected' + +function send(msg) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(msg)); + } +} + +function notifyPopup(msg) { + chrome.runtime.sendMessage(msg).catch(() => {}); +} + +function notifyAllTabs(msg) { + chrome.tabs.query({}, (tabs) => { + for (const tab of tabs) { + chrome.tabs.sendMessage(tab.id, msg).catch(() => {}); + } + }); +} + +function connect(url) { + if (ws) { + ws.close(); + ws = null; + } + serverUrl = url; + connectState = 'connecting'; + + try { + ws = new WebSocket(url); + } catch { + connectState = 'disconnected'; + notifyPopup({ type: 'ws_status', status: 'error' }); + return; + } + + ws.onopen = () => { + connectState = 'connected'; + notifyPopup({ type: 'ws_status', status: 'connected' }); + }; + + ws.onclose = () => { + connectState = 'disconnected'; + roomCode = null; + notifyPopup({ type: 'ws_status', status: 'disconnected' }); + }; + + ws.onerror = () => { + connectState = 'disconnected'; + notifyPopup({ type: 'ws_status', status: 'error' }); + }; + + ws.onmessage = (event) => { + let msg; + try { msg = JSON.parse(event.data); } catch { return; } + + switch (msg.type) { + case 'created': + case 'joined': + roomCode = msg.code; + notifyPopup(msg); + break; + case 'error': + case 'peer_joined': + case 'peer_left': + notifyPopup(msg); + break; + case 'sync': + notifyAllTabs({ type: 'apply_sync', action: msg.action, time: msg.time }); + break; + } + }; +} + +// Keep service worker alive and reconnect if needed +chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); + +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name !== 'keepalive') return; + if (ws && ws.readyState === WebSocket.OPEN) { + send({ type: 'ping' }); + } else if (serverUrl && connectState !== 'connecting') { + connect(serverUrl); + } +}); + +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + switch (msg.type) { + case 'connect': + chrome.storage.local.set({ serverUrl: msg.url }); + connect(msg.url); + sendResponse({ ok: true }); + break; + case 'disconnect': + serverUrl = null; + roomCode = null; + if (ws) ws.close(); + sendResponse({ ok: true }); + break; + case 'create_room': + send({ type: 'create' }); + sendResponse({ ok: true }); + break; + case 'join_room': + send({ type: 'join', code: msg.code }); + sendResponse({ ok: true }); + break; + case 'media_event': + if (roomCode) { + send({ type: 'sync', action: msg.action, time: msg.time }); + } + sendResponse({ ok: true }); + break; + case 'get_state': + sendResponse({ connectState, roomCode }); + break; + } + return true; +}); + +// Restore WS connection on service worker restart +chrome.storage.local.get(['serverUrl'], ({ serverUrl: saved }) => { + if (saved) connect(saved); +}); diff --git a/extension/content.js b/extension/content.js new file mode 100644 index 0000000..b5d6bb4 --- /dev/null +++ b/extension/content.js @@ -0,0 +1,66 @@ +let applyingRemote = false; + +function getMediaElement() { + const els = Array.from(document.querySelectorAll('video, audio')); + if (!els.length) return null; + const playing = els.find((el) => !el.paused && !el.ended); + if (playing) return playing; + const videos = els.filter((el) => el.tagName === 'VIDEO'); + return videos.length ? videos[0] : els[0]; +} + +function onPlay(e) { + if (applyingRemote) return; + chrome.runtime.sendMessage({ type: 'media_event', action: 'play', time: e.target.currentTime }); +} + +function onPause(e) { + if (applyingRemote) return; + chrome.runtime.sendMessage({ type: 'media_event', action: 'pause', time: e.target.currentTime }); +} + +function onSeeked(e) { + if (applyingRemote) return; + chrome.runtime.sendMessage({ type: 'media_event', action: 'seek', time: e.target.currentTime }); +} + +function attachToMedia(el) { + if (el._syncwatchAttached) return; + el._syncwatchAttached = true; + el.addEventListener('play', onPlay); + el.addEventListener('pause', onPause); + el.addEventListener('seeked', onSeeked); +} + +function attachAll() { + document.querySelectorAll('video, audio').forEach(attachToMedia); +} + +attachAll(); + +const observer = new MutationObserver(attachAll); +observer.observe(document.documentElement, { childList: true, subtree: true }); + +chrome.runtime.onMessage.addListener((msg) => { + if (msg.type !== 'apply_sync') return; + + const el = getMediaElement(); + if (!el) return; + + applyingRemote = true; + + // Re-sync time on any action; only seek if drift is significant + const drift = Math.abs(el.currentTime - msg.time); + + if (msg.action === 'play') { + if (drift > 1) el.currentTime = msg.time; + el.play().catch(() => {}); + } else if (msg.action === 'pause') { + el.pause(); + if (drift > 1) el.currentTime = msg.time; + } else if (msg.action === 'seek') { + el.currentTime = msg.time; + } + + setTimeout(() => { applyingRemote = false; }, 500); +}); diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..ebdc22d --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,26 @@ +{ + "manifest_version": 3, + "name": "SyncWatch", + "version": "1.0.0", + "description": "Sync media playback with a friend in real time", + "permissions": [ + "storage", + "alarms", + "tabs" + ], + "host_permissions": [""], + "background": { + "service_worker": "background.js" + }, + "action": { + "default_popup": "popup.html", + "default_title": "SyncWatch" + }, + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"], + "run_at": "document_idle" + } + ] +} diff --git a/extension/popup.css b/extension/popup.css new file mode 100644 index 0000000..5066ea6 --- /dev/null +++ b/extension/popup.css @@ -0,0 +1,164 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #0f0f1a; + color: #dde1f0; + width: 280px; + min-height: 160px; +} + +.container { + padding: 18px 16px 16px; +} + +h1 { + font-size: 17px; + font-weight: 700; + color: #8b9cf4; + letter-spacing: 0.5px; + margin-bottom: 14px; +} + +input[type="text"] { + width: 100%; + padding: 8px 11px; + background: #1a1a2e; + border: 1px solid #2e2e50; + border-radius: 7px; + color: #dde1f0; + font-size: 13px; + margin-bottom: 8px; + outline: none; + transition: border-color 0.15s; +} + +input[type="text"]:focus { + border-color: #8b9cf4; +} + +button { + width: 100%; + padding: 9px; + background: #8b9cf4; + border: none; + border-radius: 7px; + color: #0f0f1a; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; +} + +button:hover:not(:disabled) { + background: #a0aff7; +} + +button:disabled { + opacity: 0.5; + cursor: default; +} + +.btn-secondary { + background: transparent; + border: 1px solid #2e2e50; + color: #888; + margin-top: 12px; + font-weight: 400; +} + +.btn-secondary:hover:not(:disabled) { + background: transparent; + border-color: #555; + color: #bbb; +} + +.hidden { + display: none !important; +} + +.server-status { + font-size: 11px; + color: #5cb85c; + margin-bottom: 14px; + letter-spacing: 0.3px; +} + +.divider { + text-align: center; + font-size: 11px; + color: #444; + margin: 10px 0; +} + +.join-row { + display: flex; + gap: 7px; + align-items: flex-start; +} + +.join-row input { + flex: 1; + margin-bottom: 0; + text-align: center; + letter-spacing: 3px; + font-size: 15px; + font-weight: 600; +} + +.join-row button { + width: auto; + padding: 8px 14px; + flex-shrink: 0; +} + +.code-card { + background: #1a1a2e; + border: 1px solid #2e2e50; + border-radius: 10px; + padding: 14px; + text-align: center; + margin-bottom: 10px; +} + +.code-label { + display: block; + font-size: 10px; + color: #666; + text-transform: uppercase; + letter-spacing: 1.5px; + margin-bottom: 6px; +} + +.code-value { + font-size: 30px; + font-weight: 700; + letter-spacing: 6px; + color: #8b9cf4; + font-family: 'SF Mono', 'Fira Code', monospace; +} + +.peer-status { + font-size: 12px; + color: #888; + text-align: center; + margin-bottom: 4px; +} + +.peer-status.connected { + color: #5cb85c; +} + +.error { + background: #2a1010; + border: 1px solid #6b2020; + border-radius: 7px; + padding: 8px 11px; + font-size: 12px; + color: #f08080; + margin-top: 10px; +} diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 0000000..3339f30 --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,44 @@ + + + + + SyncWatch + + + +
+

SyncWatch

+ +
+ + +
+ + + + +
+ + + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 0000000..bd1f539 --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,109 @@ +const $ = (id) => document.getElementById(id); + +const connectSection = $('connect-section'); +const roomSection = $('room-section'); +const serverUrlInput = $('server-url'); +const connectBtn = $('connect-btn'); +const roomControls = $('room-controls'); +const inRoom = $('in-room'); +const createBtn = $('create-btn'); +const codeInput = $('code-input'); +const joinBtn = $('join-btn'); +const disconnectBtn = $('disconnect-btn'); +const roomCodeEl = $('room-code'); +const peerStatusEl = $('peer-status'); +const errorMsg = $('error-msg'); + +function showError(text) { + errorMsg.textContent = text; + errorMsg.classList.remove('hidden'); + setTimeout(() => errorMsg.classList.add('hidden'), 3000); +} + +function showRoomView(code, hasPeer) { + roomControls.classList.add('hidden'); + inRoom.classList.remove('hidden'); + roomCodeEl.textContent = code; + peerStatusEl.textContent = hasPeer ? 'Peer connected' : 'Waiting for peer...'; + peerStatusEl.className = 'peer-status' + (hasPeer ? ' connected' : ''); +} + +connectBtn.addEventListener('click', () => { + const url = serverUrlInput.value.trim(); + if (!url) return; + connectBtn.disabled = true; + connectBtn.textContent = 'Connecting...'; + chrome.runtime.sendMessage({ type: 'connect', url }); +}); + +createBtn.addEventListener('click', () => { + chrome.runtime.sendMessage({ type: 'create_room' }); +}); + +joinBtn.addEventListener('click', () => { + const code = codeInput.value.trim(); + if (!/^\d{6}$/.test(code)) { showError('Enter a 6-digit code'); return; } + chrome.runtime.sendMessage({ type: 'join_room', code }); +}); + +codeInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') joinBtn.click(); +}); + +disconnectBtn.addEventListener('click', () => { + chrome.runtime.sendMessage({ type: 'disconnect' }); + roomSection.classList.add('hidden'); + connectSection.classList.remove('hidden'); + roomControls.classList.remove('hidden'); + inRoom.classList.add('hidden'); + connectBtn.disabled = false; + connectBtn.textContent = 'Connect'; +}); + +chrome.runtime.onMessage.addListener((msg) => { + switch (msg.type) { + case 'ws_status': + if (msg.status === 'connected') { + connectSection.classList.add('hidden'); + roomSection.classList.remove('hidden'); + } else { + connectSection.classList.remove('hidden'); + roomSection.classList.add('hidden'); + connectBtn.disabled = false; + connectBtn.textContent = 'Connect'; + if (msg.status === 'error') showError('Could not connect to server'); + } + break; + case 'created': + showRoomView(msg.code, false); + break; + case 'joined': + showRoomView(msg.code, msg.memberCount > 1); + break; + case 'peer_joined': + peerStatusEl.textContent = 'Peer connected'; + peerStatusEl.className = 'peer-status connected'; + break; + case 'peer_left': + peerStatusEl.textContent = 'Peer disconnected'; + peerStatusEl.className = 'peer-status'; + break; + case 'error': + showError(msg.message); + break; + } +}); + +// Restore state when popup opens +chrome.storage.local.get(['serverUrl'], ({ serverUrl }) => { + if (serverUrl) serverUrlInput.value = serverUrl; +}); + +chrome.runtime.sendMessage({ type: 'get_state' }, (state) => { + if (!state) return; + if (state.connectState === 'connected') { + connectSection.classList.add('hidden'); + roomSection.classList.remove('hidden'); + if (state.roomCode) showRoomView(state.roomCode, false); + } +}); diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..ed8ace9 --- /dev/null +++ b/server/package.json @@ -0,0 +1,15 @@ +{ + "name": "syncwatch-server", + "version": "1.0.0", + "description": "WebSocket relay server for SyncWatch Chrome extension", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "engines": { + "node": ">=16" + }, + "dependencies": { + "ws": "^8.18.0" + } +} diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..8b0fa0d --- /dev/null +++ b/server/server.js @@ -0,0 +1,98 @@ +const { WebSocketServer, WebSocket } = require('ws'); +const http = require('http'); + +const PORT = process.env.PORT || 8080; + +const server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('SyncWatch server running\n'); +}); + +const wss = new WebSocketServer({ server }); + +// Map> +const rooms = new Map(); + +function generateCode() { + let code; + do { + code = Math.floor(100000 + Math.random() * 900000).toString(); + } while (rooms.has(code)); + return code; +} + +function broadcast(room, message, exclude) { + const data = JSON.stringify(message); + for (const client of room) { + if (client !== exclude && client.readyState === WebSocket.OPEN) { + client.send(data); + } + } +} + +wss.on('connection', (ws) => { + ws.roomCode = null; + + ws.on('message', (raw) => { + let msg; + try { msg = JSON.parse(raw); } catch { return; } + + switch (msg.type) { + case 'create': { + if (ws.roomCode) return; + const code = generateCode(); + rooms.set(code, new Set([ws])); + ws.roomCode = code; + ws.send(JSON.stringify({ type: 'created', code })); + break; + } + + case 'join': { + if (ws.roomCode) return; + const code = String(msg.code || ''); + const room = rooms.get(code); + if (!room) { + ws.send(JSON.stringify({ type: 'error', message: 'Room not found' })); + return; + } + if (room.size >= 2) { + ws.send(JSON.stringify({ type: 'error', message: 'Room is full' })); + return; + } + room.add(ws); + ws.roomCode = code; + ws.send(JSON.stringify({ type: 'joined', code, memberCount: room.size })); + broadcast(room, { type: 'peer_joined' }, ws); + break; + } + + case 'sync': { + if (!ws.roomCode) return; + const room = rooms.get(ws.roomCode); + if (!room) return; + broadcast(room, { type: 'sync', action: msg.action, time: msg.time }, ws); + break; + } + + case 'ping': + ws.send(JSON.stringify({ type: 'pong' })); + break; + } + }); + + ws.on('close', () => { + if (!ws.roomCode) return; + const room = rooms.get(ws.roomCode); + if (!room) return; + room.delete(ws); + if (room.size === 0) { + rooms.delete(ws.roomCode); + } else { + broadcast(room, { type: 'peer_left' }, null); + } + }); +}); + +server.listen(PORT, () => { + console.log(`SyncWatch server listening on port ${PORT}`); +});