mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 20:18:14 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01bc95e176 | |||
| 7417c21217 | |||
| 3e63602559 | |||
| 4785c9625c | |||
| 0d08711398 | |||
| abe876e04e | |||
| 3771243b3c | |||
| ee79d66ac0 | |||
| ac8d73ef4f | |||
| f3ee7b47b9 |
@@ -40,6 +40,7 @@ jobs:
|
||||
context: .
|
||||
file: server/Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
|
||||
+2
-1
@@ -38,7 +38,8 @@ coverage/
|
||||
# KoalaSync Specific
|
||||
# We ignore the synced files in the extension folder to ensure
|
||||
# the root 'shared/' remains the Single Source of Truth.
|
||||
extension/shared/
|
||||
extension/shared/*
|
||||
!extension/shared/README.md
|
||||
|
||||
# Temporary scratch files
|
||||
scratch/
|
||||
|
||||
+142
-54
@@ -30,8 +30,10 @@ function ensureState() {
|
||||
chrome.storage.session.get([
|
||||
'logs', 'history', 'currentRoom', 'lastActionState',
|
||||
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
|
||||
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime'
|
||||
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'currentTabId', 'currentTabTitle'
|
||||
], (data) => {
|
||||
if (data.currentTabId !== undefined) currentTabId = data.currentTabId;
|
||||
if (data.currentTabTitle !== undefined) currentTabTitle = data.currentTabTitle;
|
||||
// Merge data from storage with any early-arriving state
|
||||
// New entries (added during boot) must stay at the top (index 0)
|
||||
if (data.logs) logs = [...logs, ...data.logs].slice(0, 50);
|
||||
@@ -121,13 +123,12 @@ async function getPeerId() {
|
||||
|
||||
async function getSettings() {
|
||||
return new Promise(resolve => {
|
||||
chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'targetTabId', 'username'], (data) => {
|
||||
chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username'], (data) => {
|
||||
resolve({
|
||||
serverUrl: data.serverUrl || '',
|
||||
useCustomServer: data.useCustomServer || false,
|
||||
roomId: data.roomId || '',
|
||||
password: data.password || '',
|
||||
targetTabId: data.targetTabId || null,
|
||||
username: data.username || ''
|
||||
});
|
||||
});
|
||||
@@ -334,7 +335,7 @@ function updateBadgeStatus() {
|
||||
} else if (status === 'connecting') {
|
||||
chrome.action.setBadgeText({ text: '...' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#fbbf24' });
|
||||
} else if (status === 'connected' && currentTabId) {
|
||||
} else if (status === 'connected' && currentRoom && currentTabId) {
|
||||
chrome.action.setBadgeText({ text: 'ON' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#22c55e' });
|
||||
} else {
|
||||
@@ -518,7 +519,17 @@ function handleServerEvent(event, data) {
|
||||
if (!Array.isArray(currentRoom.peers)) currentRoom.peers = [];
|
||||
if (data.status === 'joined') {
|
||||
if (!currentRoom.peers.find(p => (p.peerId || p) === data.peerId)) {
|
||||
currentRoom.peers.push({ peerId: data.peerId, username: data.username, tabTitle: data.tabTitle });
|
||||
currentRoom.peers.push({
|
||||
peerId: data.peerId,
|
||||
username: data.username,
|
||||
tabTitle: data.tabTitle,
|
||||
mediaTitle: data.mediaTitle || null,
|
||||
playbackState: data.playbackState || null,
|
||||
currentTime: data.currentTime != null ? data.currentTime : null,
|
||||
volume: data.volume != null ? data.volume : null,
|
||||
muted: data.muted != null ? data.muted : null,
|
||||
lastHeartbeat: Date.now()
|
||||
});
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
}
|
||||
@@ -533,10 +544,26 @@ function handleServerEvent(event, data) {
|
||||
if (typeof peer === 'object') {
|
||||
peer.tabTitle = data.tabTitle;
|
||||
peer.username = data.username;
|
||||
peer.mediaTitle = data.mediaTitle !== undefined ? data.mediaTitle : peer.mediaTitle;
|
||||
peer.playbackState = data.playbackState !== undefined ? data.playbackState : peer.playbackState;
|
||||
peer.currentTime = data.currentTime !== undefined ? data.currentTime : peer.currentTime;
|
||||
peer.volume = data.volume !== undefined ? data.volume : peer.volume;
|
||||
peer.muted = data.muted !== undefined ? data.muted : peer.muted;
|
||||
peer.lastHeartbeat = Date.now();
|
||||
} else {
|
||||
// Migration: replace string with object
|
||||
const idx = currentRoom.peers.indexOf(peer);
|
||||
currentRoom.peers[idx] = { peerId: data.peerId, username: data.username, tabTitle: data.tabTitle };
|
||||
currentRoom.peers[idx] = {
|
||||
peerId: data.peerId,
|
||||
username: data.username,
|
||||
tabTitle: data.tabTitle,
|
||||
mediaTitle: data.mediaTitle || null,
|
||||
playbackState: data.playbackState || null,
|
||||
currentTime: data.currentTime != null ? data.currentTime : null,
|
||||
volume: data.volume != null ? data.volume : null,
|
||||
muted: data.muted != null ? data.muted : null,
|
||||
lastHeartbeat: Date.now()
|
||||
};
|
||||
}
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
@@ -576,10 +603,6 @@ function updateLastAction(action, senderId, timestamp = Date.now()) {
|
||||
}
|
||||
|
||||
async function routeToContent(action, payload) {
|
||||
if (!currentTabId) {
|
||||
const settings = await getSettings();
|
||||
currentTabId = settings.targetTabId;
|
||||
}
|
||||
if (!currentTabId) return;
|
||||
|
||||
const tabId = parseInt(currentTabId);
|
||||
@@ -679,11 +702,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
} else {
|
||||
connect();
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'RETRY_CONNECT') {
|
||||
reconnectFailed = false;
|
||||
reconnectStartTime = null;
|
||||
reconnectDelay = 1000;
|
||||
connect();
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'GET_STATUS') {
|
||||
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
|
||||
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : 'disconnected');
|
||||
@@ -714,6 +739,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
});
|
||||
addLog('Left Room', 'info');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'CLEAR_LOGS') {
|
||||
logs = [];
|
||||
sendResponse({ status: 'ok' });
|
||||
@@ -725,6 +751,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(`42${JSON.stringify([EVENTS.GET_ROOMS])}`);
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'WEB_JOIN_REQUEST') {
|
||||
const { roomId, password, useCustomServer, serverUrl } = message;
|
||||
chrome.storage.sync.set({
|
||||
@@ -751,7 +778,6 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
});
|
||||
return true; // Keep channel open for async getSettings
|
||||
} else if (message.type === 'REGENERATE_ID') {
|
||||
const newId = self.crypto.randomUUID().substring(0, 8);
|
||||
chrome.storage.local.set({ peerId: newId }, () => {
|
||||
@@ -760,7 +786,6 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
if (socket) socket.close(); // Force reconnect with new ID
|
||||
sendResponse({ peerId: newId });
|
||||
});
|
||||
return true;
|
||||
} else if (message.type === 'GET_VIDEO_STATE') {
|
||||
const { tabId } = message;
|
||||
if (!tabId) {
|
||||
@@ -774,47 +799,53 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
sendResponse(res);
|
||||
}
|
||||
});
|
||||
return true; // Keep channel open
|
||||
} 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();
|
||||
} else {
|
||||
// Event coming from POPUP: We must also route it to our OWN content script
|
||||
routeToContent(message.action, message.payload);
|
||||
}
|
||||
|
||||
// Update local state as initiator
|
||||
const timestamp = Date.now();
|
||||
updateLastAction(message.action, 'You', timestamp);
|
||||
message.payload.actionTimestamp = timestamp;
|
||||
|
||||
// Events coming from content script or popup
|
||||
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
|
||||
isForceSyncInitiator = true;
|
||||
forceSyncAcks.clear();
|
||||
const deadline = Date.now() + 5000;
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: true,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: deadline
|
||||
});
|
||||
addLog('Initiating Force Sync...', 'info');
|
||||
const processEvent = () => {
|
||||
const timestamp = Date.now();
|
||||
updateLastAction(message.action, 'You', timestamp);
|
||||
message.payload.actionTimestamp = timestamp;
|
||||
|
||||
// Route to our own content script so we pause and seek
|
||||
routeToContent(EVENTS.FORCE_SYNC_PREPARE, message.payload);
|
||||
|
||||
// Timeout if not everyone ACKs
|
||||
forceSyncTimeout = setTimeout(() => {
|
||||
if (isForceSyncInitiator) {
|
||||
addLog('Force Sync: Timeout waiting for ACKs, executing anyway...', 'warn');
|
||||
executeForceSync();
|
||||
}
|
||||
}, 5000);
|
||||
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
|
||||
isForceSyncInitiator = true;
|
||||
forceSyncAcks.clear();
|
||||
const deadline = Date.now() + 5000;
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: true,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: deadline
|
||||
});
|
||||
addLog('Initiating Force Sync...', 'info');
|
||||
|
||||
routeToContent(EVENTS.FORCE_SYNC_PREPARE, message.payload);
|
||||
|
||||
forceSyncTimeout = setTimeout(() => {
|
||||
if (isForceSyncInitiator) {
|
||||
addLog('Force Sync: Timeout waiting for ACKs, executing anyway...', 'warn');
|
||||
executeForceSync();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
addToHistory(message.action, 'You');
|
||||
emit(message.action, { ...message.payload, peerId });
|
||||
sendResponse({ status: 'ok' });
|
||||
};
|
||||
|
||||
if (sender.tab) {
|
||||
const senderTabId = sender.tab.id;
|
||||
|
||||
if (!currentTabId || currentTabId !== senderTabId) {
|
||||
sendResponse({ status: 'ignored_unselected_tab' });
|
||||
return;
|
||||
}
|
||||
|
||||
currentTabTitle = sender.tab.title ? sender.tab.title.substring(0, 50) : null;
|
||||
chrome.storage.session.set({ currentTabTitle });
|
||||
updateBadgeStatus();
|
||||
processEvent();
|
||||
} else {
|
||||
routeToContent(message.action, message.payload);
|
||||
processEvent();
|
||||
}
|
||||
addToHistory(message.action, 'You');
|
||||
emit(message.action, { ...message.payload, peerId });
|
||||
} else if (message.type === 'FORCE_SYNC_ACK') {
|
||||
if (isForceSyncInitiator) {
|
||||
forceSyncAcks.add(peerId);
|
||||
@@ -827,6 +858,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
} else {
|
||||
emit(EVENTS.FORCE_SYNC_ACK, { peerId });
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'CMD_ACK') {
|
||||
// Content script successfully ran a command. Send ACK back to the initiator.
|
||||
if (currentCommandSenderId && currentCommandSenderId !== peerId) {
|
||||
@@ -836,17 +868,63 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
actionTimestamp: message.actionTimestamp
|
||||
});
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'HEARTBEAT') {
|
||||
if (sender.tab) {
|
||||
currentTabId = sender.tab.id;
|
||||
const senderTabId = sender.tab.id;
|
||||
|
||||
if (!currentTabId || currentTabId !== senderTabId) {
|
||||
sendResponse({ status: 'ignored_unselected_tab' });
|
||||
return;
|
||||
}
|
||||
|
||||
currentTabTitle = sender.tab.title ? sender.tab.title.substring(0, 50) : null;
|
||||
chrome.storage.session.set({ currentTabTitle });
|
||||
updateBadgeStatus();
|
||||
}
|
||||
// Peer status heartbeat from content script
|
||||
|
||||
getSettings().then(settings => {
|
||||
emit(EVENTS.PEER_STATUS, { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle });
|
||||
const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle };
|
||||
emit(EVENTS.PEER_STATUS, statusPayload);
|
||||
|
||||
if (currentRoom && currentRoom.peers) {
|
||||
const me = currentRoom.peers.find(p => (p.peerId || p) === peerId);
|
||||
if (me && typeof me === 'object') {
|
||||
me.tabTitle = currentTabTitle;
|
||||
me.username = settings.username;
|
||||
me.mediaTitle = message.payload.mediaTitle;
|
||||
me.playbackState = message.payload.playbackState;
|
||||
me.currentTime = message.payload.currentTime;
|
||||
me.volume = message.payload.volume;
|
||||
me.muted = message.payload.muted;
|
||||
me.lastHeartbeat = Date.now();
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
}
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
});
|
||||
} else if (message.type === 'SET_TARGET_TAB') {
|
||||
currentTabId = message.tabId;
|
||||
currentTabTitle = message.tabTitle;
|
||||
chrome.storage.session.set({ currentTabId, currentTabTitle });
|
||||
updateBadgeStatus();
|
||||
|
||||
if (currentTabId) {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId: currentTabId },
|
||||
files: ['content.js']
|
||||
}).catch(err => {
|
||||
addLog(`Failed to inject into tab: ${err.message}`, 'warn');
|
||||
});
|
||||
}
|
||||
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'LOG') {
|
||||
addLog(`[Content] ${message.message}`, message.level || 'info');
|
||||
sendResponse({ status: 'ok' });
|
||||
} else {
|
||||
// Final fallback to prevent channel hanging
|
||||
sendResponse({ error: 'unhandled_message' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -855,11 +933,21 @@ chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
if (tabId === currentTabId) {
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
chrome.storage.sync.set({ targetTabId: null });
|
||||
chrome.storage.session.set({ currentTabId: null, currentTabTitle: null });
|
||||
updateBadgeStatus();
|
||||
addLog('Target tab closed.', 'warn');
|
||||
}
|
||||
});
|
||||
|
||||
// Re-inject on full page refresh
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||||
if (currentTabId && tabId === parseInt(currentTabId) && changeInfo.status === 'complete') {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['content.js']
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// Initial Connect
|
||||
connect();
|
||||
|
||||
+26
-25
@@ -25,19 +25,16 @@
|
||||
PEER_STATUS: "peer_status"
|
||||
};
|
||||
|
||||
let lastTargetState = null;
|
||||
let targetStateTimeout = null;
|
||||
let expectedEvents = new Set();
|
||||
let expectedTimeouts = {};
|
||||
|
||||
function setTargetState(state) {
|
||||
lastTargetState = state;
|
||||
if (targetStateTimeout) clearTimeout(targetStateTimeout);
|
||||
if (state !== null) {
|
||||
// Seek events might take longer than play/pause, using 2s for safety
|
||||
const timeout = state === 'seek' ? 2000 : 1500;
|
||||
targetStateTimeout = setTimeout(() => {
|
||||
lastTargetState = null;
|
||||
}, timeout);
|
||||
}
|
||||
function expectEvent(state) {
|
||||
expectedEvents.add(state);
|
||||
if (expectedTimeouts[state]) clearTimeout(expectedTimeouts[state]);
|
||||
const timeout = state === 'seek' ? 10000 : 1500;
|
||||
expectedTimeouts[state] = setTimeout(() => {
|
||||
expectedEvents.delete(state);
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
function reportLog(message, level = 'info') {
|
||||
@@ -74,11 +71,11 @@
|
||||
if (ytButton) {
|
||||
const isCurrentlyPlaying = !video.paused;
|
||||
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
|
||||
setTargetState(action === EVENTS.PLAY ? 'playing' : 'paused');
|
||||
expectEvent(action === EVENTS.PLAY ? 'playing' : 'paused');
|
||||
ytButton.click();
|
||||
}
|
||||
if (action === EVENTS.SEEK) {
|
||||
setTargetState('seek');
|
||||
expectEvent('seek');
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
return;
|
||||
@@ -90,11 +87,11 @@
|
||||
if (twitchButton) {
|
||||
const isCurrentlyPlaying = !video.paused;
|
||||
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
|
||||
setTargetState(action === EVENTS.PLAY ? 'playing' : 'paused');
|
||||
expectEvent(action === EVENTS.PLAY ? 'playing' : 'paused');
|
||||
twitchButton.click();
|
||||
}
|
||||
if (action === EVENTS.SEEK) {
|
||||
setTargetState('seek');
|
||||
expectEvent('seek');
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
return;
|
||||
@@ -103,16 +100,16 @@
|
||||
|
||||
// Fallback for native HTML5
|
||||
if (action === EVENTS.PLAY) {
|
||||
setTargetState('playing');
|
||||
expectEvent('playing');
|
||||
video.play().catch((e) => {
|
||||
reportLog(`Playback prevented: ${e.message}`, 'warn');
|
||||
setTargetState(null);
|
||||
expectedEvents.delete('playing');
|
||||
});
|
||||
} else if (action === EVENTS.PAUSE) {
|
||||
setTargetState('paused');
|
||||
expectEvent('paused');
|
||||
video.pause();
|
||||
} else if (action === EVENTS.SEEK) {
|
||||
setTargetState('seek');
|
||||
expectEvent('seek');
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -135,7 +132,7 @@
|
||||
|
||||
elapsed += interval;
|
||||
const timeDiff = Math.abs(video.currentTime - targetTime);
|
||||
const ready = video.readyState >= 3 && timeDiff < 1.0;
|
||||
const ready = video.readyState >= 3 && timeDiff < 2.0;
|
||||
if (ready) {
|
||||
clearInterval(timer);
|
||||
resolve(true);
|
||||
@@ -175,7 +172,8 @@
|
||||
reportLog(`Media Action Error: Invalid force sync payload - ${JSON.stringify(payload)}`, 'error');
|
||||
return;
|
||||
}
|
||||
setTargetState('paused');
|
||||
expectEvent('paused');
|
||||
expectEvent('seek');
|
||||
video.pause();
|
||||
video.currentTime = payload.targetTime;
|
||||
pollSeekReady(payload.targetTime).then((ready) => {
|
||||
@@ -213,6 +211,7 @@
|
||||
duration: video.duration || 0,
|
||||
readyState: video.readyState,
|
||||
muted: video.muted,
|
||||
volume: video.volume,
|
||||
playbackRate: video.playbackRate,
|
||||
url: window.location.href,
|
||||
id: video.id || 'none',
|
||||
@@ -240,8 +239,8 @@
|
||||
|
||||
const eventState = action === EVENTS.PLAY ? 'playing' : (action === EVENTS.PAUSE ? 'paused' : (action === EVENTS.SEEK ? 'seek' : null));
|
||||
|
||||
if (eventState && lastTargetState === eventState) {
|
||||
setTargetState(null); // Consume the match
|
||||
if (eventState && expectedEvents.has(eventState)) {
|
||||
expectedEvents.delete(eventState); // Consume the match
|
||||
return; // Ignore event caused by our programmatic action
|
||||
}
|
||||
|
||||
@@ -316,7 +315,9 @@
|
||||
payload: {
|
||||
playbackState: video.paused ? 'paused' : 'playing',
|
||||
currentTime: video.currentTime,
|
||||
mediaTitle: mediaTitle
|
||||
mediaTitle: mediaTitle,
|
||||
volume: video.volume,
|
||||
muted: video.muted
|
||||
}
|
||||
}).catch(err => {
|
||||
if (err.message.includes('Extension context invalidated')) {
|
||||
|
||||
+1
-11
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "KoalaSync",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.4",
|
||||
"description": "Synchronize video playback across different tabs and users.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
@@ -27,16 +27,6 @@
|
||||
"type": "module"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"js": [
|
||||
"content.js"
|
||||
],
|
||||
"run_at": "document_idle",
|
||||
"all_frames": false
|
||||
},
|
||||
{
|
||||
"matches": ["https://koalasync.shik3i.net/*"],
|
||||
"js": ["bridge.js"],
|
||||
|
||||
@@ -148,9 +148,6 @@
|
||||
}
|
||||
|
||||
.peer-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
@@ -344,6 +341,11 @@
|
||||
<button id="clearLogs" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;">CLEAR</button>
|
||||
</div>
|
||||
<div id="logList"></div>
|
||||
|
||||
<div style="margin-top: 20px; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 10px;">
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="color: var(--text-muted); text-decoration: none; font-size: 10px; opacity: 0.6; display: block;">GitHub Repository</a>
|
||||
<div id="appVersion" style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;">v0.0.0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="popup.js" type="module"></script>
|
||||
|
||||
+130
-24
@@ -49,12 +49,18 @@ let lastPeersJson = null;
|
||||
// --- Initialization ---
|
||||
async function init() {
|
||||
// Load Settings
|
||||
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'targetTabId', 'filterNoise', 'username']);
|
||||
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username']);
|
||||
elements.serverUrl.value = data.serverUrl || '';
|
||||
elements.roomId.value = data.roomId || '';
|
||||
elements.password.value = data.password || '';
|
||||
elements.username.value = data.username || '';
|
||||
elements.filterNoise.checked = data.filterNoise !== false;
|
||||
|
||||
// Set Version Info
|
||||
const versionEl = document.getElementById('appVersion');
|
||||
if (versionEl) {
|
||||
versionEl.textContent = `v${chrome.runtime.getManifest().version}`;
|
||||
}
|
||||
|
||||
if (data.useCustomServer) {
|
||||
setServerMode(true);
|
||||
@@ -62,21 +68,23 @@ async function init() {
|
||||
setServerMode(false);
|
||||
}
|
||||
|
||||
// Populate Tabs
|
||||
await populateTabs();
|
||||
|
||||
toggleUIState(!!data.roomId);
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
||||
refreshLogs();
|
||||
refreshHistory();
|
||||
|
||||
// Initial Status Check
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => {
|
||||
if (res) {
|
||||
localPeerId = res.peerId;
|
||||
applyConnectionStatus(res.status);
|
||||
updatePeerList(res.peers);
|
||||
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
||||
|
||||
// Populate Tabs using the background's targetTabId
|
||||
await populateTabs(res.peers, res.targetTabId);
|
||||
} else {
|
||||
await populateTabs();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -94,6 +102,7 @@ async function init() {
|
||||
function toggleUIState(inRoom) {
|
||||
if (elements.sectionJoin) elements.sectionJoin.style.display = inRoom ? 'none' : 'block';
|
||||
if (elements.sectionActive) elements.sectionActive.style.display = inRoom ? 'block' : 'none';
|
||||
if (elements.peerListSync) elements.peerListSync.style.display = inRoom ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function updateUI(roomId, password, useCustomServer = false, serverUrl = '') {
|
||||
@@ -185,11 +194,56 @@ function updateLastActionUI(state, peers) {
|
||||
elements.lastActionCard.appendChild(grid);
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
if (seconds === null || seconds === undefined || isNaN(seconds)) return '--:--';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getVolumeIcon(volume, muted) {
|
||||
if (muted || volume === 0) return '🔇';
|
||||
if (volume < 0.33) return '🔈';
|
||||
if (volume < 0.66) return '🔉';
|
||||
return '🔊';
|
||||
}
|
||||
|
||||
let activePeers = [];
|
||||
let interpolationInterval = null;
|
||||
|
||||
function startInterpolation() {
|
||||
if (interpolationInterval) return;
|
||||
interpolationInterval = setInterval(() => {
|
||||
const timeElements = document.querySelectorAll('.peer-time-display');
|
||||
timeElements.forEach(el => {
|
||||
const peerId = el.dataset.peerId;
|
||||
const peer = activePeers.find(p => p.peerId === peerId);
|
||||
if (peer && peer.playbackState === 'playing' && peer.currentTime != null && peer.lastHeartbeat) {
|
||||
const elapsed = (Date.now() - peer.lastHeartbeat) / 1000;
|
||||
el.textContent = formatTime(peer.currentTime + elapsed);
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function updatePeerList(peers) {
|
||||
if (!peers) return;
|
||||
activePeers = peers;
|
||||
if (!interpolationInterval) startInterpolation();
|
||||
|
||||
// UI Throttle: Only re-render if the peer state actually changed
|
||||
const currentPeersJson = JSON.stringify(peers);
|
||||
// UI Throttle: Only re-render if the peer state actually changed (excluding time interpolation)
|
||||
const stateToHash = peers.map(p => ({
|
||||
id: p.peerId,
|
||||
user: p.username,
|
||||
tab: p.tabTitle,
|
||||
media: p.mediaTitle,
|
||||
state: p.playbackState,
|
||||
vol: p.volume,
|
||||
muted: p.muted
|
||||
}));
|
||||
const currentPeersJson = JSON.stringify(stateToHash);
|
||||
if (currentPeersJson === lastPeersJson) return;
|
||||
lastPeersJson = currentPeersJson;
|
||||
|
||||
@@ -210,10 +264,10 @@ function updatePeerList(peers) {
|
||||
|
||||
const peerItem = document.createElement('div');
|
||||
peerItem.className = 'peer-item';
|
||||
peerItem.style.cssText = 'display:block; padding: 6px 0;';
|
||||
peerItem.style.cssText = 'position:relative; display:block; padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.05);';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'display:flex; justify-content:space-between; align-items:center;';
|
||||
header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding-right: 24px;';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
if (pUsername) {
|
||||
@@ -232,29 +286,72 @@ function updatePeerList(peers) {
|
||||
|
||||
header.appendChild(nameSpan);
|
||||
|
||||
// Volume Icon (Top Right)
|
||||
if (p.volume !== undefined && p.volume !== null) {
|
||||
const volIcon = document.createElement('div');
|
||||
volIcon.style.cssText = 'position:absolute; top:8px; right:0; cursor:help; font-size:14px;';
|
||||
volIcon.textContent = getVolumeIcon(p.volume, p.muted);
|
||||
volIcon.title = p.muted ? 'Muted' : `Volume: ${Math.round(p.volume * 100)}%`;
|
||||
peerItem.appendChild(volIcon);
|
||||
}
|
||||
|
||||
if (pId === localPeerId) {
|
||||
const you = document.createElement('span');
|
||||
you.style.cssText = 'font-size:10px; color:var(--accent)';
|
||||
you.style.cssText = 'font-size:10px; color:var(--accent); font-weight:bold;';
|
||||
you.textContent = 'YOU';
|
||||
header.appendChild(you);
|
||||
}
|
||||
|
||||
peerItem.appendChild(header);
|
||||
|
||||
// Media Info
|
||||
if (p.mediaTitle) {
|
||||
const mediaDiv = document.createElement('div');
|
||||
mediaDiv.style.cssText = 'font-size:11px; color:var(--star); font-weight: 600; margin-top: 2px;';
|
||||
mediaDiv.style.cssText = 'font-size:11px; color:var(--star); font-weight: 600; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 280px;';
|
||||
mediaDiv.textContent = `🎬 ${p.mediaTitle}`;
|
||||
peerItem.appendChild(mediaDiv);
|
||||
}
|
||||
|
||||
if (pTabTitle) {
|
||||
const titleDiv = document.createElement('div');
|
||||
titleDiv.style.cssText = 'font-size:10px; color:var(--text-muted); opacity: 0.8;';
|
||||
titleDiv.textContent = p.mediaTitle ? `via ${pTabTitle}` : pTabTitle;
|
||||
peerItem.appendChild(titleDiv);
|
||||
// Status Line (Play/Pause + Time)
|
||||
const statusLine = document.createElement('div');
|
||||
statusLine.style.cssText = 'display:flex; align-items:center; gap:8px; margin-top:4px;';
|
||||
|
||||
if (p.playbackState) {
|
||||
const stateIcon = document.createElement('span');
|
||||
stateIcon.style.fontSize = '10px';
|
||||
if (p.playbackState === 'playing') {
|
||||
stateIcon.textContent = '▶';
|
||||
stateIcon.style.color = 'var(--success)';
|
||||
} else {
|
||||
stateIcon.textContent = '⏸';
|
||||
stateIcon.style.color = 'var(--error)';
|
||||
}
|
||||
statusLine.appendChild(stateIcon);
|
||||
}
|
||||
|
||||
if (p.currentTime !== undefined && p.currentTime !== null) {
|
||||
const timeSpan = document.createElement('span');
|
||||
timeSpan.className = 'peer-time-display';
|
||||
timeSpan.dataset.peerId = pId;
|
||||
timeSpan.style.cssText = 'font-size:11px; font-family:monospace; color:var(--text-muted);';
|
||||
|
||||
let displayTime = p.currentTime;
|
||||
if (p.playbackState === 'playing' && p.lastHeartbeat && p.currentTime != null) {
|
||||
const elapsed = (Date.now() - p.lastHeartbeat) / 1000;
|
||||
displayTime += elapsed;
|
||||
}
|
||||
timeSpan.textContent = formatTime(displayTime);
|
||||
statusLine.appendChild(timeSpan);
|
||||
}
|
||||
|
||||
if (pTabTitle) {
|
||||
const titleDiv = document.createElement('span');
|
||||
titleDiv.style.cssText = 'font-size:10px; color:var(--text-muted); opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; text-align: right;';
|
||||
titleDiv.textContent = pTabTitle;
|
||||
statusLine.appendChild(titleDiv);
|
||||
}
|
||||
|
||||
peerItem.appendChild(statusLine);
|
||||
container.appendChild(peerItem);
|
||||
});
|
||||
};
|
||||
@@ -266,10 +363,16 @@ function updatePeerList(peers) {
|
||||
populateTabs(peers);
|
||||
}
|
||||
|
||||
async function populateTabs(providedPeers = null) {
|
||||
const data = await chrome.storage.sync.get(['targetTabId', 'filterNoise']);
|
||||
async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||
const data = await chrome.storage.sync.get(['filterNoise']);
|
||||
const isFilterActive = data.filterNoise !== false;
|
||||
const currentTargetTabId = data.targetTabId;
|
||||
|
||||
// Fallback if not provided directly
|
||||
let currentTargetTabId = providedTargetTabId;
|
||||
if (currentTargetTabId === null) {
|
||||
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
|
||||
currentTargetTabId = status?.targetTabId;
|
||||
}
|
||||
|
||||
// Use provided peers or fetch if missing
|
||||
let peerIds = providedPeers;
|
||||
@@ -645,15 +748,18 @@ elements.retryBtn.addEventListener('click', () => {
|
||||
chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' });
|
||||
});
|
||||
|
||||
elements.targetTab.addEventListener('change', async () => {
|
||||
await chrome.storage.sync.set({ targetTabId: elements.targetTab.value });
|
||||
elements.targetTab.addEventListener('change', () => {
|
||||
const val = elements.targetTab.value;
|
||||
const tabId = val ? parseInt(val) : null;
|
||||
const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.text.replace('⭐ MATCH: ', '') || null;
|
||||
chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId, tabTitle });
|
||||
});
|
||||
|
||||
elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
if (elements.forceSyncBtn.disabled) return;
|
||||
|
||||
const settings = await chrome.storage.sync.get(['targetTabId']);
|
||||
if (!settings.targetTabId) return;
|
||||
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
|
||||
if (!status || !status.targetTabId) return;
|
||||
|
||||
// Lockout to prevent spamming
|
||||
const originalText = elements.forceSyncBtn.textContent;
|
||||
@@ -664,7 +770,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
elements.forceSyncBtn.textContent = originalText;
|
||||
}, 5000);
|
||||
|
||||
const tabId = parseInt(settings.targetTabId);
|
||||
const tabId = parseInt(status.targetTabId);
|
||||
|
||||
const sendForceSync = (time) => {
|
||||
chrome.runtime.sendMessage({
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# ⚠️ READ BEFORE EDITING
|
||||
|
||||
This directory is a **MIRROR** of the root `/shared` folder.
|
||||
|
||||
**DO NOT edit these files directly.** Any changes made here will be overwritten the next time the synchronization script is run.
|
||||
|
||||
### Proper Workflow:
|
||||
1. **Edit** the source files in the root `[repo_root]/shared/` directory.
|
||||
2. **Run** the synchronization script:
|
||||
- **Windows**: `[repo_root]\scripts\sync-constants.bat`
|
||||
- **Linux/macOS**: `[repo_root]/scripts/sync-constants.sh`
|
||||
3. **Verify** that the changes have propagated to this folder.
|
||||
|
||||
Failure to follow this protocol will result in out-of-sync components and broken protocol logic.
|
||||
@@ -292,6 +292,10 @@ io.on('connection', (socket) => {
|
||||
username: data.username !== undefined ? data.username : existing.username,
|
||||
tabTitle: data.tabTitle !== undefined ? data.tabTitle : existing.tabTitle,
|
||||
mediaTitle: data.mediaTitle !== undefined ? data.mediaTitle : existing.mediaTitle,
|
||||
playbackState: data.playbackState !== undefined ? data.playbackState : existing.playbackState,
|
||||
currentTime: data.currentTime !== undefined ? data.currentTime : existing.currentTime,
|
||||
volume: data.volume !== undefined ? data.volume : existing.volume,
|
||||
muted: data.muted !== undefined ? data.muted : existing.muted,
|
||||
lastSeen: Date.now()
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/**
|
||||
* blacklist.js
|
||||
*
|
||||
* ⚠️ WARNING: This is the SINGLE SOURCE OF TRUTH.
|
||||
* If you edit this file, you MUST run /scripts/sync-constants.bat
|
||||
* to propagate changes to the extension and relay server.
|
||||
*
|
||||
* Domains to be filtered out from the tab selection dropdown to reduce "noise".
|
||||
* These are typically sites that won't contain shareable video content.
|
||||
*/
|
||||
|
||||
+5
-1
@@ -1,9 +1,13 @@
|
||||
/**
|
||||
* KoalaSync Shared Constants & Protocol Definitions
|
||||
*
|
||||
* ⚠️ WARNING: This is the SINGLE SOURCE OF TRUTH.
|
||||
* If you edit this file, you MUST run /scripts/sync-constants.bat
|
||||
* to propagate changes to the extension and relay server.
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = "1.0.0";
|
||||
export const APP_VERSION = "1.0.3";
|
||||
export const APP_VERSION = "1.1.4";
|
||||
|
||||
export const OFFICIAL_SERVER_URL = 'wss://sync.shik3i.net';
|
||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://koalasync.shik3i.net';
|
||||
|
||||
Reference in New Issue
Block a user