mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9cf7c49dc | |||
| bcbd46d658 | |||
| 4d489ec992 | |||
| 65ad4b5c6b | |||
| c2857dbdda | |||
| d07bf745a3 | |||
| bd54e893b4 | |||
| 50c9ba4ec8 | |||
| 55c2d4ed0d | |||
| 02afc193c6 | |||
| 7fc156977a | |||
| 99cb07bc2a | |||
| 77ffda3e42 | |||
| 01bc95e176 | |||
| 7417c21217 | |||
| 3e63602559 | |||
| 4785c9625c | |||
| 0d08711398 | |||
| abe876e04e | |||
| 3771243b3c | |||
| ee79d66ac0 | |||
| ac8d73ef4f | |||
| f3ee7b47b9 | |||
| 0f611fa112 | |||
| 67f872ffd2 | |||
| e40e2e5101 |
@@ -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 }}
|
||||
|
||||
@@ -51,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
|
||||
|
||||
+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/
|
||||
|
||||
+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.
|
||||
|
||||
+419
-95
@@ -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,11 +29,14 @@ function ensureState() {
|
||||
chrome.storage.session.get([
|
||||
'logs', 'history', 'currentRoom', 'lastActionState',
|
||||
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
|
||||
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime'
|
||||
'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;
|
||||
@@ -65,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 = [];
|
||||
}
|
||||
@@ -98,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() {
|
||||
@@ -121,13 +147,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 || ''
|
||||
});
|
||||
});
|
||||
@@ -144,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(() => {});
|
||||
@@ -334,7 +359,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 {
|
||||
@@ -417,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(() => {});
|
||||
@@ -518,14 +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 });
|
||||
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);
|
||||
@@ -533,10 +570,16 @@ 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
|
||||
// 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 };
|
||||
currentRoom.peers[idx] = createPeerData(data);
|
||||
}
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
@@ -544,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;
|
||||
@@ -564,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,
|
||||
@@ -576,10 +760,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);
|
||||
@@ -633,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) => {
|
||||
@@ -679,11 +843,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');
|
||||
@@ -693,27 +859,33 @@ 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(() => {});
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'CLEAR_LOGS') {
|
||||
logs = [];
|
||||
sendResponse({ status: 'ok' });
|
||||
@@ -725,6 +897,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 +924,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 +932,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 +945,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() + 8500;
|
||||
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();
|
||||
}
|
||||
}, 8500);
|
||||
}
|
||||
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 +1004,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 +1014,153 @@ 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 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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -855,11 +1169,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();
|
||||
|
||||
+263
-28
@@ -22,22 +22,34 @@
|
||||
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 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);
|
||||
}
|
||||
// --- 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]);
|
||||
const timeout = state === 'seek' ? 10000 : 1500;
|
||||
expectedTimeouts[state] = setTimeout(() => {
|
||||
expectedEvents.delete(state);
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
function reportLog(message, level = 'info') {
|
||||
@@ -50,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();
|
||||
@@ -74,11 +180,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 +196,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 +209,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 +241,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 +281,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) => {
|
||||
@@ -183,23 +290,79 @@
|
||||
});
|
||||
}
|
||||
} 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) {
|
||||
const dataAttributes = {};
|
||||
if (video.attributes) {
|
||||
for (const attr of video.attributes) {
|
||||
if (attr.name.startsWith('data-')) {
|
||||
dataAttributes[attr.name] = attr.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = (navigator.mediaSession && navigator.mediaSession.metadata) ? {
|
||||
title: navigator.mediaSession.metadata.title,
|
||||
artist: navigator.mediaSession.metadata.artist,
|
||||
album: navigator.mediaSession.metadata.album,
|
||||
artwork: Array.from(navigator.mediaSession.metadata.artwork || []).map(a => a.src)
|
||||
} : null;
|
||||
|
||||
sendResponse({
|
||||
paused: video.paused,
|
||||
currentTime: video.currentTime,
|
||||
duration: video.duration || 0,
|
||||
readyState: video.readyState,
|
||||
muted: video.muted,
|
||||
volume: video.volume,
|
||||
playbackRate: video.playbackRate,
|
||||
url: window.location.href,
|
||||
id: video.id || 'none'
|
||||
id: video.id || 'none',
|
||||
className: video.className || 'none',
|
||||
src: video.src || 'none',
|
||||
currentSrc: video.currentSrc || 'none',
|
||||
dataAttributes,
|
||||
metadata
|
||||
});
|
||||
} else {
|
||||
sendResponse({ error: 'No video found' });
|
||||
@@ -215,10 +378,12 @@
|
||||
const current = video.currentTime;
|
||||
if (!Number.isFinite(current)) return;
|
||||
|
||||
const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -228,6 +393,7 @@
|
||||
payload: {
|
||||
currentTime: current,
|
||||
targetTime: current,
|
||||
mediaTitle: mediaTitle,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
});
|
||||
@@ -235,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,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();
|
||||
}
|
||||
}
|
||||
@@ -286,11 +509,15 @@
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
const video = findVideo();
|
||||
if (video) {
|
||||
const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null;
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'HEARTBEAT',
|
||||
payload: {
|
||||
playbackState: video.paused ? 'paused' : 'playing',
|
||||
currentTime: video.currentTime
|
||||
currentTime: video.currentTime,
|
||||
mediaTitle: mediaTitle,
|
||||
volume: video.volume,
|
||||
muted: video.muted
|
||||
}
|
||||
}).catch(err => {
|
||||
if (err.message.includes('Extension context invalidated')) {
|
||||
@@ -308,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.0.4",
|
||||
"version": "1.2.1",
|
||||
"description": "Synchronize video playback across different tabs and users.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
@@ -22,21 +22,7 @@
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"js": [
|
||||
"content.js"
|
||||
],
|
||||
"run_at": "document_idle",
|
||||
"all_frames": false
|
||||
},
|
||||
{
|
||||
"matches": ["https://koalasync.shik3i.net/*"],
|
||||
"js": ["bridge.js"],
|
||||
+22
-4
@@ -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);">
|
||||
@@ -328,7 +341,7 @@
|
||||
</div>
|
||||
|
||||
<label>Video Debug Info</label>
|
||||
<div id="videoDebug" class="info-card" style="font-size: 10px; font-family: monospace; color: var(--text-muted); min-height: 60px; line-height: 1.4;">
|
||||
<div id="videoDebug" class="info-card" style="font-size: 10px; font-family: monospace; color: var(--text-muted); max-height: 250px; overflow-y: auto; line-height: 1.4;">
|
||||
No tab selected or video detected.
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+247
-47
@@ -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', 'targetTabId', '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);
|
||||
@@ -62,21 +73,26 @@ 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);
|
||||
|
||||
// Render lobby status if active
|
||||
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
|
||||
} else {
|
||||
await populateTabs();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -94,6 +110,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 +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;
|
||||
|
||||
@@ -210,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) {
|
||||
@@ -232,22 +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);
|
||||
|
||||
if (pTabTitle) {
|
||||
const titleDiv = document.createElement('div');
|
||||
titleDiv.style.cssText = 'font-size:10px; color:var(--text-muted);';
|
||||
titleDiv.textContent = pTabTitle;
|
||||
peerItem.appendChild(titleDiv);
|
||||
// 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; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 280px;';
|
||||
mediaDiv.textContent = `🎬 ${p.mediaTitle}`;
|
||||
peerItem.appendChild(mediaDiv);
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
};
|
||||
@@ -259,10 +371,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;
|
||||
@@ -520,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 });
|
||||
});
|
||||
@@ -638,15 +760,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;
|
||||
@@ -657,7 +782,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({
|
||||
@@ -770,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, []);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -806,31 +940,57 @@ function refreshDebugInfo() {
|
||||
if (elements.videoDebug) {
|
||||
elements.videoDebug.innerHTML = '';
|
||||
|
||||
const status = document.createElement('div');
|
||||
status.style.cssText = 'color:var(--accent); margin-bottom:4px;';
|
||||
status.textContent = `VIDEO STATE: ${state.paused ? 'PAUSED' : 'PLAYING'}`;
|
||||
const addField = (label, value, color = null) => {
|
||||
const row = document.createElement('div');
|
||||
row.style.marginBottom = '4px';
|
||||
if (color) row.style.color = color;
|
||||
|
||||
const b = document.createElement('b');
|
||||
b.textContent = `${label}: `;
|
||||
b.style.color = 'var(--text-muted)';
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.textContent = value;
|
||||
span.style.wordBreak = 'break-all';
|
||||
|
||||
row.appendChild(b);
|
||||
row.appendChild(span);
|
||||
elements.videoDebug.appendChild(row);
|
||||
};
|
||||
|
||||
const addSection = (title) => {
|
||||
const div = document.createElement('div');
|
||||
div.style.cssText = 'margin: 8px 0 4px 0; border-bottom: 1px solid #334155; padding-bottom: 2px; color: var(--accent); font-weight: bold; font-size: 9px;';
|
||||
div.textContent = title.toUpperCase();
|
||||
elements.videoDebug.appendChild(div);
|
||||
};
|
||||
|
||||
addField('STATE', state.paused ? 'PAUSED' : 'PLAYING', 'var(--accent)');
|
||||
addField('TIME', `${state.currentTime.toFixed(2)}s / ${state.duration.toFixed(2)}s`);
|
||||
addField('READY', state.readyState);
|
||||
|
||||
const time = document.createElement('div');
|
||||
time.style.fontSize = '11px';
|
||||
time.textContent = `Time: ${state.currentTime.toFixed(2)}s / ${state.duration.toFixed(2)}s`;
|
||||
addSection('Identification');
|
||||
addField('URL', state.url);
|
||||
addField('ID', state.id);
|
||||
addField('CLASS', state.className);
|
||||
|
||||
const readyState = document.createElement('div');
|
||||
readyState.style.fontSize = '11px';
|
||||
readyState.textContent = `ReadyState: ${state.readyState}`;
|
||||
|
||||
const misc = document.createElement('div');
|
||||
misc.style.fontSize = '11px';
|
||||
misc.textContent = `Muted: ${state.muted} | PlaybackRate: ${state.playbackRate}`;
|
||||
|
||||
const url = document.createElement('div');
|
||||
url.style.cssText = 'font-size:9px; margin-top:4px; opacity:0.7;';
|
||||
url.textContent = `URL: ${state.url.substring(0, 40)}...`;
|
||||
|
||||
elements.videoDebug.appendChild(status);
|
||||
elements.videoDebug.appendChild(time);
|
||||
elements.videoDebug.appendChild(readyState);
|
||||
elements.videoDebug.appendChild(misc);
|
||||
elements.videoDebug.appendChild(url);
|
||||
addSection('Media Source');
|
||||
addField('CURRENT_SRC', state.currentSrc);
|
||||
addField('SRC', state.src);
|
||||
|
||||
if (state.metadata) {
|
||||
addSection('Media Session API');
|
||||
addField('TITLE', state.metadata.title || 'n/a');
|
||||
addField('ARTIST', state.metadata.artist || 'n/a');
|
||||
addField('ALBUM', state.metadata.album || 'n/a');
|
||||
}
|
||||
|
||||
if (state.dataAttributes && Object.keys(state.dataAttributes).length > 0) {
|
||||
addSection('Data Attributes');
|
||||
for (const [key, val] of Object.entries(state.dataAttributes)) {
|
||||
addField(key.replace('data-', '').toUpperCase(), val);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -838,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,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.
|
||||
@@ -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/"
|
||||
+110
-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, 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,12 +287,13 @@ io.on('connection', (socket) => {
|
||||
peerId,
|
||||
username: username || null,
|
||||
tabTitle: tabTitle || null,
|
||||
mediaTitle: mediaTitle || null,
|
||||
lastSeen: Date.now()
|
||||
});
|
||||
socketToRoom.set(socket.id, { roomId, peerId });
|
||||
peerToSocket.set(peerId, socket.id);
|
||||
|
||||
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, username: username || null, tabTitle: tabTitle || null, status: 'joined' });
|
||||
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, username: username || null, tabTitle: tabTitle || null, mediaTitle: mediaTitle || null, status: 'joined' });
|
||||
socket.emit(EVENTS.ROOM_DATA, {
|
||||
roomId,
|
||||
peers: Array.from(room.peers).map(sid => room.peerData.get(sid))
|
||||
@@ -265,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 => {
|
||||
@@ -285,11 +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 || existing.username,
|
||||
tabTitle: data.tabTitle || existing.tabTitle,
|
||||
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()
|
||||
});
|
||||
|
||||
@@ -311,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);
|
||||
@@ -353,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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -381,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) {
|
||||
@@ -411,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'));
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
+11
-2
@@ -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.2.1";
|
||||
|
||||
export const OFFICIAL_SERVER_URL = 'wss://sync.shik3i.net';
|
||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://koalasync.shik3i.net';
|
||||
@@ -28,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