Initial commit: SyncWatch Chrome extension + WebSocket server
This commit is contained in:
127
extension/background.js
Normal file
127
extension/background.js
Normal 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
66
extension/content.js
Normal 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
26
extension/manifest.json
Normal 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
164
extension/popup.css
Normal 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
44
extension/popup.html
Normal 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
109
extension/popup.js
Normal 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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user