Initial commit: Production-ready KoalaSync Monorepo with Manifest V3, Socket.IO Relay, and Two-Phase Sync

This commit is contained in:
MacBook
2026-04-21 07:49:37 +02:00
commit 14033244e5
27 changed files with 3068 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
# KoalaSync Chrome Extension
A Manifest V3 Chrome Extension for synchronized video playback.
## Key Features
- **Manifest V3**: Using a modern Service Worker architecture.
- **Native WebSockets**: No heavy libraries, uses the browser's native API.
## Privacy & Permissions
KoalaSync requires `<all_urls>` permission to detect and interact with video elements (`<video>`) on any website.
- **No Browsing History**: We do not track which sites you visit.
- **No Telemetry**: There are no analytics or tracking scripts included.
- **Local State**: Settings (Server URL, Room ID, Password) are stored only locally in your browser using `chrome.storage`.
## Installation
1. Go to `chrome://extensions/`.
2. Enable **Developer mode**.
3. Click **Load unpacked** and select this folder.
## Development
If you change `shared/constants.js`, remember to run the synchronization script:
- Windows: `..\scripts\sync-constants.bat`
- Linux/macOS: `../scripts/sync-constants.sh`
+375
View File
@@ -0,0 +1,375 @@
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, APP_VERSION } from './shared/constants.js';
// --- State Management ---
let socket = null;
let reconnectDelay = 1000;
const MAX_RECONNECT_DELAY = 30000;
let isConnecting = false;
let peerId = null; // initialized via getPeerId()
let currentRoom = null;
let currentTabId = null;
let currentTabTitle = null; // New: for Smart Matching
let logs = [];
let history = []; // New: for Action History
// Force Sync Coordination
let isForceSyncInitiator = false;
let forceSyncAcks = new Set();
let forceSyncTimeout = null;
// --- Storage Utils ---
async function getPeerId() {
const data = await chrome.storage.local.get(['peerId']);
if (data.peerId) return data.peerId;
const newId = self.crypto.randomUUID().substring(0, 8);
await chrome.storage.local.set({ peerId: newId });
return newId;
}
async function getSettings() {
return new Promise(resolve => {
chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'targetTabId'], (data) => {
resolve({
serverUrl: data.serverUrl || '',
useCustomServer: data.useCustomServer || false,
roomId: data.roomId || '',
password: data.password || '',
targetTabId: data.targetTabId || null
});
});
});
}
function addLog(message, type = 'info') {
const log = {
timestamp: new Date().toISOString(),
message,
type
};
logs.unshift(log);
if (logs.length > 50) logs.pop();
chrome.runtime.sendMessage({ type: 'LOG_UPDATE', log }).catch(() => {});
}
// --- WebSocket Client ---
async function connect() {
if (isConnecting || (socket && socket.readyState === WebSocket.OPEN)) return;
if (!peerId) peerId = await getPeerId();
const settings = await getSettings();
isConnecting = true;
broadcastConnectionStatus('connecting');
const isCustomServer = settings.serverUrl && settings.useCustomServer;
const finalUrl = isCustomServer ? settings.serverUrl : OFFICIAL_SERVER_URL;
addLog(`Connecting to ${isCustomServer ? finalUrl : 'Official Server'}...`, 'info');
const url = new URL(finalUrl);
url.pathname = '/socket.io/';
url.searchParams.set('EIO', '4');
url.searchParams.set('transport', 'websocket');
url.searchParams.set('version', APP_VERSION);
if (!isCustomServer) {
url.searchParams.set('token', OFFICIAL_SERVER_TOKEN);
}
socket = new WebSocket(url.toString());
socket.onopen = () => {
isConnecting = false;
reconnectDelay = 1000;
addLog('WebSocket Connection Opened', 'success');
broadcastConnectionStatus('connected');
// Socket.IO Handshake: Send "40" to join default namespace
socket.send('40');
};
socket.onmessage = (event) => {
const msg = event.data;
// Engine.IO Ping/Pong
if (msg === '2') {
socket.send('3'); // Pong
return;
}
// Socket.IO Handshake / Packet parsing
if (msg.startsWith('0')) {
addLog(`Socket.IO Handshake: ${msg}`, 'info');
} else if (msg.startsWith('40')) {
addLog('Joined Namespace /', 'success');
// Auto-rejoin room if we have one in settings
if (settings.roomId) {
emit(EVENTS.JOIN_ROOM, {
roomId: settings.roomId,
password: settings.password,
peerId,
protocolVersion: PROTOCOL_VERSION
});
}
} else if (msg.startsWith('42')) {
// Event: 42["event", data]
try {
const payload = JSON.parse(msg.substring(2));
handleServerEvent(payload[0], payload[1]);
} catch (e) {
addLog(`Failed to parse message: ${msg}`, 'error');
}
}
};
socket.onclose = () => {
isConnecting = false;
if (currentRoom) {
currentRoom.peers = [];
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
}
broadcastConnectionStatus('disconnected');
addLog(`Disconnected. Retrying in ${reconnectDelay / 1000}s...`, 'warn');
scheduleReconnect();
};
socket.onerror = (err) => {
broadcastConnectionStatus('disconnected');
addLog('WebSocket Error', 'error');
socket.close();
};
}
function broadcastConnectionStatus(status) {
chrome.runtime.sendMessage({ type: 'CONNECTION_STATUS', status }).catch(() => {});
updateBadgeStatus();
}
function updateBadgeStatus() {
if (currentTabId) {
chrome.action.setBadgeText({ text: 'ON' });
chrome.action.setBadgeBackgroundColor({ color: '#22c55e' });
} else {
chrome.action.setBadgeText({ text: '' });
}
}
function showNotification(senderName, action) {
const label = action === 'play' ? 'started playback' :
action === 'pause' ? 'paused playback' :
action === 'seek' ? 'seeked the video' :
action === 'force_sync_execute' ? 'synchronized everyone' : action;
chrome.notifications.create(`sync_${Date.now()}`, {
type: 'basic',
iconUrl: 'icons/icon128.png',
title: 'KoalaSync',
message: `${senderName || 'A peer'} ${label}.`,
priority: 1
});
}
function scheduleReconnect() {
setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
connect();
}, reconnectDelay);
}
function emit(event, data) {
if (socket && socket.readyState === WebSocket.OPEN) {
const msg = `42${JSON.stringify([event, data])}`;
socket.send(msg);
}
}
// --- Event Handlers ---
function handleServerEvent(event, data) {
// console.log(`[RECV] ${event}`, data);
switch (event) {
case EVENTS.ROOM_DATA:
currentRoom = data;
addLog(`Joined Room: ${data.roomId}`, 'success');
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: data.peers }).catch(() => {});
break;
case EVENTS.ERROR:
addLog(`Server Error: ${data.message}`, 'error');
break;
case EVENTS.PLAY:
case EVENTS.PAUSE:
case EVENTS.SEEK:
case EVENTS.FORCE_SYNC_PREPARE:
routeToContent(event, data);
break;
case EVENTS.FORCE_SYNC_ACK:
if (isForceSyncInitiator) {
forceSyncAcks.add(data.senderId);
addLog(`Received ACK from ${data.senderId} (${forceSyncAcks.size})`, 'info');
// Check if all peers responded (minus ourselves)
const peerCount = currentRoom ? currentRoom.peers.length : 1;
if (forceSyncAcks.size >= peerCount - 1) {
executeForceSync();
}
}
break;
case EVENTS.FORCE_SYNC_EXECUTE:
routeToContent(event, data);
break;
case EVENTS.PEER_STATUS:
if (currentRoom) {
if (data.status === 'joined') {
if (!currentRoom.peers.find(p => (p.peerId || p) === data.peerId)) {
currentRoom.peers.push({ peerId: data.peerId, tabTitle: data.tabTitle });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
}
} else if (data.status === 'left') {
currentRoom.peers = currentRoom.peers.filter(p => (p.peerId || p) !== data.peerId);
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
} else {
// Heartbeat/Update: Update tabTitle for matching
const peer = currentRoom.peers.find(p => (p.peerId || p) === data.peerId);
if (peer) {
if (typeof peer === 'object') {
peer.tabTitle = data.tabTitle;
} else {
// Migration: replace string with object
const idx = currentRoom.peers.indexOf(peer);
currentRoom.peers[idx] = { peerId: data.peerId, tabTitle: data.tabTitle };
}
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
}
}
}
break;
default:
// History Tracking
const historyEntry = {
action: event,
senderId: data.senderId || 'You',
timestamp: new Date().toISOString()
};
history.unshift(historyEntry);
if (history.length > 20) history.pop();
chrome.runtime.sendMessage({ type: 'HISTORY_UPDATE', history }).catch(() => {});
// Notification for remote actions
if (data.senderId) showNotification(data.senderId, event);
routeToContent(event, data);
break;
}
}
function executeForceSync() {
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
isForceSyncInitiator = false;
emit(EVENTS.FORCE_SYNC_EXECUTE, {});
routeToContent(EVENTS.FORCE_SYNC_EXECUTE, {});
addLog('Force Sync Executed', 'success');
}
async function routeToContent(action, payload) {
if (!currentTabId) {
const settings = await getSettings();
currentTabId = settings.targetTabId;
}
if (!currentTabId) return;
const tabId = parseInt(currentTabId);
if (isNaN(tabId)) return;
chrome.tabs.sendMessage(tabId, {
type: 'SERVER_COMMAND',
action,
payload
}).catch(err => {
// Auto-Reinject if content script is missing
if (err.message.includes('Receiving end does not exist')) {
chrome.scripting.executeScript({
target: { tabId },
files: ['content.js']
}).then(() => {
setTimeout(() => routeToContent(action, payload), 500);
});
} else {
addLog(`Content Script not responding in tab ${tabId}`, 'warn');
currentTabId = null;
updateBadgeStatus();
}
});
}
// --- Keep-Alive Mechanism ---
chrome.alarms.create('keepAlive', { periodInMinutes: 0.25 }); // every 15s
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'keepAlive') {
// console.log('SW KeepAlive Heartbeat');
if (!socket || socket.readyState !== WebSocket.OPEN) {
connect();
}
}
});
// --- Extension Message Listeners ---
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'CONNECT') {
connect();
} else if (message.type === 'GET_STATUS') {
const status = socket ? (socket.readyState === WebSocket.OPEN ? 'connected' : (isConnecting ? 'connecting' : 'disconnected')) : 'disconnected';
sendResponse({ status, peers: currentRoom ? currentRoom.peers : [] });
} else if (message.type === 'LEAVE_ROOM') {
emit(EVENTS.LEAVE_ROOM, { peerId });
currentRoom = null;
addLog('Left Room', 'info');
} else if (message.type === 'CLEAR_LOGS') {
logs = [];
sendResponse({ status: 'ok' });
} else if (message.type === 'GET_LOGS') {
sendResponse(logs);
} else if (message.type === 'GET_HISTORY') {
sendResponse(history);
} else if (message.type === 'CONTENT_EVENT') {
if (sender.tab) {
currentTabId = sender.tab.id;
currentTabTitle = sender.tab.title ? sender.tab.title.substring(0, 50) : null;
updateBadgeStatus();
}
// Events coming from content script (manual play/pause)
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
isForceSyncInitiator = true;
forceSyncAcks.clear();
addLog('Initiating Force Sync...', 'info');
// Timeout if not everyone ACKs
forceSyncTimeout = setTimeout(() => {
if (isForceSyncInitiator) {
addLog('Force Sync: Timeout waiting for ACKs, executing anyway...', 'warn');
executeForceSync();
}
}, 5000);
}
emit(message.action, { ...message.payload, peerId });
} else if (message.type === 'FORCE_SYNC_ACK') {
emit(EVENTS.FORCE_SYNC_ACK, { peerId });
} else if (message.type === 'HEARTBEAT') {
if (sender.tab) {
currentTabId = sender.tab.id;
currentTabTitle = sender.tab.title ? sender.tab.title.substring(0, 50) : null;
}
// Peer status heartbeat from content script
emit(EVENTS.PEER_STATUS, { ...message.payload, peerId, tabTitle: currentTabTitle });
}
return true;
});
// Tab removal listener
chrome.tabs.onRemoved.addListener((tabId) => {
if (tabId === currentTabId) {
currentTabId = null;
currentTabTitle = null;
chrome.storage.sync.set({ targetTabId: null });
updateBadgeStatus();
addLog('Target tab closed.', 'warn');
}
});
// Initial Connect
connect();
+165
View File
@@ -0,0 +1,165 @@
/**
* KoalaSync Content Script
* Injected into video tabs to control playback and detect events.
*/
(function() {
if (window.koalaSyncInjected) return;
window.koalaSyncInjected = true;
let isProcessingCommand = false;
// --- Helper: find the best video element on the page ---
function findVideo() {
const videos = document.querySelectorAll('video');
return videos.length > 0 ? videos[0] : null;
}
// --- Helper: YouTube/Twitch specific actions ---
function tryMediaAction(action, data) {
const video = findVideo();
if (!video) return;
isProcessingCommand = true;
try {
const host = window.location.hostname.toLowerCase();
const isYouTube = host.includes('youtube.com');
const isTwitch = host.includes('twitch.tv');
if (isYouTube) {
const ytButton = document.querySelector('.ytp-play-button');
if (ytButton) {
const title = ytButton.getAttribute('aria-label') || '';
const isCurrentlyPlaying = title.toLowerCase().includes('pause');
if ((action === 'play' && !isCurrentlyPlaying) || (action === 'pause' && isCurrentlyPlaying)) {
ytButton.click();
}
}
if (action === 'seek') video.currentTime = data.targetTime;
return;
}
if (isTwitch) {
const twitchButton = document.querySelector('[data-a-target="player-play-pause-button"]');
if (twitchButton) {
const label = twitchButton.getAttribute('aria-label')?.toLowerCase() || '';
// Check for common localized labels (pause, stoppen, arrête)
const isCurrentlyPlaying = label.includes('pause') || label.includes('stoppen') || label.includes('arrête');
if ((action === 'play' && !isCurrentlyPlaying) || (action === 'pause' && isCurrentlyPlaying)) {
twitchButton.click();
}
}
if (action === 'seek') video.currentTime = data.targetTime;
return;
}
// Fallback for native HTML5
if (action === 'play') {
video.play().catch(() => {});
} else if (action === 'pause') {
video.pause();
} else if (action === 'seek') {
video.currentTime = data.targetTime;
}
} catch (e) {
console.error('KoalaSync Media Action Error:', e);
} finally {
// Guarantee reset even on early returns in YouTube/Twitch blocks
setTimeout(() => { isProcessingCommand = false; }, 1000);
}
}
// --- Helper: Wait until video is ready for playback (buffered & seeked) ---
function pollSeekReady(targetTime, timeoutMs = 8000) {
return new Promise((resolve) => {
const video = findVideo();
if (!video) { resolve(false); return; }
const interval = 150;
let elapsed = 0;
const timer = setInterval(() => {
elapsed += interval;
const timeDiff = Math.abs(video.currentTime - targetTime);
const ready = video.readyState >= 3 && timeDiff < 1.0;
if (ready) {
clearInterval(timer);
resolve(true);
} else if (elapsed >= timeoutMs) {
clearInterval(timer);
resolve(false);
}
}, interval);
});
}
// Listen for commands from background.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'SERVER_COMMAND') {
const { action, payload } = message;
if (action === 'play') {
tryMediaAction('play');
} else if (action === 'pause') {
tryMediaAction('pause');
} else if (action === 'seek') {
tryMediaAction('seek', payload);
} else if (action === 'force_sync_prepare') {
const video = findVideo();
if (video) {
video.pause();
video.currentTime = payload.targetTime;
pollSeekReady(payload.targetTime).then(() => {
chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' });
});
}
} else if (action === 'force_sync_execute') {
tryMediaAction('play');
}
}
});
// Detect native events
function reportEvent(action) {
if (isProcessingCommand) return;
const video = findVideo();
if (!video) return;
chrome.runtime.sendMessage({
type: 'CONTENT_EVENT',
action,
payload: {
currentTime: video.currentTime,
timestamp: Date.now()
}
});
}
function setupListeners() {
const video = findVideo();
if (video && !video.dataset.koalaAttached) {
video.addEventListener('play', () => reportEvent('play'));
video.addEventListener('pause', () => reportEvent('pause'));
video.addEventListener('seeked', () => reportEvent('seek'));
video.dataset.koalaAttached = 'true';
}
}
// Heartbeat
setInterval(() => {
const video = findVideo();
if (video) {
chrome.runtime.sendMessage({
type: 'HEARTBEAT',
payload: {
playbackState: video.paused ? 'paused' : 'playing',
currentTime: video.currentTime
}
}).catch(() => {});
}
}, 15000);
const observer = new MutationObserver(() => setupListeners());
observer.observe(document.body, { childList: true, subtree: true });
setupListeners();
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

