Initial commit: SyncWatch Chrome extension + WebSocket server

This commit is contained in:
2026-05-23 22:22:19 -04:00
commit 24d88f4754
8 changed files with 649 additions and 0 deletions

127
extension/background.js Normal file
View File

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

66
extension/content.js Normal file
View File

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

26
extension/manifest.json Normal file
View File

@@ -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": ["<all_urls>"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_title": "SyncWatch"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}

164
extension/popup.css Normal file
View File

@@ -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;
}

44
extension/popup.html Normal file
View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SyncWatch</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="container">
<h1>SyncWatch</h1>
<div id="connect-section">
<input id="server-url" type="text" placeholder="ws://your-server:8080" spellcheck="false">
<button id="connect-btn">Connect</button>
</div>
<div id="room-section" class="hidden">
<p class="server-status">Connected to server</p>
<div id="room-controls">
<button id="create-btn">Create Room</button>
<div class="divider">or join existing</div>
<div class="join-row">
<input id="code-input" type="text" placeholder="123456" maxlength="6" spellcheck="false">
<button id="join-btn">Join</button>
</div>
</div>
<div id="in-room" class="hidden">
<div class="code-card">
<span class="code-label">Room Code</span>
<span id="room-code" class="code-value"></span>
</div>
<p id="peer-status" class="peer-status">Waiting for peer...</p>
</div>
<button id="disconnect-btn" class="btn-secondary">Disconnect</button>
</div>
<p id="error-msg" class="error hidden"></p>
</div>
<script src="popup.js"></script>
</body>
</html>

109
extension/popup.js Normal file
View File

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

15
server/package.json Normal file
View File

@@ -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"
}
}

98
server/server.js Normal file
View File

@@ -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<roomCode, Set<WebSocket>>
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}`);
});