mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9cf7c49dc | |||
| bcbd46d658 | |||
| 4d489ec992 | |||
| 65ad4b5c6b | |||
| c2857dbdda | |||
| d07bf745a3 | |||
| bd54e893b4 | |||
| 50c9ba4ec8 | |||
| 55c2d4ed0d | |||
| 02afc193c6 | |||
| 7fc156977a | |||
| 99cb07bc2a | |||
| 77ffda3e42 | |||
| 01bc95e176 | |||
| 7417c21217 |
@@ -52,19 +52,17 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Sync Protocol Constants
|
||||
- name: Build Extensions
|
||||
run: |
|
||||
chmod +x ./scripts/sync-constants.sh
|
||||
./scripts/sync-constants.sh
|
||||
|
||||
- name: Create Extension Zip
|
||||
run: |
|
||||
zip -r koala-sync-extension.zip extension/ -x "*.DS_Store*"
|
||||
npm install
|
||||
npm run build:extension
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: koala-sync-extension.zip
|
||||
files: |
|
||||
dist/koalasync-chrome.zip
|
||||
dist/koalasync-firefox.zip
|
||||
name: Release ${{ github.ref_name }}
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
|
||||
+3
-2
@@ -23,7 +23,7 @@ KoalaSync is a specialized tool for **synchronized video playback** across multi
|
||||
- `docker-compose.yml`: Root-level orchestration for the relay server.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Single Source of Truth**: `shared/constants.js` and `shared/blacklist.js` are the master files. They must be synchronized to the `extension/shared/` directory using `.\scripts\sync-constants.bat` or `./scripts/sync-constants.sh`.
|
||||
> **Single Source of Truth**: `shared/constants.js` and `shared/blacklist.js` are the master files. They must be synchronized to the `extension/shared/` directory using `node scripts/build-extension.js`.
|
||||
> - **Extension Modules** (`background.js`, `popup.js`) import directly from `./shared/constants.js`.
|
||||
> - **Content Scripts** (`content.js`) use a **manual synchronous mirror** to prevent race conditions during page load. Always verify parity after sync.
|
||||
|
||||
@@ -56,6 +56,7 @@ The popup UI follows a strict design system. Do not modify these variables or th
|
||||
## 5. Non-Negotiables (Core Logic)
|
||||
The following features are critical and must not be removed or fundamentally altered:
|
||||
- **Two-Phase Force Sync**: The `Prepare` → `ACK` → `Execute` flow ensures all peers are buffered before playback resumes.
|
||||
- **Episode Auto-Sync**: Ensures series binges stay perfectly synced. A lobby initiates during title transitions, freezing peers until everyone is ready.
|
||||
- **Dual Heartbeat**:
|
||||
- **Background Heartbeat (30s)**: Ensures session persistence even without a video element.
|
||||
- **Content Heartbeat (15s)**: Transmits current video metadata (time, title).
|
||||
@@ -84,7 +85,7 @@ The following features are critical and must not be removed or fundamentally alt
|
||||
|
||||
### Adding a Protocol Event
|
||||
1. Add the event name to `shared/constants.js`.
|
||||
2. Run the sync script (`.\scripts\sync-constants.bat` or `./scripts/sync-constants.sh`).
|
||||
2. Run the build script (`node scripts/build-extension.js`).
|
||||
3. Implement the handler in `server/index.js` and `background.js`.
|
||||
|
||||
### Testing Locally
|
||||
|
||||
+13
-5
@@ -25,25 +25,33 @@ When a user interacts with a video:
|
||||
Ensures all peers are frame-perfect and buffered before resuming:
|
||||
1. **Prepare**: Initiator sends `FORCE_SYNC_PREPARE` with the target timestamp.
|
||||
2. **Buffer**: Peers seek and pause. Once buffered (`readyState >= 3`), they send a `FORCE_SYNC_ACK`.
|
||||
3. **Execute**: Once the Initiator collects ACKs (or after a 5s timeout), they send `FORCE_SYNC_EXECUTE`.
|
||||
3. **Execute**: Once the Initiator collects ACKs (or after an 8.5s timeout), they send `FORCE_SYNC_EXECUTE`.
|
||||
4. **Resume**: All peers call `play()` simultaneously.
|
||||
|
||||
## 4. Peer Lifecycle & Dual Heartbeat
|
||||
## 4. Episode Auto-Sync
|
||||
Maintains continuous synchronized viewing when watching series:
|
||||
1. **Detection**: `content.js` monitors the Media Session API for title changes.
|
||||
2. **Lobby Creation**: When a new title is detected, the peer initiates an `EPISODE_LOBBY` and broadcasts the new title.
|
||||
3. **Wait State**: All peers freeze their video until they have also loaded the exact same title.
|
||||
4. **Mid-Lobby Joins**: If a new user joins the room during an active lobby, the lobby initiator broadcasts the active lobby state so the newcomer can sync up.
|
||||
5. **Resume**: Once all peers report `EPISODE_READY`, the lobby is resolved and playback resumes perfectly.
|
||||
|
||||
## 5. Peer Lifecycle & Dual Heartbeat
|
||||
To maintain a clean room state and eliminate "Ghost Peers":
|
||||
- **Session Heartbeat (Background)**: Every 30 seconds, `background.js` sends an "I'm alive" signal to the server. This keeps you in the room even if no video is playing.
|
||||
- **Video Heartbeat (Content)**: Every 15 seconds, `content.js` sends current playback metadata (time, title, state) if a video is found.
|
||||
- **Server Pruning**: The server runs a "Reaper" every 2 minutes. If a peer has sent **zero** activity (no events and no heartbeats) for 5 minutes, they are forcefully disconnected.
|
||||
- **Immediate Cleanup**: Rooms are deleted instantly when the last peer leaves or disconnects.
|
||||
|
||||
## 5. Security & Stability
|
||||
## 6. Security & Stability
|
||||
- **Service Worker Lifecycle**: Uses `chrome.alarms` to prevent the Manifest V3 service worker from suspending while in an active room.
|
||||
- **Rate Limiting**: Server-side per-socket and per-IP rate limits to prevent sync-spamming or DoS.
|
||||
- **Noise Filtering**: Uses a curated blacklist of domains (Search Engines, Social Media) to declutter the "Target Tab" selector in the popup.
|
||||
- **Diagnostics**: A "Dev" tab provides real-time access to the underlying `<video>` state (`readyState`, `paused`, `currentTime`) for easier troubleshooting.
|
||||
|
||||
## 6. Constant Synchronization & Consistency
|
||||
## 7. Constant Synchronization & Consistency
|
||||
To maintain a "Single Source of Truth" across the server and extension without using a bundler:
|
||||
- **Relay Server & Extension Modules**: `background.js` and `popup.js` import constants directly from `shared/constants.js`.
|
||||
- **Content Scripts**: To ensure zero-latency execution, `content.js` uses a manual mirror of `EVENTS`.
|
||||
- **Synchronization**: The `./scripts/sync-constants.sh` script ensures that the `shared/` folder within the `extension/` directory is kept up-to-date with the root `shared/` source.
|
||||
- **Synchronization**: The `node scripts/build-extension.js` script ensures that the `shared/` folder within the `extension/` directory is kept up-to-date with the root `shared/` source while packaging artifacts.
|
||||
- **Verification**: Any protocol change requires a manual verification sweep across all three constant locations (Shared, Server, and Content Script Mirror).
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
KoalaSync is a premium, lightweight Chrome Extension and Relay Server for synchronized video playback across any website (YouTube, Twitch, Netflix, and custom HTML5 players).
|
||||
|
||||
**Latest Version**: `v1.2.0` (Episode Auto-Sync)
|
||||
|
||||
> [!TIP]
|
||||
> **New Developers & AI Agents**: Please read [AI_INIT.md](AI_INIT.md) before starting work.
|
||||
|
||||
@@ -17,6 +19,7 @@ KoalaSync is a premium, lightweight Chrome Extension and Relay Server for synchr
|
||||
|
||||
## Key Features
|
||||
- **Global Synchronization**: Synchronize Play, Pause, and Seeking on any website with a `<video>` tag.
|
||||
- **Episode Auto-Sync**: Perfectly sync series binges. All peers wait until everyone has loaded the next episode before starting together (v1.2.0+).
|
||||
- **Smart Matching**: Automatically highlights and sorts tabs containing matching video titles.
|
||||
- **Noise Filtering**: Built-in domain blacklist to hide non-video sites from selection.
|
||||
- **Smart Identity**: Customizable usernames combined with unique hexadecimal peer IDs.
|
||||
@@ -25,6 +28,7 @@ KoalaSync is a premium, lightweight Chrome Extension and Relay Server for synchr
|
||||
- **Integrated Diagnostics**: A dedicated "Dev" tab for real-time video state debugging.
|
||||
- **Seamless Invitations**: Smart invitation links that automatically configure the server and room credentials for your friends.
|
||||
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Relay Server (Docker)
|
||||
@@ -37,9 +41,9 @@ docker-compose up -d --build
|
||||
The server will be available at `ws://localhost:3000`.
|
||||
|
||||
### 2. Chrome Extension
|
||||
1. **Synchronize Protocol**: From the root directory, run the sync script to copy the master constants to the extension folder:
|
||||
1. **Synchronize Protocol**: From the root directory, run the build script to copy the master constants and prepare the extension:
|
||||
```bash
|
||||
./scripts/sync-constants.sh
|
||||
node scripts/build-extension.js
|
||||
```
|
||||
2. Open Chrome and go to `chrome://extensions/`.
|
||||
3. Enable **Developer mode** (top right).
|
||||
|
||||
+10
-17
@@ -5,8 +5,8 @@ KoalaSync uses a "Single Source of Truth" for its communication protocol constan
|
||||
|
||||
To ensure that the extension and the relay server are always using the exact same event names and protocol versions, we maintain a mirrored copy of the shared files within the `extension/shared/` folder.
|
||||
|
||||
## When should you run the sync script?
|
||||
You MUST run the synchronization script in any of the following scenarios:
|
||||
## When should you run the build script?
|
||||
You MUST run the build script in any of the following scenarios:
|
||||
1. **After a fresh `git clone` or `git pull`** (as the synced files are ignored by git).
|
||||
2. **After modifying** `shared/constants.js`.
|
||||
3. **After modifying** `shared/blacklist.js`.
|
||||
@@ -15,29 +15,22 @@ You MUST run the synchronization script in any of the following scenarios:
|
||||
|
||||
## How to sync
|
||||
|
||||
### On Windows
|
||||
Run the batch script from the repository root:
|
||||
```powershell
|
||||
.\scripts\sync-constants.bat
|
||||
```
|
||||
|
||||
### On macOS / Linux
|
||||
Run the shell script from the repository root:
|
||||
Run the Node.js build script from the repository root:
|
||||
```bash
|
||||
./scripts/sync-constants.sh
|
||||
node scripts/build-extension.js
|
||||
```
|
||||
|
||||
## What does it do?
|
||||
The script performs the following actions:
|
||||
1. Ensures the `extension/shared/` directory exists.
|
||||
2. Copies `shared/constants.js` to `extension/shared/constants.js`.
|
||||
3. Copies `shared/blacklist.js` to `extension/shared/blacklist.js`.
|
||||
The build script performs the following actions:
|
||||
1. Synchronizes protocol constants by copying `shared/constants.js` and `shared/blacklist.js` into `extension/shared/`.
|
||||
2. Compiles browser-specific manifest files.
|
||||
3. Packages the final ready-to-publish extension artifacts for Chrome and Firefox into the `dist/` directory.
|
||||
|
||||
## Protocol Versioning
|
||||
As of v1.0.0-RC5, the system enforces a strict `protocolVersion` check during the `JOIN_ROOM` handshake.
|
||||
- The version is defined in `shared/constants.js`.
|
||||
- If the extension and server versions mismatch, the server will reject the connection with an `Incompatible protocol version` error.
|
||||
- **Always run the sync script** after bumping the version number to ensure both components are updated.
|
||||
- **Always run the build script** after bumping the version number to ensure both components are updated.
|
||||
|
||||
> [!CAUTION]
|
||||
> **NEVER** edit the files inside `extension/shared/` directly. They will be overwritten the next time the sync script is run. Always edit the files in the root `shared/` directory and then run the sync script.
|
||||
> **NEVER** edit the files inside `extension/shared/` directly. They will be overwritten the next time the build script is run. Always edit the files in the root `shared/` directory and then run the build script.
|
||||
|
||||
+312
-56
@@ -1,4 +1,4 @@
|
||||
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, APP_VERSION } from './shared/constants.js';
|
||||
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, APP_VERSION, EPISODE_LOBBY_TIMEOUT } from './shared/constants.js';
|
||||
|
||||
// --- State Management ---
|
||||
let socket = null;
|
||||
@@ -8,7 +8,6 @@ let isConnecting = false;
|
||||
let peerId = null; // initialized via getPeerId()
|
||||
let currentRoom = null;
|
||||
let lastPeersJson = null;
|
||||
let heartbeatInterval = null;
|
||||
let currentTabId = null;
|
||||
let currentTabTitle = null; // New: for Smart Matching
|
||||
let logs = [];
|
||||
@@ -30,13 +29,14 @@ function ensureState() {
|
||||
chrome.storage.session.get([
|
||||
'logs', 'history', 'currentRoom', 'lastActionState',
|
||||
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
|
||||
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'currentTabId', 'currentTabTitle'
|
||||
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'currentTabId', 'currentTabTitle',
|
||||
'episodeLobby'
|
||||
], (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);
|
||||
if (data.logs) logs = [...logs, ...data.logs].slice(0, 200);
|
||||
if (data.history) history = [...history, ...data.history].slice(0, 20);
|
||||
if (data.currentRoom) currentRoom = data.currentRoom;
|
||||
if (data.lastActionState) lastActionState = data.lastActionState;
|
||||
@@ -67,11 +67,22 @@ function ensureState() {
|
||||
}
|
||||
}
|
||||
|
||||
// Recover Episode Lobby
|
||||
if (data.episodeLobby && !episodeLobby) {
|
||||
episodeLobby = data.episodeLobby;
|
||||
const lobbyRemaining = (episodeLobby.createdAt + EPISODE_LOBBY_TIMEOUT) - Date.now();
|
||||
if (lobbyRemaining > 0) {
|
||||
episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout'), lobbyRemaining);
|
||||
} else {
|
||||
cancelEpisodeLobby('Timeout (recovered)');
|
||||
}
|
||||
}
|
||||
|
||||
storageInitialized = true;
|
||||
|
||||
// Process any early logs/history that weren't captured in the spread
|
||||
if (pendingLogs.length > 0) {
|
||||
logs = [...pendingLogs, ...logs].slice(0, 50);
|
||||
logs = [...pendingLogs, ...logs].slice(0, 200);
|
||||
chrome.storage.session.set({ logs });
|
||||
pendingLogs = [];
|
||||
}
|
||||
@@ -100,17 +111,30 @@ let isForceSyncInitiator = false;
|
||||
let forceSyncAcks = new Set();
|
||||
let forceSyncTimeout = null;
|
||||
|
||||
// --- Storage Utils ---
|
||||
function startHeartbeat() {
|
||||
// Session heartbeats are now handled by the chrome.alarms 'keepAlive' listener
|
||||
// to ensure they survive Service Worker suspension in MV3.
|
||||
}
|
||||
// Episode Auto-Sync Lobby
|
||||
let episodeLobby = null; // { expectedTitle, initiatorPeerId, readyPeers: [], createdAt }
|
||||
let episodeLobbyTimeout = null;
|
||||
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
// --- Storage Utils ---
|
||||
|
||||
/**
|
||||
* Canonical peer data factory. All peer object construction must go through
|
||||
* here to guarantee a consistent shape with predictable null defaults.
|
||||
* @param {object} raw - Raw data from server event or heartbeat payload.
|
||||
* @returns {object} Normalized peer data object.
|
||||
*/
|
||||
function createPeerData(raw) {
|
||||
return {
|
||||
peerId: raw.peerId || null,
|
||||
username: raw.username || null,
|
||||
tabTitle: raw.tabTitle || null,
|
||||
mediaTitle: raw.mediaTitle || null,
|
||||
playbackState: raw.playbackState || null,
|
||||
currentTime: raw.currentTime != null ? raw.currentTime : null,
|
||||
volume: raw.volume != null ? raw.volume : null,
|
||||
muted: raw.muted != null ? raw.muted : null,
|
||||
lastHeartbeat: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
async function getPeerId() {
|
||||
@@ -145,7 +169,7 @@ function addLog(message, type = 'info') {
|
||||
pendingLogs.unshift(log);
|
||||
} else {
|
||||
logs.unshift(log);
|
||||
if (logs.length > 50) logs.pop();
|
||||
if (logs.length > 200) logs.pop();
|
||||
chrome.storage.session.set({ logs });
|
||||
}
|
||||
chrome.runtime.sendMessage({ type: 'LOG_UPDATE', log }).catch(() => {});
|
||||
@@ -418,17 +442,13 @@ function addToHistory(action, senderId) {
|
||||
|
||||
// --- Event Handlers ---
|
||||
function handleServerEvent(event, data) {
|
||||
|
||||
switch (event) {
|
||||
case EVENTS.ROOM_DATA:
|
||||
currentRoom = data;
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
addLog(`Joined Room: ${data.roomId}`, 'success');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: data.peers }).catch(() => {});
|
||||
|
||||
// Start background heartbeat
|
||||
startHeartbeat();
|
||||
|
||||
|
||||
// Inform Website Bridge & Popup
|
||||
const joinStatusMsg = { type: 'JOIN_STATUS', success: true, message: 'Joined' };
|
||||
chrome.runtime.sendMessage(joinStatusMsg).catch(() => {});
|
||||
@@ -519,19 +539,30 @@ 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,
|
||||
mediaTitle: data.mediaTitle || null
|
||||
});
|
||||
currentRoom.peers.push(createPeerData(data));
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
|
||||
if (episodeLobby && episodeLobby.initiatorPeerId === peerId) {
|
||||
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle: episodeLobby.expectedTitle });
|
||||
}
|
||||
}
|
||||
} else if (data.status === 'left') {
|
||||
currentRoom.peers = currentRoom.peers.filter(p => (p.peerId || p) !== data.peerId);
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
|
||||
// Episode Lobby: Handle peer departure
|
||||
if (episodeLobby) {
|
||||
checkEpisodeLobbyPeerDeparture();
|
||||
}
|
||||
|
||||
if (isForceSyncInitiator) {
|
||||
const peerCount = currentRoom.peers ? currentRoom.peers.length : 1;
|
||||
if (forceSyncAcks.size >= peerCount) {
|
||||
executeForceSync();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Heartbeat/Update: Update tabTitle for matching
|
||||
const peer = currentRoom.peers.find(p => (p.peerId || p) === data.peerId);
|
||||
@@ -540,15 +571,15 @@ function handleServerEvent(event, data) {
|
||||
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
|
||||
// Migration: replace string peer with normalized object
|
||||
const idx = currentRoom.peers.indexOf(peer);
|
||||
currentRoom.peers[idx] = {
|
||||
peerId: data.peerId,
|
||||
username: data.username,
|
||||
tabTitle: data.tabTitle,
|
||||
mediaTitle: data.mediaTitle || null
|
||||
};
|
||||
currentRoom.peers[idx] = createPeerData(data);
|
||||
}
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
@@ -556,6 +587,51 @@ function handleServerEvent(event, data) {
|
||||
}
|
||||
}
|
||||
break;
|
||||
case EVENTS.EPISODE_LOBBY:
|
||||
if (data.senderId && data.expectedTitle) {
|
||||
addLog(`Episode lobby from ${data.senderId}: "${data.expectedTitle}"`, 'info');
|
||||
// If we already have a lobby for this same title, treat as dedup
|
||||
if (episodeLobby && episodeLobby.expectedTitle === data.expectedTitle) {
|
||||
break; // Already tracking this lobby
|
||||
}
|
||||
// Cancel any existing lobby before starting a new one
|
||||
if (episodeLobby) clearEpisodeLobbyState();
|
||||
|
||||
episodeLobby = {
|
||||
expectedTitle: data.expectedTitle,
|
||||
initiatorPeerId: data.senderId,
|
||||
readyPeers: [],
|
||||
createdAt: Date.now()
|
||||
};
|
||||
persistEpisodeLobby();
|
||||
broadcastLobbyUpdate();
|
||||
|
||||
// Start timeout
|
||||
episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout'), EPISODE_LOBBY_TIMEOUT);
|
||||
|
||||
// Forward to content script to start polling
|
||||
if (currentTabId) {
|
||||
const tabId = parseInt(currentTabId);
|
||||
if (!isNaN(tabId)) {
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
type: 'EPISODE_LOBBY',
|
||||
expectedTitle: data.expectedTitle
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case EVENTS.EPISODE_READY:
|
||||
if (episodeLobby && data.senderId) {
|
||||
if (!episodeLobby.readyPeers.includes(data.senderId)) {
|
||||
episodeLobby.readyPeers.push(data.senderId);
|
||||
persistEpisodeLobby();
|
||||
broadcastLobbyUpdate();
|
||||
addLog(`Episode ready from ${data.senderId} (${episodeLobby.readyPeers.length})`, 'info');
|
||||
checkEpisodeLobbyCompletion();
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
addLog(`Received unknown event from server: ${event}`, 'warn');
|
||||
break;
|
||||
@@ -576,6 +652,102 @@ function executeForceSync() {
|
||||
addLog('Force Sync Executed', 'success');
|
||||
}
|
||||
|
||||
// --- Episode Auto-Sync Lobby Functions ---
|
||||
function persistEpisodeLobby() {
|
||||
if (storageInitialized) chrome.storage.session.set({ episodeLobby });
|
||||
}
|
||||
|
||||
function broadcastLobbyUpdate() {
|
||||
chrome.runtime.sendMessage({ type: 'LOBBY_UPDATE', lobby: episodeLobby }).catch(() => {});
|
||||
}
|
||||
|
||||
function clearEpisodeLobbyState() {
|
||||
if (episodeLobbyTimeout) clearTimeout(episodeLobbyTimeout);
|
||||
episodeLobbyTimeout = null;
|
||||
episodeLobby = null;
|
||||
if (storageInitialized) chrome.storage.session.set({ episodeLobby: null });
|
||||
broadcastLobbyUpdate();
|
||||
|
||||
// Notify content script to stop polling
|
||||
if (currentTabId) {
|
||||
const tabId = parseInt(currentTabId);
|
||||
if (!isNaN(tabId)) {
|
||||
chrome.tabs.sendMessage(tabId, { type: 'EPISODE_LOBBY_CANCEL' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEpisodeLobby(reason) {
|
||||
if (!episodeLobby) return;
|
||||
const title = episodeLobby.expectedTitle;
|
||||
clearEpisodeLobbyState();
|
||||
addLog(`Episode lobby cancelled: ${reason} for "${title}"`, 'warn');
|
||||
|
||||
// Chrome notification on failure (per Q2: only notify on failure)
|
||||
chrome.notifications.create(`episode_${Date.now()}`, {
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon128.png',
|
||||
title: 'KoalaSync — Episode Sync Failed',
|
||||
message: `Auto-sync cancelled: ${reason}. You may need to manually sync.`,
|
||||
priority: 1
|
||||
});
|
||||
}
|
||||
|
||||
function executeEpisodeLobby() {
|
||||
if (!episodeLobby) return;
|
||||
const title = episodeLobby.expectedTitle;
|
||||
clearEpisodeLobbyState();
|
||||
addLog(`Episode lobby complete: Starting "${title}" via Force Sync`, 'success');
|
||||
|
||||
// Trigger a standard Force Sync at targetTime 0.0
|
||||
isForceSyncInitiator = true;
|
||||
forceSyncAcks.clear();
|
||||
const deadline = Date.now() + 8500;
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: true,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: deadline
|
||||
});
|
||||
|
||||
const syncPayload = { targetTime: 0.0 };
|
||||
emit(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, peerId });
|
||||
routeToContent(EVENTS.FORCE_SYNC_PREPARE, syncPayload);
|
||||
|
||||
forceSyncTimeout = setTimeout(() => {
|
||||
if (isForceSyncInitiator) {
|
||||
addLog('Force Sync (Episode): Timeout waiting for ACKs, executing anyway...', 'warn');
|
||||
executeForceSync();
|
||||
}
|
||||
}, 8500);
|
||||
}
|
||||
|
||||
function checkEpisodeLobbyCompletion() {
|
||||
if (!episodeLobby || !currentRoom) return;
|
||||
const peerCount = currentRoom.peers ? currentRoom.peers.length : 1;
|
||||
if (episodeLobby.readyPeers.length >= peerCount) {
|
||||
executeEpisodeLobby();
|
||||
}
|
||||
}
|
||||
|
||||
function checkEpisodeLobbyPeerDeparture() {
|
||||
if (!episodeLobby || !currentRoom) return;
|
||||
const remainingPeerIds = currentRoom.peers.map(p => typeof p === 'object' ? p.peerId : p);
|
||||
|
||||
// If only we remain, cancel the lobby
|
||||
if (remainingPeerIds.length <= 1) {
|
||||
cancelEpisodeLobby('All other peers left');
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter readyPeers to only include peers still in the room
|
||||
episodeLobby.readyPeers = episodeLobby.readyPeers.filter(id => remainingPeerIds.includes(id));
|
||||
persistEpisodeLobby();
|
||||
broadcastLobbyUpdate();
|
||||
|
||||
// Re-check if all remaining peers are now ready
|
||||
checkEpisodeLobbyCompletion();
|
||||
}
|
||||
|
||||
function updateLastAction(action, senderId, timestamp = Date.now()) {
|
||||
lastActionState = {
|
||||
action,
|
||||
@@ -641,23 +813,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(async () => {
|
||||
await ensureState();
|
||||
// Calling a chrome API keeps the SW alive in MV3 (Chrome 110+)
|
||||
chrome.storage.session.get('keepAlive', () => {});
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
connect();
|
||||
} else if (currentRoom) {
|
||||
// Redundant heartbeat for active SW state
|
||||
const settings = await getSettings();
|
||||
emit(EVENTS.PEER_STATUS, {
|
||||
peerId,
|
||||
status: 'heartbeat',
|
||||
username: settings.username,
|
||||
tabTitle: currentTabTitle
|
||||
});
|
||||
}
|
||||
}, 30000); // every 30s
|
||||
|
||||
|
||||
// --- Extension Message Listeners ---
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
@@ -703,24 +859,29 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
peerId,
|
||||
peers: currentRoom ? currentRoom.peers : [],
|
||||
lastActionState,
|
||||
targetTabId: currentTabId
|
||||
targetTabId: currentTabId,
|
||||
episodeLobby: episodeLobby
|
||||
});
|
||||
} else if (message.type === 'LEAVE_ROOM') {
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
currentRoom = null;
|
||||
currentTabId = null;
|
||||
stopHeartbeat();
|
||||
|
||||
updateBadgeStatus();
|
||||
|
||||
isForceSyncInitiator = false;
|
||||
forceSyncAcks.clear();
|
||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||
|
||||
// Cancel any active episode lobby
|
||||
clearEpisodeLobbyState();
|
||||
|
||||
chrome.storage.session.set({
|
||||
currentRoom: null,
|
||||
isForceSyncInitiator: false,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null
|
||||
forceSyncDeadline: null,
|
||||
episodeLobby: null
|
||||
});
|
||||
addLog('Left Room', 'info');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
@@ -793,7 +954,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
|
||||
isForceSyncInitiator = true;
|
||||
forceSyncAcks.clear();
|
||||
const deadline = Date.now() + 5000;
|
||||
const deadline = Date.now() + 8500;
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: true,
|
||||
forceSyncAcks: [],
|
||||
@@ -808,7 +969,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
addLog('Force Sync: Timeout waiting for ACKs, executing anyway...', 'warn');
|
||||
executeForceSync();
|
||||
}
|
||||
}, 5000);
|
||||
}, 8500);
|
||||
}
|
||||
addToHistory(message.action, 'You');
|
||||
emit(message.action, { ...message.payload, peerId });
|
||||
@@ -878,6 +1039,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -902,6 +1068,96 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
} else if (message.type === 'LOG') {
|
||||
addLog(`[Content] ${message.message}`, message.level || 'info');
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'EPISODE_CHANGED') {
|
||||
// Content script detected an episode transition
|
||||
if (sender.tab) {
|
||||
const senderTabId = sender.tab.id;
|
||||
if (!currentTabId || currentTabId !== senderTabId) {
|
||||
sendResponse({ status: 'ignored_unselected_tab' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newTitle = message.payload && message.payload.newTitle;
|
||||
if (!newTitle) {
|
||||
sendResponse({ status: 'no_title' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check setting
|
||||
const epSettings = await chrome.storage.sync.get(['autoSyncNextEpisode']);
|
||||
if (!epSettings.autoSyncNextEpisode) {
|
||||
addLog(`Episode change detected ("${newTitle}") but Auto-Sync is disabled.`, 'info');
|
||||
sendResponse({ status: 'disabled' });
|
||||
return;
|
||||
}
|
||||
|
||||
// If lobby already exists for this title, just mark self ready
|
||||
if (episodeLobby && episodeLobby.expectedTitle === newTitle) {
|
||||
if (!episodeLobby.readyPeers.includes(peerId)) {
|
||||
episodeLobby.readyPeers.push(peerId);
|
||||
persistEpisodeLobby();
|
||||
broadcastLobbyUpdate();
|
||||
emit(EVENTS.EPISODE_READY, { peerId, title: newTitle });
|
||||
checkEpisodeLobbyCompletion();
|
||||
}
|
||||
sendResponse({ status: 'ready_sent' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any existing lobby for a different episode
|
||||
if (episodeLobby) clearEpisodeLobbyState();
|
||||
|
||||
// Create new lobby
|
||||
episodeLobby = {
|
||||
expectedTitle: newTitle,
|
||||
initiatorPeerId: peerId,
|
||||
readyPeers: [peerId], // We are already ready
|
||||
createdAt: Date.now()
|
||||
};
|
||||
persistEpisodeLobby();
|
||||
broadcastLobbyUpdate();
|
||||
addLog(`Episode lobby created: "${newTitle}"`, 'info');
|
||||
|
||||
// Tell content script to pause the video and start polling
|
||||
// (This is the only place we pause — after confirming the feature is enabled)
|
||||
if (sender.tab && sender.tab.id) {
|
||||
chrome.tabs.sendMessage(sender.tab.id, {
|
||||
type: 'PAUSE_FOR_LOBBY',
|
||||
expectedTitle: newTitle
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Broadcast to room
|
||||
emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle: newTitle });
|
||||
|
||||
// Start timeout (Q1: Option B — cancel on timeout)
|
||||
episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout — not all peers loaded the episode'), EPISODE_LOBBY_TIMEOUT);
|
||||
|
||||
// Immediate check — maybe we're the only one in the room
|
||||
checkEpisodeLobbyCompletion();
|
||||
|
||||
sendResponse({ status: 'lobby_created' });
|
||||
} else if (message.type === 'EPISODE_READY_LOCAL') {
|
||||
// Content script confirmed it loaded the lobby episode
|
||||
if (episodeLobby && message.payload && message.payload.title === episodeLobby.expectedTitle) {
|
||||
if (!episodeLobby.readyPeers.includes(peerId)) {
|
||||
episodeLobby.readyPeers.push(peerId);
|
||||
persistEpisodeLobby();
|
||||
broadcastLobbyUpdate();
|
||||
emit(EVENTS.EPISODE_READY, { peerId, title: message.payload.title });
|
||||
addLog(`Local episode ready: "${message.payload.title}"`, 'success');
|
||||
checkEpisodeLobbyCompletion();
|
||||
}
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'CONTENT_BOOT') {
|
||||
// Content script re-injected, check if there's an active lobby
|
||||
if (episodeLobby) {
|
||||
sendResponse({ lobbyActive: true, expectedTitle: episodeLobby.expectedTitle });
|
||||
} else {
|
||||
sendResponse({ lobbyActive: false });
|
||||
}
|
||||
} else {
|
||||
// Final fallback to prevent channel hanging
|
||||
sendResponse({ error: 'unhandled_message' });
|
||||
|
||||
+214
-3
@@ -22,12 +22,27 @@
|
||||
FORCE_SYNC_PREPARE: "force_sync_prepare",
|
||||
FORCE_SYNC_ACK: "force_sync_ack",
|
||||
FORCE_SYNC_EXECUTE: "force_sync_execute",
|
||||
PEER_STATUS: "peer_status"
|
||||
PEER_STATUS: "peer_status",
|
||||
EPISODE_LOBBY: "episode_lobby",
|
||||
EPISODE_READY: "episode_ready"
|
||||
};
|
||||
|
||||
let expectedEvents = new Set();
|
||||
let expectedTimeouts = {};
|
||||
|
||||
// --- Seek Relay Filtering ---
|
||||
// Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks
|
||||
// from being relayed to peers as user-initiated seeks.
|
||||
const MIN_SEEK_DELTA = 3.0;
|
||||
let lastReportedSeekTime = null; // last currentTime we relayed as a SEEK
|
||||
let seekDebounceTimer = null; // debounce timer for rapid seek events
|
||||
|
||||
// --- Episode Auto-Sync State ---
|
||||
let lastKnownMediaTitle = null;
|
||||
let episodeTransitionDebounce = null;
|
||||
let pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby)
|
||||
let lobbyPollTimer = null;
|
||||
|
||||
function expectEvent(state) {
|
||||
expectedEvents.add(state);
|
||||
if (expectedTimeouts[state]) clearTimeout(expectedTimeouts[state]);
|
||||
@@ -47,6 +62,100 @@
|
||||
return videos.length > 0 ? videos[0] : null;
|
||||
}
|
||||
|
||||
// --- Episode Auto-Sync: Detection ---
|
||||
function getMediaTitle() {
|
||||
return (navigator.mediaSession && navigator.mediaSession.metadata)
|
||||
? navigator.mediaSession.metadata.title
|
||||
: null;
|
||||
}
|
||||
|
||||
function checkEpisodeTransition() {
|
||||
const currentTitle = getMediaTitle();
|
||||
const video = findVideo();
|
||||
|
||||
// Only trigger if: we had a previous title, the title changed,
|
||||
// a video exists, and we're near the start of new content.
|
||||
if (lastKnownMediaTitle && currentTitle
|
||||
&& currentTitle !== lastKnownMediaTitle
|
||||
&& video
|
||||
&& video.currentTime < 5
|
||||
&& video.readyState >= 1) {
|
||||
onEpisodeTransition(currentTitle);
|
||||
}
|
||||
|
||||
// Always track the latest known title
|
||||
if (currentTitle) lastKnownMediaTitle = currentTitle;
|
||||
}
|
||||
|
||||
function onEpisodeTransition(newTitle) {
|
||||
// Debounce: prevent duplicate fires from multiple signals
|
||||
if (episodeTransitionDebounce) return;
|
||||
episodeTransitionDebounce = setTimeout(() => {
|
||||
episodeTransitionDebounce = null;
|
||||
}, 2000);
|
||||
|
||||
reportLog(`Episode transition detected: "${newTitle}"`, 'info');
|
||||
|
||||
// Do NOT pause here. We notify background.js first.
|
||||
// Background checks the setting; if enabled it creates a lobby
|
||||
// and sends back PAUSE_FOR_LOBBY so we only freeze if the feature is on.
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'EPISODE_CHANGED',
|
||||
payload: { newTitle }
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function checkAndReportLobbyReady(expectedTitle) {
|
||||
const video = findVideo();
|
||||
const currentTitle = getMediaTitle();
|
||||
|
||||
if (video && currentTitle && currentTitle === expectedTitle
|
||||
&& video.currentTime < 5 && video.readyState >= 1) {
|
||||
// Match! Pause at start and report ready.
|
||||
if (!video.paused) {
|
||||
expectEvent('paused');
|
||||
video.pause();
|
||||
}
|
||||
stopLobbyPoll();
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'EPISODE_READY_LOCAL',
|
||||
payload: { title: currentTitle }
|
||||
}).catch(() => {});
|
||||
reportLog(`Episode lobby: Ready for "${currentTitle}"`, 'success');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function startLobbyPoll(expectedTitle) {
|
||||
stopLobbyPoll();
|
||||
pendingLobbyTitle = expectedTitle;
|
||||
|
||||
// NOTE: Do NOT pause here. Three callers reach this function:
|
||||
// 1. PAUSE_FOR_LOBBY (initiator): already paused by that handler before calling us.
|
||||
// 2. EPISODE_LOBBY (non-initiator): peer may still be on the PREVIOUS episode — pausing
|
||||
// would freeze them mid-episode. The pause happens inside checkAndReportLobbyReady()
|
||||
// only once their title actually matches.
|
||||
// 3. CONTENT_BOOT recovery: same reasoning as (2).
|
||||
|
||||
// Check immediately
|
||||
if (checkAndReportLobbyReady(expectedTitle)) return;
|
||||
|
||||
// Poll every 2 seconds — no log spam, internal only
|
||||
lobbyPollTimer = setInterval(() => {
|
||||
checkAndReportLobbyReady(expectedTitle);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
|
||||
function stopLobbyPoll() {
|
||||
pendingLobbyTitle = null;
|
||||
if (lobbyPollTimer) {
|
||||
clearInterval(lobbyPollTimer);
|
||||
lobbyPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper: YouTube/Twitch specific actions ---
|
||||
function tryMediaAction(action, data) {
|
||||
const video = findVideo();
|
||||
@@ -181,11 +290,45 @@
|
||||
});
|
||||
}
|
||||
} else if (action === EVENTS.FORCE_SYNC_EXECUTE) {
|
||||
stopLobbyPoll(); // Clear any pending lobby on force sync
|
||||
tryMediaAction(EVENTS.PLAY);
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
|
||||
}
|
||||
}
|
||||
|
||||
// Episode Auto-Sync: Lobby notification from background
|
||||
if (message.type === 'EPISODE_LOBBY') {
|
||||
const expectedTitle = message.expectedTitle;
|
||||
if (expectedTitle) {
|
||||
reportLog(`Episode lobby received: waiting for "${expectedTitle}"`, 'info');
|
||||
startLobbyPoll(expectedTitle);
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Episode Auto-Sync: Lobby cancelled by background
|
||||
if (message.type === 'EPISODE_LOBBY_CANCEL') {
|
||||
stopLobbyPoll();
|
||||
sendResponse({ status: 'ok' });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Episode Auto-Sync: Background confirmed lobby created, pause the video
|
||||
if (message.type === 'PAUSE_FOR_LOBBY') {
|
||||
const video = findVideo();
|
||||
if (video && !video.paused) {
|
||||
expectEvent('paused');
|
||||
video.pause();
|
||||
}
|
||||
// Start lobby poll now that we know the feature is enabled
|
||||
if (message.expectedTitle) {
|
||||
startLobbyPoll(message.expectedTitle);
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'GET_VIDEO_STATE') {
|
||||
const video = findVideo();
|
||||
if (video) {
|
||||
@@ -211,6 +354,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',
|
||||
@@ -257,22 +401,75 @@
|
||||
|
||||
const handlePlay = () => reportEvent(EVENTS.PLAY);
|
||||
const handlePause = () => reportEvent(EVENTS.PAUSE);
|
||||
const handleSeeked = () => reportEvent(EVENTS.SEEK);
|
||||
|
||||
// Seek filtering: ignore HLS/DASH buffering micro-seeks.
|
||||
// Only relay if delta >= MIN_SEEK_DELTA AND not already debouncing.
|
||||
const handleSeeked = () => {
|
||||
const video = findVideo();
|
||||
if (!video) return;
|
||||
const current = video.currentTime;
|
||||
if (!Number.isFinite(current)) return;
|
||||
|
||||
// Step 1: Check expectedEvents (programmatic seek suppression)
|
||||
if (expectedEvents.has('seek')) {
|
||||
expectedEvents.delete('seek');
|
||||
lastReportedSeekTime = current; // Update baseline so next user seek is relative to here
|
||||
// No log — this is routine programmatic behavior (Force Sync, lobby, peer command)
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = lastReportedSeekTime !== null ? Math.abs(current - lastReportedSeekTime) : null;
|
||||
const deltaStr = delta !== null ? `Δ${delta.toFixed(2)}s` : 'Δ?';
|
||||
|
||||
// Step 2: Delta check — skip micro-seeks (buffering, chapter markers, etc.)
|
||||
if (lastReportedSeekTime !== null && delta < MIN_SEEK_DELTA) {
|
||||
reportLog(`[Seek] Filtered (${deltaStr} < ${MIN_SEEK_DELTA}s threshold) @ ${current.toFixed(2)}s — not relayed`, 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Debounce rapid consecutive seeks (e.g. scrubbing)
|
||||
// — wait 800ms for the user to settle before relaying
|
||||
if (seekDebounceTimer) clearTimeout(seekDebounceTimer);
|
||||
seekDebounceTimer = setTimeout(() => {
|
||||
seekDebounceTimer = null;
|
||||
const v = findVideo();
|
||||
if (!v) return;
|
||||
const settled = v.currentTime;
|
||||
const finalDelta = lastReportedSeekTime !== null ? Math.abs(settled - lastReportedSeekTime) : null;
|
||||
const finalDeltaStr = finalDelta !== null ? `Δ${finalDelta.toFixed(2)}s` : 'Δ?';
|
||||
lastReportedSeekTime = settled;
|
||||
reportLog(`[Seek] Relayed @ ${settled.toFixed(2)}s (${finalDeltaStr})`, 'info');
|
||||
reportEvent(EVENTS.SEEK);
|
||||
}, 800);
|
||||
};
|
||||
|
||||
|
||||
let lastVideoSrc = null;
|
||||
|
||||
// Episode detection handler for loadeddata event
|
||||
const handleLoadedData = () => {
|
||||
checkEpisodeTransition();
|
||||
};
|
||||
|
||||
function setupListeners() {
|
||||
const video = findVideo();
|
||||
if (video) {
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
video.removeEventListener('seeked', handleSeeked);
|
||||
video.removeEventListener('loadeddata', handleLoadedData);
|
||||
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener('pause', handlePause);
|
||||
video.addEventListener('seeked', handleSeeked);
|
||||
video.addEventListener('loadeddata', handleLoadedData);
|
||||
video.dataset.koalaAttached = 'true';
|
||||
lastVideoSrc = video.currentSrc || video.src;
|
||||
|
||||
// Initialize episode tracking title on first attach
|
||||
if (!lastKnownMediaTitle) {
|
||||
lastKnownMediaTitle = getMediaTitle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +485,10 @@
|
||||
const currentSrc = video.currentSrc || video.src;
|
||||
|
||||
if (!video.dataset.koalaAttached || (lastVideoSrc && currentSrc && lastVideoSrc !== currentSrc)) {
|
||||
// If src changed, also check for episode transition
|
||||
if (lastVideoSrc && currentSrc && lastVideoSrc !== currentSrc) {
|
||||
checkEpisodeTransition();
|
||||
}
|
||||
setupListeners();
|
||||
}
|
||||
}
|
||||
@@ -314,7 +515,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')) {
|
||||
@@ -332,4 +535,12 @@
|
||||
// Initial Setup
|
||||
setupListeners();
|
||||
|
||||
// Episode Auto-Sync: Boot recovery — check if background has an active lobby
|
||||
chrome.runtime.sendMessage({ type: 'CONTENT_BOOT' }, (res) => {
|
||||
if (res && res.lobbyActive && res.expectedTitle) {
|
||||
reportLog(`Boot: Active lobby detected for "${res.expectedTitle}"`, 'info');
|
||||
startLobbyPoll(res.expectedTitle);
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "KoalaSync",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.1",
|
||||
"description": "Synchronize video playback across different tabs and users.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
@@ -22,10 +22,6 @@
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["https://koalasync.shik3i.net/*"],
|
||||
+21
-3
@@ -148,9 +148,6 @@
|
||||
}
|
||||
|
||||
.peer-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
@@ -290,6 +287,16 @@
|
||||
<div style="text-align:center; color: var(--text-muted); font-size: 11px; padding-top: 20px;">No recent commands</div>
|
||||
</div>
|
||||
|
||||
<!-- Episode Auto-Sync Lobby Status -->
|
||||
<div id="episodeLobbyCard" class="info-card" style="display:none; margin-bottom: 15px; border-left: 4px solid var(--star); animation: fadeIn 0.3s ease-out;">
|
||||
<div style="display:flex; align-items:center; gap: 6px; margin-bottom: 6px;">
|
||||
<span style="font-size: 16px;">⏳</span>
|
||||
<span style="font-weight: 700; color: var(--star); font-size: 12px;">EPISODE LOBBY</span>
|
||||
</div>
|
||||
<div id="lobbyTitle" style="font-size: 11px; color: var(--text); margin-bottom: 6px; font-weight: 600;"></div>
|
||||
<div id="lobbyPeerStatus" style="font-size: 10px; color: var(--text-muted);"></div>
|
||||
</div>
|
||||
|
||||
<div id="peerListSync" class="info-card" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
@@ -304,10 +311,16 @@
|
||||
<label style="margin-bottom: 0;">Filter Noise Tabs</label>
|
||||
<input type="checkbox" id="filterNoise" style="width: auto;" checked>
|
||||
</div>
|
||||
|
||||
<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; border: 1px solid #334155;">
|
||||
<label style="margin-bottom: 0;">Auto-Sync Next Episode</label>
|
||||
<input type="checkbox" id="autoSyncNextEpisode" style="width: auto;">
|
||||
</div>
|
||||
|
||||
<div style="font-size: 11px; color: var(--text-muted); padding: 8px;">
|
||||
<p>• Username helps others identify you.</p>
|
||||
<p>• Noise filtering uses a blacklist to hide common non-video sites (e.g. Search, Social Media) from the Target Tab selector.</p>
|
||||
<p>• Auto-Sync will pause and wait for all peers when an episode changes, then sync-start together.</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 15px; padding: 8px; border-top: 1px solid var(--card);">
|
||||
@@ -344,6 +357,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>
|
||||
|
||||
+168
-13
@@ -40,7 +40,11 @@ const elements = {
|
||||
peerListSync: document.getElementById('peerListSync'),
|
||||
videoDebug: document.getElementById('videoDebug'),
|
||||
playBtn: document.getElementById('playBtn'),
|
||||
pauseBtn: document.getElementById('pauseBtn')
|
||||
pauseBtn: document.getElementById('pauseBtn'),
|
||||
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
|
||||
episodeLobbyCard: document.getElementById('episodeLobbyCard'),
|
||||
lobbyTitle: document.getElementById('lobbyTitle'),
|
||||
lobbyPeerStatus: document.getElementById('lobbyPeerStatus')
|
||||
};
|
||||
|
||||
let localPeerId = null;
|
||||
@@ -49,12 +53,19 @@ let lastPeersJson = null;
|
||||
// --- Initialization ---
|
||||
async function init() {
|
||||
// Load Settings
|
||||
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username']);
|
||||
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode']);
|
||||
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;
|
||||
elements.autoSyncNextEpisode.checked = !!data.autoSyncNextEpisode;
|
||||
|
||||
// Set Version Info
|
||||
const versionEl = document.getElementById('appVersion');
|
||||
if (versionEl) {
|
||||
versionEl.textContent = `v${chrome.runtime.getManifest().version}`;
|
||||
}
|
||||
|
||||
if (data.useCustomServer) {
|
||||
setServerMode(true);
|
||||
@@ -77,6 +88,9 @@ async function init() {
|
||||
|
||||
// Populate Tabs using the background's targetTabId
|
||||
await populateTabs(res.peers, res.targetTabId);
|
||||
|
||||
// Render lobby status if active
|
||||
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
|
||||
} else {
|
||||
await populateTabs();
|
||||
}
|
||||
@@ -188,11 +202,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;
|
||||
|
||||
@@ -213,10 +272,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) {
|
||||
@@ -235,29 +294,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);
|
||||
});
|
||||
};
|
||||
@@ -536,6 +638,10 @@ elements.filterNoise.addEventListener('change', () => {
|
||||
});
|
||||
});
|
||||
|
||||
elements.autoSyncNextEpisode.addEventListener('change', () => {
|
||||
chrome.storage.sync.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
|
||||
});
|
||||
|
||||
elements.serverUrl.addEventListener('input', () => {
|
||||
chrome.storage.sync.set({ serverUrl: elements.serverUrl.value });
|
||||
});
|
||||
@@ -789,6 +895,15 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
// Join failed: reset UI state
|
||||
updateUI(null, null);
|
||||
}
|
||||
} else if (msg.type === 'LOBBY_UPDATE') {
|
||||
// Episode lobby state changed
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
||||
if (res && res.peers) {
|
||||
updateLobbyUI(msg.lobby, res.peers);
|
||||
} else {
|
||||
updateLobbyUI(msg.lobby, []);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -883,3 +998,43 @@ function refreshDebugInfo() {
|
||||
|
||||
init();
|
||||
setInterval(refreshLogs, 5000);
|
||||
|
||||
// --- Episode Lobby UI ---
|
||||
function updateLobbyUI(lobby, peers) {
|
||||
if (!elements.episodeLobbyCard) return;
|
||||
|
||||
if (!lobby) {
|
||||
elements.episodeLobbyCard.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
elements.episodeLobbyCard.style.display = 'block';
|
||||
elements.lobbyTitle.textContent = `\u{1F3AC} Waiting for: "${lobby.expectedTitle}"`;
|
||||
|
||||
// Build peer readiness list
|
||||
const readySet = new Set(lobby.readyPeers || []);
|
||||
const peerLines = [];
|
||||
|
||||
if (peers && peers.length > 0) {
|
||||
peers.forEach(p => {
|
||||
const pId = typeof p === 'object' ? p.peerId : p;
|
||||
const pName = (typeof p === 'object' && p.username) ? p.username : pId;
|
||||
const isReady = readySet.has(pId);
|
||||
const icon = isReady ? '\u2705' : '\u23f3';
|
||||
const label = isReady ? 'Ready' : 'Loading...';
|
||||
peerLines.push(`${icon} ${pName} \u2014 ${label}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (peerLines.length > 0) {
|
||||
elements.lobbyPeerStatus.textContent = peerLines.join(' | ');
|
||||
} else {
|
||||
elements.lobbyPeerStatus.textContent = 'Waiting for peers...';
|
||||
}
|
||||
|
||||
// Show elapsed time
|
||||
if (lobby.createdAt) {
|
||||
const elapsed = Math.floor((Date.now() - lobby.createdAt) / 1000);
|
||||
elements.lobbyPeerStatus.textContent += ` (${elapsed}s)`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "1.2.1",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:extension": "node scripts/build-extension.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"fs-extra": "^11.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const archiver = require('archiver');
|
||||
|
||||
const rootDir = path.join(__dirname, '..');
|
||||
const extDir = path.join(rootDir, 'extension');
|
||||
const distDir = path.join(rootDir, 'dist');
|
||||
const baseManifestPath = path.join(extDir, 'manifest.base.json');
|
||||
|
||||
// Ensure dist directory exists
|
||||
if (fs.existsSync(distDir)) {
|
||||
fs.rmSync(distDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
|
||||
// Sync shared constants from root /shared to /extension/shared
|
||||
console.log('Syncing protocol constants...');
|
||||
const masterSharedDir = path.join(rootDir, 'shared');
|
||||
const extSharedDir = path.join(extDir, 'shared');
|
||||
|
||||
if (!fs.existsSync(extSharedDir)) {
|
||||
fs.mkdirSync(extSharedDir, { recursive: true });
|
||||
}
|
||||
|
||||
const sharedFiles = ['constants.js', 'blacklist.js'];
|
||||
for (const file of sharedFiles) {
|
||||
const src = path.join(masterSharedDir, file);
|
||||
const dest = path.join(extSharedDir, file);
|
||||
if (!fs.existsSync(src)) {
|
||||
throw new Error(`CRITICAL: Source shared file missing: ${src}. Aborting build to prevent broken artifacts.`);
|
||||
}
|
||||
fs.copyFileSync(src, dest);
|
||||
}
|
||||
console.log('✓ constants.js and blacklist.js synced to extension/shared/');
|
||||
|
||||
// Read the base manifest
|
||||
const baseManifest = JSON.parse(fs.readFileSync(baseManifestPath, 'utf8'));
|
||||
|
||||
// Helper to copy files, ignoring manifest.json and manifest.base.json
|
||||
function copyExtensionFiles(targetDir) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
const items = fs.readdirSync(extDir);
|
||||
for (const item of items) {
|
||||
if (item === 'manifest.json' || item === 'manifest.base.json') continue;
|
||||
const srcPath = path.join(extDir, item);
|
||||
const destPath = path.join(targetDir, item);
|
||||
if (fs.lstatSync(srcPath).isDirectory()) {
|
||||
fs.cpSync(srcPath, destPath, { recursive: true });
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to zip a directory
|
||||
function zipDirectory(sourceDir, outPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
const stream = fs.createWriteStream(outPath);
|
||||
|
||||
archive
|
||||
.directory(sourceDir, false)
|
||||
.on('error', err => reject(err))
|
||||
.pipe(stream);
|
||||
|
||||
stream.on('close', () => resolve());
|
||||
archive.finalize();
|
||||
});
|
||||
}
|
||||
|
||||
async function buildBrowser(browserName, manifestModifier) {
|
||||
console.log(`Building for ${browserName}...`);
|
||||
const browserDistDir = path.join(distDir, browserName);
|
||||
|
||||
// 1. Copy files
|
||||
copyExtensionFiles(browserDistDir);
|
||||
|
||||
// 2. Modify and write manifest
|
||||
const browserManifest = manifestModifier(JSON.parse(JSON.stringify(baseManifest)));
|
||||
fs.writeFileSync(
|
||||
path.join(browserDistDir, 'manifest.json'),
|
||||
JSON.stringify(browserManifest, null, 2)
|
||||
);
|
||||
|
||||
// 3. Zip it
|
||||
const zipPath = path.join(distDir, `koalasync-${browserName}.zip`);
|
||||
await zipDirectory(browserDistDir, zipPath);
|
||||
console.log(`Successfully built and zipped ${browserName} -> ${zipPath}`);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
// Build Chrome
|
||||
await buildBrowser('chrome', (manifest) => {
|
||||
manifest.background = {
|
||||
service_worker: "background.js",
|
||||
type: "module"
|
||||
};
|
||||
return manifest;
|
||||
});
|
||||
|
||||
// Build Firefox
|
||||
await buildBrowser('firefox', (manifest) => {
|
||||
manifest.background = {
|
||||
scripts: ["background.js"],
|
||||
type: "module"
|
||||
};
|
||||
manifest.browser_specific_settings = {
|
||||
gecko: {
|
||||
id: "koalasync@shik3i.net"
|
||||
}
|
||||
};
|
||||
return manifest;
|
||||
});
|
||||
|
||||
console.log('Build complete!');
|
||||
} catch (error) {
|
||||
console.error('Build failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -1,12 +0,0 @@
|
||||
@echo off
|
||||
REM KoalaSync - Protocol Synchronization Script (Windows)
|
||||
REM
|
||||
REM This script copies the master constants.js file from the shared directory
|
||||
REM to the extension directory. Since Chrome Extensions cannot load files
|
||||
REM outside their root, this manual sync is required after any changes to
|
||||
REM the shared protocol.
|
||||
|
||||
if not exist extension\shared mkdir extension\shared
|
||||
copy /y shared\constants.js extension\shared\constants.js
|
||||
copy /y shared\blacklist.js extension\shared\blacklist.js
|
||||
echo ✓ constants.js and blacklist.js synced to extension\shared\
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/bin/sh
|
||||
# KoalaSync - Protocol Synchronization Script (Linux/macOS)
|
||||
#
|
||||
# This script copies the master constants.js file from the shared directory
|
||||
# to the extension directory. Since Chrome Extensions cannot load files
|
||||
# outside their root, this manual sync is required after any changes to
|
||||
# the shared protocol.
|
||||
|
||||
mkdir -p extension/shared
|
||||
cp shared/constants.js extension/shared/constants.js
|
||||
cp shared/blacklist.js extension/shared/blacklist.js
|
||||
echo "✓ constants.js and blacklist.js synced to extension/shared/"
|
||||
+108
-65
@@ -23,7 +23,7 @@ const httpServer = createServer(app);
|
||||
// Socket.IO setup with security constraints
|
||||
const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: "*",
|
||||
origin: ["https://koalasync.shik3i.net"],
|
||||
methods: ["GET", "POST"]
|
||||
},
|
||||
maxHttpBufferSize: 1024, // 1KB max per message
|
||||
@@ -113,6 +113,51 @@ function checkEventRate(socketId) {
|
||||
return entry.count <= 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central peer teardown. Removes a socket from all room state and notifies
|
||||
* remaining peers. Call this from every disconnect/leave/reaper/dedupe path.
|
||||
*
|
||||
* @param {string} socketId - The socket.id being removed.
|
||||
* @param {string} roomId - The room it belongs to.
|
||||
* @param {string} reason - Log label ('disconnect', 'leave', 'reaper', 'dedupe', 'room-switch').
|
||||
* @param {boolean} [emitLeave=true] - Set false when the socket.io room leave
|
||||
* is handled by the caller (e.g. reaper calls
|
||||
* socket.leave() before us, or dedupe calls
|
||||
* oldSocket.leave() before disconnecting).
|
||||
*/
|
||||
function removePeerFromRoom(socketId, roomId, reason, emitLeave = true) {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return;
|
||||
|
||||
const peerData = room.peerData.get(socketId);
|
||||
if (!peerData) return; // Already cleaned up
|
||||
|
||||
const { peerId } = peerData;
|
||||
|
||||
// 1. Remove from room data structures
|
||||
room.peers.delete(socketId);
|
||||
room.peerIds.delete(socketId);
|
||||
room.peerData.delete(socketId);
|
||||
|
||||
// 2. Remove from global maps
|
||||
socketToRoom.delete(socketId);
|
||||
if (peerToSocket.get(peerId) === socketId) {
|
||||
peerToSocket.delete(peerId);
|
||||
}
|
||||
|
||||
// 3. Notify remaining peers (use io.to so the removed socket itself
|
||||
// doesn't receive it — it has already left or is disconnecting)
|
||||
io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
|
||||
|
||||
// 4. Delete empty room
|
||||
if (room.peers.size === 0) {
|
||||
rooms.delete(roomId);
|
||||
log('ROOM', `Deleted empty room after ${reason}: ${roomId.substring(0, 3)}***`);
|
||||
}
|
||||
|
||||
log('ROOM', `Peer ${peerId} removed (${reason}) from room ${roomId.substring(0, 3)}***`);
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const clientIp = socket.handshake.address;
|
||||
|
||||
@@ -155,7 +200,16 @@ io.on('connection', (socket) => {
|
||||
return;
|
||||
}
|
||||
if (!payload || typeof payload.roomId !== 'string') return;
|
||||
const { roomId, password, peerId, username, tabTitle, mediaTitle, protocolVersion } = payload;
|
||||
const { password, peerId, protocolVersion } = payload;
|
||||
|
||||
// --- M-2: Sanitize and clamp all string fields ---
|
||||
const roomId = String(payload.roomId || '').substring(0, 64);
|
||||
const username = typeof payload.username === 'string' ? payload.username.substring(0, 30) : null;
|
||||
const tabTitle = typeof payload.tabTitle === 'string' ? payload.tabTitle.substring(0, 100) : null;
|
||||
const mediaTitle = typeof payload.mediaTitle === 'string' ? payload.mediaTitle.substring(0, 100) : null;
|
||||
|
||||
if (!roomId) return; // Guard: empty after sanitization
|
||||
|
||||
try {
|
||||
// Protocol check
|
||||
if (protocolVersion !== '1.0.0') {
|
||||
@@ -171,14 +225,7 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
if (oldMapping && oldMapping.roomId !== roomId) {
|
||||
socket.leave(oldMapping.roomId);
|
||||
const oldRoom = rooms.get(oldMapping.roomId);
|
||||
if (oldRoom) {
|
||||
oldRoom.peers.delete(socket.id);
|
||||
oldRoom.peerIds.delete(socket.id);
|
||||
oldRoom.peerData.delete(socket.id);
|
||||
socket.to(oldMapping.roomId).emit(EVENTS.PEER_STATUS, { peerId: oldMapping.peerId, status: 'left' });
|
||||
if (oldRoom.peers.size === 0) rooms.delete(oldMapping.roomId);
|
||||
}
|
||||
removePeerFromRoom(socket.id, oldMapping.roomId, 'room-switch');
|
||||
}
|
||||
|
||||
const ip = socket.handshake.address;
|
||||
@@ -228,11 +275,7 @@ io.on('connection', (socket) => {
|
||||
oldSocket.disconnect(true);
|
||||
log('DEDUPE', `Kicked old session for peer ${peerId}`);
|
||||
}
|
||||
room.peers.delete(sid);
|
||||
room.peerIds.delete(sid);
|
||||
room.peerData.delete(sid);
|
||||
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId: data.peerId, status: 'left' });
|
||||
log('ROOM', `Deduplicated peer ${peerId} from room ${roomId}`);
|
||||
removePeerFromRoom(sid, roomId, 'dedupe');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,7 +309,8 @@ io.on('connection', (socket) => {
|
||||
const relayEvents = [
|
||||
EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK,
|
||||
EVENTS.PEER_STATUS, EVENTS.FORCE_SYNC_PREPARE,
|
||||
EVENTS.FORCE_SYNC_ACK, EVENTS.FORCE_SYNC_EXECUTE
|
||||
EVENTS.FORCE_SYNC_ACK, EVENTS.FORCE_SYNC_EXECUTE,
|
||||
EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_READY
|
||||
];
|
||||
|
||||
relayEvents.forEach(eventName => {
|
||||
@@ -286,12 +330,19 @@ io.on('connection', (socket) => {
|
||||
room.lastActivity = Date.now();
|
||||
|
||||
// Update peer metadata and lastSeen
|
||||
// Sanitize mutable string fields to enforce the same length
|
||||
// limits as JOIN_ROOM — the relay path is otherwise unbounded.
|
||||
const clamp = (val, max) => typeof val === 'string' ? val.substring(0, max) : val;
|
||||
const existing = room.peerData.get(socket.id) || { peerId: mapping.peerId };
|
||||
room.peerData.set(socket.id, {
|
||||
...existing,
|
||||
username: data.username !== undefined ? data.username : existing.username,
|
||||
tabTitle: data.tabTitle !== undefined ? data.tabTitle : existing.tabTitle,
|
||||
mediaTitle: data.mediaTitle !== undefined ? data.mediaTitle : existing.mediaTitle,
|
||||
username: data.username !== undefined ? clamp(data.username, 30) : existing.username,
|
||||
tabTitle: data.tabTitle !== undefined ? clamp(data.tabTitle, 100) : existing.tabTitle,
|
||||
mediaTitle: data.mediaTitle !== undefined ? clamp(data.mediaTitle, 100) : 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()
|
||||
});
|
||||
|
||||
@@ -313,27 +364,13 @@ io.on('connection', (socket) => {
|
||||
socket.on(EVENTS.LEAVE_ROOM, () => {
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (mapping) {
|
||||
const { roomId, peerId } = mapping;
|
||||
socket.leave(roomId);
|
||||
const room = rooms.get(roomId);
|
||||
if (room) {
|
||||
room.peers.delete(socket.id);
|
||||
room.peerIds.delete(socket.id);
|
||||
room.peerData.delete(socket.id);
|
||||
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
|
||||
if (room.peers.size === 0) {
|
||||
rooms.delete(roomId);
|
||||
log('ROOM', `Deleted empty room: ${roomId.substring(0, 3)}***`);
|
||||
}
|
||||
}
|
||||
socketToRoom.delete(socket.id);
|
||||
if (peerToSocket.get(peerId) === socket.id) {
|
||||
peerToSocket.delete(peerId);
|
||||
}
|
||||
socket.leave(mapping.roomId);
|
||||
removePeerFromRoom(socket.id, mapping.roomId, 'leave');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(EVENTS.EVENT_ACK, (data) => {
|
||||
if (!data || typeof data !== 'object') return;
|
||||
if (!data.targetId) return;
|
||||
|
||||
const senderMapping = socketToRoom.get(socket.id);
|
||||
@@ -355,22 +392,10 @@ io.on('connection', (socket) => {
|
||||
eventCounts.delete(socket.id);
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (mapping) {
|
||||
const { roomId, peerId } = mapping;
|
||||
const room = rooms.get(roomId);
|
||||
if (room) {
|
||||
room.peers.delete(socket.id);
|
||||
room.peerIds.delete(socket.id);
|
||||
room.peerData.delete(socket.id);
|
||||
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
|
||||
if (room.peers.size === 0) {
|
||||
rooms.delete(roomId);
|
||||
log('ROOM', `Deleted empty room (after disconnect): ${roomId.substring(0, 3)}***`);
|
||||
}
|
||||
}
|
||||
socketToRoom.delete(socket.id);
|
||||
if (peerToSocket.get(peerId) === socket.id) {
|
||||
peerToSocket.delete(peerId);
|
||||
}
|
||||
// Socket is already disconnected — no need to call socket.leave().
|
||||
// removePeerFromRoom uses io.to() for notifications, which correctly
|
||||
// excludes this dead socket since it has already left all rooms.
|
||||
removePeerFromRoom(socket.id, mapping.roomId, 'disconnect');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -383,23 +408,21 @@ setInterval(() => {
|
||||
|
||||
for (const [roomId, room] of rooms) {
|
||||
// 1. Prune dead peers
|
||||
// Snapshot keys first — we must not mutate peerData while iterating it.
|
||||
const staleSids = [];
|
||||
for (const [sid, data] of room.peerData.entries()) {
|
||||
if (data.lastSeen && data.lastSeen < peerCutoff) {
|
||||
const socket = io.sockets.sockets.get(sid);
|
||||
if (socket) socket.leave(roomId);
|
||||
|
||||
room.peers.delete(sid);
|
||||
room.peerIds.delete(sid);
|
||||
room.peerData.delete(sid);
|
||||
socketToRoom.delete(sid);
|
||||
if (peerToSocket.get(data.peerId) === sid) {
|
||||
peerToSocket.delete(data.peerId);
|
||||
}
|
||||
|
||||
io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId: data.peerId, status: 'left' });
|
||||
log('CLEANUP', `Pruned dead peer ${data.peerId} from room ${roomId}`);
|
||||
staleSids.push(sid);
|
||||
}
|
||||
}
|
||||
for (const sid of staleSids) {
|
||||
// Gracefully evict the socket from the Socket.IO room if it is
|
||||
// still technically connected (zombie with no heartbeat).
|
||||
const deadSocket = io.sockets.sockets.get(sid);
|
||||
if (deadSocket) deadSocket.leave(roomId);
|
||||
log('CLEANUP', `Pruning dead peer from room ${roomId.substring(0, 3)}***`);
|
||||
removePeerFromRoom(sid, roomId, 'reaper');
|
||||
}
|
||||
|
||||
// 2. Prune empty or inactive rooms
|
||||
if (room.peers.size === 0 || room.lastActivity < roomCutoff) {
|
||||
@@ -413,3 +436,23 @@ setInterval(() => {
|
||||
httpServer.listen(PORT, () => {
|
||||
log('SERVER', `KoalaSync Relay running on port ${PORT}`);
|
||||
});
|
||||
|
||||
// --- M-4: Graceful Shutdown ---
|
||||
function gracefulShutdown(signal) {
|
||||
log('SERVER', `${signal} received — starting graceful shutdown...`);
|
||||
// 1. Notify all connected clients so they can display a meaningful message
|
||||
io.emit(EVENTS.ERROR, { message: 'Server is restarting. Please reconnect in a moment.' });
|
||||
// 2. Stop accepting new HTTP connections
|
||||
httpServer.close(() => {
|
||||
log('SERVER', 'HTTP server closed. Exiting.');
|
||||
process.exit(0);
|
||||
});
|
||||
// 3. Safety net: force-exit after 5s if connections don't drain
|
||||
setTimeout(() => {
|
||||
log('SERVER', 'Force-exit after timeout.');
|
||||
process.exit(1);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
|
||||
+7
-2
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = "1.0.0";
|
||||
export const APP_VERSION = "1.1.2";
|
||||
export const APP_VERSION = "1.2.1";
|
||||
|
||||
export const OFFICIAL_SERVER_URL = 'wss://sync.shik3i.net';
|
||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://koalasync.shik3i.net';
|
||||
@@ -32,8 +32,13 @@ export const EVENTS = {
|
||||
FORCE_SYNC_EXECUTE: "force_sync_execute",
|
||||
EVENT_ACK: "event_ack",
|
||||
GET_ROOMS: "get_rooms",
|
||||
ROOM_LIST: "room_list"
|
||||
ROOM_LIST: "room_list",
|
||||
|
||||
// Episode Auto-Sync
|
||||
EPISODE_LOBBY: "episode_lobby", // Broadcast: waiting for everyone on this episode
|
||||
EPISODE_READY: "episode_ready" // Response: loaded the episode and paused at 0:00
|
||||
};
|
||||
|
||||
export const HEARTBEAT_INTERVAL = 15000; // 15s
|
||||
export const FORCE_SYNC_TIMEOUT = 5000; // 5s timeout for ACKs
|
||||
export const EPISODE_LOBBY_TIMEOUT = 60000; // 60s timeout for episode lobby
|
||||
|
||||
+25
-7
@@ -104,12 +104,26 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'invite-banner';
|
||||
banner.id = 'koala-banner';
|
||||
banner.innerHTML = `
|
||||
<div class="container" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<span>🎫 Invitation for <b>${roomId}</b> detected!</span>
|
||||
<a href="join.html${window.location.hash}" class="btn-banner">OPEN JOIN PAGE</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'container';
|
||||
container.style.cssText = 'display:flex; justify-content:space-between; align-items:center;';
|
||||
|
||||
const inviteSpan = document.createElement('span');
|
||||
inviteSpan.appendChild(document.createTextNode('🎫 Invitation for '));
|
||||
const boldRoom = document.createElement('b');
|
||||
boldRoom.textContent = roomId;
|
||||
inviteSpan.appendChild(boldRoom);
|
||||
inviteSpan.appendChild(document.createTextNode(' detected!'));
|
||||
|
||||
const joinLink = document.createElement('a');
|
||||
joinLink.href = 'join.html' + window.location.hash;
|
||||
joinLink.className = 'btn-banner';
|
||||
joinLink.textContent = 'OPEN JOIN PAGE';
|
||||
|
||||
container.appendChild(inviteSpan);
|
||||
container.appendChild(joinLink);
|
||||
banner.appendChild(container);
|
||||
document.body.prepend(banner);
|
||||
}
|
||||
}
|
||||
@@ -178,7 +192,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(() => window.close(), 3000);
|
||||
} else {
|
||||
banner.style.background = 'var(--error)';
|
||||
banner.innerHTML = `<div class="container">❌ Error: ${message}</div>`;
|
||||
banner.innerHTML = '';
|
||||
const errDiv = document.createElement('div');
|
||||
errDiv.className = 'container';
|
||||
errDiv.textContent = '❌ Error: ' + message;
|
||||
banner.appendChild(errDiv);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<header class="hero">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-text">
|
||||
<div class="version-badge" data-reveal>v1.2.0 OUT NOW</div>
|
||||
<h1 data-reveal>Watch Together.<br>Sync Perfectly.</h1>
|
||||
<p data-reveal>KoalaSync brings friends closer through synchronized video playback. No lag, no tracking, just shared moments.</p>
|
||||
<div class="cta-group" data-reveal>
|
||||
@@ -64,6 +65,11 @@
|
||||
<h3>Real-time Sync</h3>
|
||||
<p>Proprietary two-phase synchronization protocol ensures sub-millisecond precision across all peers.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-reveal>
|
||||
<div class="feature-icon">🎬</div>
|
||||
<h3>Episode Auto-Sync</h3>
|
||||
<p>New in v1.2.0: Perfectly sync series binges. All peers wait until everyone has loaded the next episode.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-reveal>
|
||||
<div class="feature-icon">🛡️</div>
|
||||
<h3>Privacy First</h3>
|
||||
@@ -75,6 +81,7 @@
|
||||
<p>Find the right tab instantly. KoalaSync highlights and sorts matching video tabs for you.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -223,6 +223,21 @@ nav {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
display: inline-block;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: var(--accent);
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 99px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
|
||||
.hero-image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user