+45
View File
@@ -0,0 +1,45 @@
{
"manifest_version": 3,
"name": "KoalaSync",
"version": "1.0.0",
"description": "Synchronize video playback across different tabs and users.",
"permissions": [
"storage",
"tabs",
"scripting",
"alarms",
"activeTab"
],
"host_permissions": [
"<all_urls>"
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"matches": [
"<all_urls>"
],
"js": [
"content.js"
],
"run_at": "document_idle",
"all_frames": false
}
],
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
+286
View File
@@ -0,0 +1,286 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>KoalaSync</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0f172a;
--card: #1e293b;
--accent: #6366f1;
--accent-hover: #818cf8;
--text: #f8fafc;
--text-muted: #94a3b8;
--success: #22c55e;
--error: #ef4444;
--radius: 12px;
--star: #fbbf24;
}
body {
width: 320px;
margin: 0;
padding: 16px;
background: var(--bg);
color: var(--text);
font-family: 'Outfit', sans-serif;
font-size: 14px;
}
h1 {
font-size: 18px;
margin: 0 0 16px 0;
color: var(--accent);
text-align: center;
letter-spacing: 1px;
text-transform: uppercase;
}
/* Tabs */
.tabs {
display: flex;
gap: 4px;
background: var(--card);
padding: 4px;
border-radius: var(--radius);
margin-bottom: 20px;
}
.tab-btn {
flex: 1;
padding: 8px;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-weight: 600;
border-radius: 8px;
transition: all 0.2s;
}
.tab-btn.active {
background: var(--accent);
color: white;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* Forms */
.form-group {
margin-bottom: 12px;
}
label {
display: block;
font-size: 11px;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 4px;
font-weight: 700;
}
input, select {
width: 100%;
box-sizing: border-box;
background: var(--card);
border: 1px solid #334155;
color: white;
padding: 10px;
border-radius: 8px;
outline: none;
font-family: inherit;
}
input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
button.primary {
width: 100%;
padding: 12px;
background: var(--accent);
border: none;
color: white;
font-weight: 700;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
margin-top: 8px;
}
button.primary:hover {
background: var(--accent-hover);
transform: translateY(-1px);
}
button.secondary {
width: 100%;
padding: 10px;
background: #334155;
border: none;
color: white;
font-weight: 600;
border-radius: 8px;
cursor: pointer;
margin-top: 8px;
}
/* Info Cards */
.info-card {
background: var(--card);
padding: 12px;
border-radius: 8px;
margin-bottom: 12px;
border: 1px solid #334155;
}
.peer-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #334155;
}
.peer-item:last-child { border: 0; }
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
margin-right: 6px;
}
.status-online { background: var(--success); box-shadow: 0 0 8px var(--success); }
.status-offline { background: var(--error); }
/* Logs */
#logList {
max-height: 200px;
overflow-y: auto;
font-size: 10px;
font-family: monospace;
background: #000;
padding: 8px;
border-radius: 8px;
color: #ccc;
}
.log-entry { margin-bottom: 4px; }
.log-error { color: var(--error); }
.log-success { color: var(--success); }
/* Invite Link */
.invite-box {
display: flex;
gap: 8px;
margin-top: 8px;
}
.invite-box input { flex: 1; font-size: 11px; }
.invite-box button { width: 40px; padding: 0; }
</style>
</head>
<body>
<h1>KoalaSync</h1>
<div class="tabs">
<button class="tab-btn active" data-tab="tab-room">Room</button>
<button class="tab-btn" data-tab="tab-sync">Sync</button>
<button class="tab-btn" data-tab="tab-dev">Dev</button>
</div>
<!-- Room Tab -->
<div id="tab-room" class="tab-content active">
<div class="form-group">
<label>Server</label>
<div style="display:flex; gap:4px; margin-bottom:8px;">
<button id="serverOfficial" class="tab-btn active" style="flex:1; padding:6px; font-size:11px;">Official</button>
<button id="serverCustom" class="tab-btn" style="flex:1; padding:6px; font-size:11px;">Custom</button>
</div>
<input type="text" id="serverUrl" placeholder="wss://your-server:3000" style="display:none;">
</div>
<div class="form-group">
<label>Room ID</label>
<input type="text" id="roomId" placeholder="Leave empty to create">
</div>
<div class="form-group">
<label>Password (Optional)</label>
<input type="password" id="password" placeholder="Room password">
</div>
<button id="joinBtn" class="primary">Join / Create Room</button>
<div id="roomInfo" style="display:none; margin-top: 20px;">
<label>Invite Link</label>
<div class="invite-box">
<input type="text" id="inviteLink" readonly>
<button id="copyInvite" class="secondary">📋</button>
</div>
<button id="leaveBtn" class="secondary" style="color: var(--error);">Leave Room</button>
</div>
</div>
<!-- Sync Tab -->
<div id="tab-sync" class="tab-content">
<div class="form-group">
<label>Target Tab (Video Source)</label>
<select id="targetTab">
<option value="">-- Select a Tab --</option>
</select>
</div>
<button id="forceSyncBtn" class="primary" style="background: linear-gradient(135deg, #6366f1, #a855f7);">
⚡ Force Sync Everyone
</button>
<div style="margin-top: 20px;">
<label>Peers in Room</label>
<div id="peerList" class="info-card">
<div style="text-align:center; color: var(--text-muted); font-size: 12px;">No peers connected</div>
</div>
</div>
<label>Recent Activity</label>
<div id="historyList" class="info-card" style="max-height: 120px; overflow-y: auto; font-size: 11px; color: var(--text-muted);">
<!-- History will be injected here -->
</div>
</div>
<!-- Dev Tab -->
<div id="tab-dev" class="tab-content">
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px;">
<label style="margin-bottom: 0;">Filter Noise Tabs</label>
<input type="checkbox" id="filterNoise" style="width: auto;" checked>
</div>
<label>Connection Status</label>
<div id="connStatus" class="info-card" style="display:flex; align-items:center;">
<span id="connDot" class="status-dot status-offline"></span>
<span id="connText" style="flex:1;">Disconnected</span>
<button id="copyLogs" class="btn secondary" style="width: auto; padding: 4px 10px; font-size: 11px;">Copy Logs</button>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 8px;">
<label>Logs (Last 50)</label>
<button id="clearLogs" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;">CLEAR</button>
</div>
<div id="logList"></div>
</div>
<script src="popup.js" type="module"></script>
</body>
</html>
+289
View File
@@ -0,0 +1,289 @@
import { EVENTS } from './shared/constants.js';
import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
const elements = {
tabs: document.querySelectorAll('.tab-btn'),
contents: document.querySelectorAll('.tab-content'),
copyInvite: document.getElementById('copyInvite'),
targetTab: document.getElementById('targetTab'),
forceSyncBtn: document.getElementById('forceSyncBtn'),
peerList: document.getElementById('peerList'),
logList: document.getElementById('logList'),
clearLogs: document.getElementById('clearLogs'),
connDot: document.getElementById('connDot'),
connText: document.getElementById('connText'),
serverUrl: document.getElementById('serverUrl'),
serverOfficial: document.getElementById('serverOfficial'),
serverCustom: document.getElementById('serverCustom'),
roomId: document.getElementById('roomId'),
password: document.getElementById('password'),
joinBtn: document.getElementById('joinBtn'),
leaveBtn: document.getElementById('leaveBtn'),
roomInfo: document.getElementById('roomInfo'),
inviteLink: document.getElementById('inviteLink'),
filterNoise: document.getElementById('filterNoise'),
historyList: document.getElementById('historyList'),
copyLogs: document.getElementById('copyLogs')
};
// --- Initialization ---
async function init() {
// Load Settings
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'targetTabId', 'filterNoise']);
elements.serverUrl.value = data.serverUrl || '';
elements.roomId.value = data.roomId || '';
elements.password.value = data.password || '';
elements.filterNoise.checked = data.filterNoise !== false;
if (data.useCustomServer) {
setServerMode(true);
} else {
setServerMode(false);
}
// Populate Tabs
await populateTabs();
updateUI(data.roomId, data.password);
refreshLogs();
refreshHistory();
// Initial Status Check
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
if (res) {
applyConnectionStatus(res.status);
updatePeerList(res.peers);
}
});
}
// --- UI Logic ---
function updateUI(roomId, password) {
const inRoom = !!roomId;
elements.roomInfo.style.display = inRoom ? 'block' : 'none';
if (inRoom) {
elements.inviteLink.value = `${roomId}${password ? '#' + password : ''}`;
}
}
function updatePeerList(peers) {
if (!peers) return;
elements.peerList.innerHTML = peers.map(id => `
<div class="peer-item">
<span>👤 ${id}</span>
</div>
`).join('');
// Re-populate tabs to update Star Matching when peers change
populateTabs();
}
async function populateTabs() {
const data = await chrome.storage.sync.get(['targetTabId', 'filterNoise']);
const isFilterActive = data.filterNoise !== false;
const currentTargetTabId = data.targetTabId;
// Get current peers from background to do matching
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
const peerIds = status?.peers || [];
const tabs = await chrome.tabs.query({});
// Clear existing options except placeholder
while (elements.targetTab.options.length > 1) {
elements.targetTab.remove(1);
}
const filteredTabs = tabs.filter(tab => {
if (!tab.url || tab.url.startsWith('chrome://')) return false;
if (isFilterActive && tab.id !== parseInt(currentTargetTabId)) {
const urlStr = tab.url.toLowerCase();
if (BLACKLIST_DOMAINS.some(d => urlStr.includes(d.toLowerCase()))) return false;
}
return true;
});
filteredTabs.forEach(tab => {
const option = document.createElement('option');
option.value = tab.id;
const title = (tab.title || 'Loading...');
// Smart Matching Logic
const peerTitles = peerIds.map(p => p.tabTitle).filter(t => t && t.length > 3);
const isMatch = peerTitles.some(pt => {
const t1 = title.toLowerCase();
const t2 = pt.toLowerCase();
return t1.includes(t2) || t2.includes(t1);
});
let label = title.substring(0, 45) + (title.length > 45 ? '...' : '');
if (isMatch) {
label = `⭐ MATCH: ${label}`;
option.style.fontWeight = 'bold';
option.style.color = 'var(--star)';
}
option.textContent = label;
elements.targetTab.appendChild(option);
});
// Sort: Matches first
const options = Array.from(elements.targetTab.options);
const placeholder = options.shift(); // Remove placeholder
options.sort((a, b) => (b.textContent.includes('⭐') ? 1 : 0) - (a.textContent.includes('⭐') ? 1 : 0));
elements.targetTab.innerHTML = '';
elements.targetTab.appendChild(placeholder);
options.forEach(opt => elements.targetTab.appendChild(opt));
if (currentTargetTabId) {
elements.targetTab.value = currentTargetTabId;
}
}
function applyConnectionStatus(status) {
const connected = status === 'connected';
const connecting = status === 'connecting';
elements.connDot.className = 'status-dot ' + (connected ? 'status-online' : 'status-offline');
elements.connText.textContent = connected ? 'Connected' : (connecting ? 'Connecting...' : 'Disconnected');
}
function updateHistory(history) {
if (!history || !elements.historyList) return;
if (history.length === 0) {
elements.historyList.innerHTML = '<div style="text-align:center; padding: 10px;">No activity yet</div>';
return;
}
elements.historyList.innerHTML = history.map(item => {
const time = new Date(item.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const actionLabel = item.action.toUpperCase().replace('FORCE_SYNC_', '');
const sender = item.senderId === 'You' ? '<span style="color:var(--accent)">You</span>' : item.senderId;
return `<div style="margin-bottom: 4px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 2px;">
<span style="color:#64748b">[${time}]</span> <b>${actionLabel}</b> by ${sender}
</div>`;
}).join('');
}
function refreshHistory() {
chrome.runtime.sendMessage({ type: 'GET_HISTORY' }, (res) => {
if (res) updateHistory(res);
});
}
function setServerMode(custom) {
elements.serverOfficial.classList.toggle('active', !custom);
elements.serverCustom.classList.toggle('active', custom);
elements.serverUrl.style.display = custom ? 'block' : 'none';
chrome.storage.sync.set({ useCustomServer: custom });
}
elements.serverOfficial.addEventListener('click', () => setServerMode(false));
elements.serverCustom.addEventListener('click', () => setServerMode(true));
elements.filterNoise.addEventListener('change', () => {
chrome.storage.sync.set({ filterNoise: elements.filterNoise.checked }, () => {
populateTabs();
});
});
elements.tabs.forEach(btn => {
btn.addEventListener('click', () => {
elements.tabs.forEach(b => b.classList.remove('active'));
elements.contents.forEach(c => c.classList.remove('active'));
btn.classList.add('active');
document.getElementById(btn.dataset.tab).classList.add('active');
if (btn.dataset.tab === 'tab-sync') refreshHistory();
});
});
// --- Action Handlers ---
elements.joinBtn.addEventListener('click', async () => {
const serverUrl = elements.serverUrl.value;
const roomId = elements.roomId.value || Math.random().toString(36).substring(2, 8).toUpperCase();
const password = elements.password.value;
await chrome.storage.sync.set({ serverUrl, roomId, password });
elements.roomId.value = roomId;
// Tell background to connect
chrome.runtime.sendMessage({ type: 'CONNECT' });
updateUI(roomId, password);
});
elements.leaveBtn.addEventListener('click', async () => {
chrome.runtime.sendMessage({ type: 'LEAVE_ROOM' });
await chrome.storage.sync.set({ roomId: '', password: '' });
elements.roomId.value = '';
elements.password.value = '';
updateUI(null, null);
});
elements.targetTab.addEventListener('change', async () => {
await chrome.storage.sync.set({ targetTabId: elements.targetTab.value });
});
elements.forceSyncBtn.addEventListener('click', async () => {
const settings = await chrome.storage.sync.get(['targetTabId']);
if (!settings.targetTabId) return;
chrome.tabs.sendMessage(parseInt(settings.targetTabId), { action: 'get_current_time' }, (response) => {
if (response && response.currentTime !== undefined) {
const time = parseFloat(response.currentTime);
chrome.runtime.sendMessage({
type: 'CONTENT_EVENT',
action: EVENTS.FORCE_SYNC_PREPARE,
payload: { targetTime: time }
});
}
});
});
elements.clearLogs.addEventListener('click', () => {
chrome.runtime.sendMessage({ type: 'CLEAR_LOGS' }, () => {
elements.logList.innerHTML = '';
});
});
elements.copyInvite.addEventListener('click', () => {
navigator.clipboard.writeText(elements.inviteLink.value);
elements.copyInvite.textContent = '✅';
setTimeout(() => { elements.copyInvite.textContent = '📋'; }, 2000);
});
// --- Logs & Status ---
async function refreshLogs() {
chrome.runtime.sendMessage({ type: 'GET_LOGS' }, (logs) => {
if (logs) {
elements.logList.innerHTML = logs.map(log => `
<div class="log-entry log-${log.type}">
[${log.timestamp.split('T')[1].split('.')[0]}] ${log.message}
</div>
`).join('');
}
});
}
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'LOG_UPDATE') {
refreshLogs();
} else if (msg.type === 'PEER_UPDATE') {
updatePeerList(msg.peers);
} else if (msg.type === 'CONNECTION_STATUS') {
applyConnectionStatus(msg.status);
} else if (msg.type === 'HISTORY_UPDATE') {
updateHistory(msg.history);
}
});
elements.copyLogs.addEventListener('click', () => {
chrome.runtime.sendMessage({ type: 'GET_LOGS' }, (logs) => {
if (!logs || logs.length === 0) return;
const text = logs.map(l => `[${l.timestamp}] [${l.type}] ${l.message}`).join('\n');
navigator.clipboard.writeText(text).then(() => {
const original = elements.copyLogs.textContent;
elements.copyLogs.textContent = 'Copied!';
setTimeout(() => elements.copyLogs.textContent = original, 2000);
});
});
});
init();
setInterval(refreshLogs, 5000);