Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1aca6c37d4 | |||
| aa740592dd | |||
| 6f8bcf8478 | |||
| 807a620fe9 | |||
| 6e138f51d3 | |||
| ed50e354ab | |||
| 48bd503b5c | |||
| 35351bdacd | |||
| a0063d42b1 | |||
| 8b3f9e1242 | |||
| eb5515fc1b | |||
| c621685aae | |||
| b98cfc9ca1 | |||
| 6f08a9d7c4 | |||
| 440ed2db47 | |||
| ed24a4c263 | |||
| 284b82a910 | |||
| f59d30569e | |||
| f23c7eb3c8 | |||
| 9a4dd41555 | |||
| 2067f76ced | |||
| 3b65af1bbb | |||
| bb316340a7 | |||
| a3af397fe9 | |||
| 0ac2b49d89 | |||
| c9f93dc4ba | |||
| 4909a86a13 | |||
| d66c68be5d | |||
| 23d4b7068e | |||
| f40d4e8de4 | |||
| e996275c2a | |||
| dd37045afc | |||
| 92bec29215 | |||
| 552afac26a | |||
| 3c671bcfab | |||
| 18ed8953db | |||
| 967fe8872e | |||
| 0e93e31bd0 | |||
| 19ddfd1e21 | |||
| 4d85362020 | |||
| 53c1b8eea3 | |||
| 2b2aeeba00 | |||
| 5067b8e541 | |||
| 8778403449 | |||
| af87e34f5d | |||
| 385602c194 | |||
| bf0fa55b9d | |||
| f063fd5f3d | |||
| 0a555942f8 | |||
| d3f680e313 | |||
| e8203419a3 | |||
| e3536ad1a7 | |||
| aa1382b2ad | |||
| 44ee3fab25 | |||
| e9b55c72f0 | |||
| a83cdf4b03 | |||
| 3fb48ee822 | |||
| 2d20af7199 | |||
| 579677e11c | |||
| 05e1801653 | |||
| 6774b2bfb7 | |||
| 108d015e9f | |||
| e0b9e9ef27 | |||
| 641c9bfb24 | |||
| cf993b4ef0 | |||
| 5b1d6d7ba4 | |||
| 9ed7c98933 | |||
| 68b0f3306c | |||
| a71cdc3e53 | |||
| 366d157d80 | |||
| 2d231cb390 | |||
| b11b3316af | |||
| e337527e63 | |||
| 882565a079 | |||
| 4653c455d5 | |||
| 800a6c09b7 | |||
| 751496ed48 | |||
| 734a004f23 | |||
| 01ce3ec99e | |||
| d9b26f9fb4 | |||
| 1e3bb94660 | |||
| 90882f91ef | |||
| 5157428e74 | |||
| 47ca7563b7 | |||
| f7829bbebb | |||
| 6093da4dc6 | |||
| 583e15745f | |||
| bd8c7edc3a | |||
| bd4c53f9c7 | |||
| 5440d136fe | |||
| c9ec6ce3e0 | |||
| 652f1cef4f | |||
| 0f1f8bde1b | |||
| fa4e4039b3 | |||
| cb466d3865 | |||
| 54be9f9a39 | |||
| 47a9c08f48 | |||
| b0bcab77e3 | |||
| 6624bcc1ca | |||
| ca391ca83b | |||
| 1438a4d41f | |||
| c9cf7c49dc | |||
| bcbd46d658 | |||
| 4d489ec992 |
@@ -7,7 +7,5 @@ extension/
|
||||
website/
|
||||
scripts/
|
||||
*.md
|
||||
*.bat
|
||||
*.sh
|
||||
.env
|
||||
server/.env
|
||||
|
||||
@@ -52,19 +52,55 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Sync Protocol Constants
|
||||
run: |
|
||||
chmod +x ./scripts/sync-constants.sh
|
||||
./scripts/sync-constants.sh
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Extension Zip
|
||||
- name: Inject version into source files
|
||||
run: |
|
||||
zip -r koala-sync-extension.zip extension/ -x "*.DS_Store*"
|
||||
VERSION=${{ steps.version.outputs.VERSION }}
|
||||
DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
echo "Injecting version $VERSION from tag $GITHUB_REF_NAME..."
|
||||
|
||||
# 1. extension/manifest.base.json
|
||||
jq --arg v "$VERSION" '.version = $v' extension/manifest.base.json > tmp.json && mv tmp.json extension/manifest.base.json
|
||||
echo " ✓ manifest.base.json -> $VERSION"
|
||||
|
||||
# 2. shared/constants.js — APP_VERSION
|
||||
sed -i "s/export const APP_VERSION = '.*'/export const APP_VERSION = '$VERSION'/" shared/constants.js
|
||||
echo " ✓ shared/constants.js -> $VERSION"
|
||||
|
||||
# 3. package.json
|
||||
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
|
||||
echo " ✓ package.json -> $VERSION"
|
||||
|
||||
# 4. website/version.json
|
||||
jq -n --arg v "$VERSION" --arg d "$DATE" '{version: $v, date: $d}' > website/version.json
|
||||
echo " ✓ website/version.json -> version $VERSION, date $DATE"
|
||||
|
||||
echo "Version injection complete."
|
||||
|
||||
- name: Commit and push version updates back to main
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add extension/manifest.base.json shared/constants.js package.json website/version.json
|
||||
git commit -m "chore(release): update versions to $GITHUB_REF_NAME [skip ci]" || echo "No changes to commit"
|
||||
git push origin HEAD:main
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Extensions
|
||||
run: |
|
||||
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
|
||||
|
||||
@@ -38,8 +38,7 @@ 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/README.md
|
||||
extension/shared/
|
||||
|
||||
# Temporary scratch files
|
||||
scratch/
|
||||
|
||||
@@ -15,31 +15,41 @@ KoalaSync is a specialized tool for **synchronized video playback** across multi
|
||||
- **Identity**: Users are identified by a unique hex `peerId` combined with a customizable `username`.
|
||||
|
||||
## 2. Repository Structure
|
||||
- `extension/`: Chrome Extension (Manifest V3). Contains background service worker, content scripts, and popup UI.
|
||||
- `extension/`: Browser Extension (Chrome & Firefox, Manifest V3). Contains background service worker, content scripts, and popup UI.
|
||||
- `server/`: Node.js Relay Server using Socket.IO (WebSocket-only).
|
||||
- `website/`: **Landing Page** & Invitation Bridge (Marketing, Tutorials, and Downloads).
|
||||
- `shared/`: **Single Source of Truth** for protocol constants and event names.
|
||||
- `scripts/`: Utility scripts (e.g., `sync-constants.sh`).
|
||||
- `scripts/`: Development utilities (e.g., `build-extension.js`).
|
||||
- `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.
|
||||
> - **Content Scripts** (`content.js`) use a **marker-injected synchronous copy** of the constants. The build script automatically replaces the marked blocks — no manual mirroring needed.
|
||||
|
||||
## 3. Mandatory Reading
|
||||
Before touching any code, you MUST read the following documents in order:
|
||||
1. [ARCHITECTURE.md](ARCHITECTURE.md) – Detailed communication flows, Dual Heartbeat, and two-phase sync protocol.
|
||||
1. [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) – Detailed communication flows, Dual Heartbeat, and two-phase sync protocol.
|
||||
2. [extension/README.md](extension/README.md) – Extension components, tab structure, and loading process.
|
||||
3. [SYNC_GUIDE.md](SYNC_GUIDE.md) – Protocol constants and synchronization requirements.
|
||||
3. [docs/SYNC_GUIDE.md](docs/SYNC_GUIDE.md) – Protocol constants and synchronization requirements.
|
||||
|
||||
## 4. The "Vanilla JS Mirror" Pattern
|
||||
To avoid boot-time race conditions in Manifest V3 without a bundler, the following architectural trade-off is enforced:
|
||||
- **Synchronous Execution**: `content.js` MUST execute synchronously to catch early media events.
|
||||
- **Manual Mirroring**: `content.js` maintains a manual mirror of the `EVENTS` constants from `shared/constants.js`.
|
||||
- **Maintenance**: Developers must ensure that any changes to `shared/constants.js` are manually reflected in `content.js` after running the sync scripts.
|
||||
- **Automated Injection**: The build script (`node scripts/build-extension.js`) automatically injects `EVENTS` and `HEARTBEAT_INTERVAL` into `content.js` using marker-based replacement (see `scripts/README.md` for marker details).
|
||||
- **Maintenance**: After modifying `shared/constants.js`, simply run the build script. No manual mirroring is required.
|
||||
|
||||
## 5. Design Guidelines
|
||||
## 5. File Responsibility Map
|
||||
|
||||
| File | Responsibility |
|
||||
|:-----|:---------------|
|
||||
| `background.js` | WebSocket client, state orchestrator, event router, session persistence |
|
||||
| `content.js` | Video element detection, media control, event origin detection (loop prevention) |
|
||||
| `popup.js` | UI rendering, user input handling, peer display, invitation link generation |
|
||||
| `bridge.js` | Landing page ↔ extension communication for invitation join flow |
|
||||
| `server/index.js` | Room management, message relay, rate limiting, authentication, peer lifecycle |
|
||||
|
||||
## 6. Design Guidelines
|
||||
The popup UI follows a strict design system. Do not modify these variables or the layout structure without explicit approval.
|
||||
- **Font**: System font stack. **MANDATORY**: No external CDNs or Google Fonts to ensure 100% privacy.
|
||||
- **Popup Width**: Fixed at `320px`.
|
||||
@@ -53,11 +63,12 @@ The popup UI follows a strict design system. Do not modify these variables or th
|
||||
| `--success` | `#22c55e` | Success states / Online dot |
|
||||
| `--error` | `#ef4444` | Errors / Offline dot |
|
||||
|
||||
## 5. Non-Negotiables (Core Logic)
|
||||
## 7. 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.
|
||||
- **Background Heartbeat (1m)**: Ensures session persistence even without a video element.
|
||||
- **Content Heartbeat (15s)**: Transmits current video metadata (time, title).
|
||||
- **Dead Peer Pruning**: Server "Reaper" disconnects peers after 5 minutes of total silence (no heartbeats or events).
|
||||
- **Deduplication**: Server kills old sockets if a user re-joins with the same `peerId` to prevent ghosts.
|
||||
@@ -66,33 +77,59 @@ The following features are critical and must not be removed or fundamentally alt
|
||||
- **SW Keep-alive**: Use of `chrome.alarms` to prevent the Manifest V3 Service Worker from suspending.
|
||||
- **Diagnostics**: The "Dev" tab provides real-time access to the underlying `<video>` state for troubleshooting.
|
||||
- **Persistence**: `peerId` and `username` must be stored to remain stable across sessions.
|
||||
- **Room ID Format**: Room IDs are restricted to `[a-zA-Z0-9-]` only (alphanumeric + hyphens). This is enforced server-side.
|
||||
|
||||
## 6. Technical Constraints
|
||||
## 8. Technical Constraints
|
||||
- **No Bundler**: The extension uses plain ES Modules. Do not introduce build steps or npm packages into the `extension/` folder.
|
||||
- **Manual Protocol**: `background.js` implements a subset of the Socket.IO wire protocol natives.
|
||||
- **Manual Protocol**: `background.js` implements a subset of the Socket.IO wire protocol natively.
|
||||
- **Server Transport**: Restricted to `websocket` only. Polling is disabled.
|
||||
- **Docker Context**: The Docker build must run from the **Repo Root**.
|
||||
- **Manifest Settings**: `run_at` must remain `document_idle`, and `all_frames` must remain `false`.
|
||||
|
||||
## 7. Security & Deployment
|
||||
## 9. Security & Deployment
|
||||
- **Tokens**: Security tokens are intentionally managed via `shared/constants.js` and server `.env`.
|
||||
- **Environment**: `.env` is excluded via `.gitignore`. Only `.env.example` should be committed.
|
||||
- **Revocation**: `MIN_VERSION` check on the server is used to deprecate old extension versions.
|
||||
- **Invitation Links**: Correctly propagate server URLs, Room IDs, and Passwords via the URL hash to the bridge.
|
||||
|
||||
## 8. Common Workflows
|
||||
## 10. Common Workflows
|
||||
|
||||
### ⚠️ Pre-Session Git Sync (MANDATORY)
|
||||
Before starting any task, committing, or pushing, you **MUST** run `git pull --rebase` to ensure your local branch is up-to-date with `origin/main`. CI pipelines and other agents may push commits concurrently. Skipping this step will cause merge conflicts and rejected pushes.
|
||||
|
||||
### Releasing a New Version (CRITICAL WORKFLOW FOR AI AGENTS)
|
||||
> [!CAUTION]
|
||||
> **AI AGENTS MUST FOLLOW THIS EXACT SEQUENCE WHEN RELEASING A NEW VERSION OR TAGGING.**
|
||||
> The CI pipeline automatically injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json`. You do NOT need to manually bump version numbers.
|
||||
> - **Website Versioning**: **NEVER** manually modify the version fallback strings in `website/index.html`. The website dynamically fetches the latest version and release date from `website/version.json` at runtime using `website/app.js`. Manual bumps in the HTML file are completely redundant and should be avoided.
|
||||
1. **MANDATORY SYNTAX & LINT CHECKS**: Before staging, committing, or pushing any changes, you **MUST** run both checks on every modified JavaScript file:
|
||||
- **Syntax Validation**: Run `node -c` on every single modified JavaScript file (e.g., `node -c extension/background.js` and `node -c extension/content.js`). **NEVER** commit or push code that fails this check.
|
||||
- **ESLint Validation**: Run `npm run lint` (or `npx eslint .`). The output must show **zero errors and zero warnings**. ESLint is configured to catch undefined variables, unused vars, unreachable code, and other semantic issues. **NEVER** commit or push code that fails this check.
|
||||
2. Commit all verified code changes and push to `main`.
|
||||
3. Create and push a new tag. **MANDATORY**: Tags MUST start with a `v` (e.g., `v1.4.0`). The GitHub Actions release workflow is strictly configured to ignore any tags without the `v` prefix.
|
||||
- **🚫 TAG IMMUTABILITY**: Once a tag is pushed to `origin`, it is **PERMANENT**. You MUST **NEVER** reuse, move, or force-push an existing tag — not even to "fix" a mistake. If a release is missing a fix, increment the version and create a **new** tag (e.g., `v1.7.0` → `v1.7.1`). Tags are immutable identifiers; moving them breaks CI pipelines, corrupts the release history, and causes unreproducible builds.
|
||||
4. The CI will extract the version from the tag (e.g., `v1.4.0` → `1.4.0`), inject it into all source files, build the extension artifacts, publish the Docker image, and create a GitHub Release.
|
||||
5. Verify the release builds on GitHub Actions.
|
||||
|
||||
### 🚫 Force Push Policy
|
||||
> [!CAUTION]
|
||||
> **Force pushing (`git push --force` or `git push -f`) is FORBIDDEN without explicit user confirmation.**
|
||||
> - If a push is rejected due to a non-fast-forward conflict, you **MUST** run `git pull --rebase` first.
|
||||
> - If a force push is absolutely required (e.g., squashed history, amended commits), you **MUST** ask the user for explicit permission with a clear explanation of why it's necessary. Never force-push autonomously.
|
||||
> - This applies to both branches (`main`) and **tags** (see Tag Immutability above). Force-pushing tags is doubly destructive. Never do it.
|
||||
|
||||
### 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
|
||||
1. Load `extension/` as an "Unpacked Extension" in Chrome.
|
||||
2. Start the server from the root: `docker-compose up --build`.
|
||||
3. Use **different browser profiles** or vendors to test multi-peer logic.
|
||||
4. Use the **Dev tab** to verify real-time video element metadata.
|
||||
1. Run the build script: `node scripts/build-extension.js`.
|
||||
2. Load `dist/chrome/` as an "Unpacked Extension" in Chrome (or `dist/firefox/` in Firefox).
|
||||
3. Start the server from the root: `docker-compose up --build`.
|
||||
4. Use **different browser profiles** or vendors to test multi-peer logic.
|
||||
5. Use the **Dev tab** to verify real-time video element metadata.
|
||||
|
||||
### Locking Old Versions
|
||||
1. Increase `APP_VERSION` in `shared/constants.js`.
|
||||
2. Update `MIN_VERSION` in the server's `.env` file and restart.
|
||||
1. Update `MIN_VERSION` in the server's `.env` file to the minimum acceptable version.
|
||||
2. Restart the server. Older extensions will be rejected with a "Version too old" error.
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# KoalaSync Architecture
|
||||
|
||||
This document describes the communication flows and internal logic of the KoalaSync system.
|
||||
|
||||
## 1. Extension Startup & Connection
|
||||
- **Initialization**: On startup, `background.js` reads settings (Server URL, Username, Last Room) from `chrome.storage.sync`.
|
||||
- **WebSocket Handshake**:
|
||||
1. Background creates a `new WebSocket` to `/socket.io/?EIO=4&transport=websocket&version=1.0.0`.
|
||||
2. Server performs security checks:
|
||||
- **IP Rate Limit**: Checks if the IP has exceeded connection limits.
|
||||
- **Protocol Version**: Client must match the server's protocol (currently `1.0.0`).
|
||||
3. Server responds with Engine.IO handshake (`0`) and the client joins the namespace (`40`).
|
||||
- **Room Join**: Background emits `JOIN_ROOM` containing `roomId`, `password`, `peerId`, and `username`.
|
||||
- **Deduplication**: If a user joins with a `peerId` that already has an active socket, the server kills the old socket to prevent "Ghost Peers".
|
||||
|
||||
## 2. Media Event Synchronization
|
||||
When a user interacts with a video:
|
||||
1. **Detection**: `content.js` listens to native events (`play`, `pause`, `seeked`) on the `<video>` element.
|
||||
2. **Prevention of Loops**: Uses `lastTargetState` to distinguish between user actions and programmatic actions triggered by the extension.
|
||||
3. **Reporting**: `content.js` sends a `CONTENT_EVENT` to `background.js`.
|
||||
4. **Relay**: The Server forwards the event to all other peers in the room.
|
||||
5. **Execution**: Remote peers receive the command and call `video.play()`, `video.pause()`, or `video.currentTime = targetTime`.
|
||||
|
||||
## 3. Two-Phase Force Sync
|
||||
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`.
|
||||
4. **Resume**: All peers call `play()` simultaneously.
|
||||
|
||||
## 4. 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
|
||||
- **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
|
||||
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.
|
||||
- **Verification**: Any protocol change requires a manual verification sweep across all three constant locations (Shared, Server, and Content Script Mirror).
|
||||
@@ -0,0 +1,58 @@
|
||||
# Contributing to KoalaSync
|
||||
|
||||
Thank you for your interest in contributing to KoalaSync! We welcome all contributions, from bug reports to new features.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### 1. Prerequisites
|
||||
- Node.js (v18+)
|
||||
- Docker (for local server testing)
|
||||
|
||||
### 2. Setup
|
||||
1. Clone the repository.
|
||||
2. Run `npm install` in the root directory to install build dependencies.
|
||||
3. Run the build script to synchronize protocol constants and generate browser bundles:
|
||||
```bash
|
||||
node scripts/build-extension.js
|
||||
```
|
||||
|
||||
### 3. Testing Locally
|
||||
1. Load `dist/chrome/` as an "Unpacked Extension" in Chrome (`chrome://extensions/` → Developer Mode → Load Unpacked).
|
||||
2. For Firefox, load `dist/firefox/` via `about:debugging` → "Load Temporary Add-on".
|
||||
3. Start the relay server: `docker-compose up --build`.
|
||||
4. Use **two different browser profiles** (or Chrome + Firefox) to test multi-peer synchronization.
|
||||
5. Use the extension's **Dev tab** to verify real-time video element metadata (`readyState`, `currentTime`, `paused`).
|
||||
|
||||
### 4. Protocol Synchronization
|
||||
KoalaSync uses a "Single Source of Truth" for protocol constants in `shared/constants.js`.
|
||||
- **CRITICAL**: If you modify the constants, you MUST run the build script:
|
||||
```bash
|
||||
node scripts/build-extension.js
|
||||
```
|
||||
This will automatically synchronize the changes to the extension and generate the browser-specific bundles in the `dist/` folder.
|
||||
|
||||
### 5. Code Standards
|
||||
- **Vanilla JS**: The extension must remain dependency-free. Do not add npm packages to the `extension/` directory.
|
||||
- **Privacy**: Do not add external requests (CDNs, fonts, analytics, etc.).
|
||||
- **Comments**: Maintain the existing documentation style, especially for complex sync logic.
|
||||
- **Room IDs**: Room IDs are restricted to `[a-zA-Z0-9-]` (alphanumeric + hyphens only). Ensure any UI that generates room IDs follows this constraint.
|
||||
|
||||
### 6. Version Numbers
|
||||
> [!IMPORTANT]
|
||||
> **Do NOT manually bump version numbers.** The CI pipeline automatically injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json` during release builds. Manually changing version numbers in a PR will cause conflicts.
|
||||
|
||||
## Pull Request Process
|
||||
1. Create a new branch for your feature or bugfix.
|
||||
2. Ensure your code is tested locally (Chrome and Firefox).
|
||||
3. Update relevant documentation (e.g., `docs/ARCHITECTURE.md` if you change the protocol).
|
||||
4. Submit your PR with a clear description of the changes.
|
||||
|
||||
## Bug Reports
|
||||
When reporting a bug, please include:
|
||||
- **Browser**: Chrome / Firefox / Edge + version number.
|
||||
- **Extension Version**: Visible in the popup's Dev tab.
|
||||
- **Dev Tab Output**: Copy the connection status, logs, and video debug info from the Dev tab.
|
||||
- **Steps to Reproduce**: A clear sequence of actions that triggers the issue.
|
||||
|
||||
## Security
|
||||
If you find a security vulnerability, please do not open a public issue. Instead, refer to our [SECURITY.md](SECURITY.md) for responsible disclosure instructions.
|
||||
@@ -0,0 +1,46 @@
|
||||
# KoalaSync - Production Caddy Configuration Example
|
||||
# Replace domains and paths with your actual setup.
|
||||
|
||||
# 1. Marketing Website & Invitation Bridge
|
||||
sync.koalastuff.net {
|
||||
root * /var/www/koalasync/website
|
||||
file_server
|
||||
encode zstd gzip
|
||||
|
||||
# Static Caching for high-performance PageSpeed (1 year with validation)
|
||||
@static {
|
||||
file
|
||||
path *.ico *.css *.js *.png *.svg *.webp
|
||||
}
|
||||
header @static Cache-Control "public, max-age=31536000, must-revalidate"
|
||||
|
||||
# Security Headers & Content Security Policy (CSP)
|
||||
header {
|
||||
# Strict Content Security Policy (restricts scripts and connections to self, forbids frames)
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';"
|
||||
# Prevent FLoC tracking
|
||||
Permissions-Policy interest-cohort=()
|
||||
# Security best practices
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
X-Content-Type-Options nosniff
|
||||
X-Frame-Options DENY
|
||||
Referrer-Policy no-referrer-when-downgrade
|
||||
}
|
||||
}
|
||||
|
||||
# 2. Relay Server (Socket.IO / WebSocket)
|
||||
syncserver.koalastuff.net {
|
||||
reverse_proxy localhost:3000 {
|
||||
# Ensure WebSocket support is explicitly handled if needed
|
||||
# (Caddy usually handles this automatically)
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
|
||||
# Security Headers for the relay
|
||||
header {
|
||||
X-Content-Type-Options nosniff
|
||||
X-Frame-Options DENY
|
||||
Referrer-Policy no-referrer
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
# Privacy Policy
|
||||
|
||||
**KoalaSync does not collect, store, or sell any personal data.**
|
||||
|
||||
KoalaSync is designed with a **Security-First & Volatile** architecture. This means we prioritize keeping your data out of persistent storage, though certain technical data must be processed temporarily to ensure service stability and security.
|
||||
|
||||
## 1. Data Processing (In-Memory Only)
|
||||
KoalaSync does not use a database. All active session data exists only in the server's RAM and is purged immediately when no longer needed.
|
||||
- **Session Data**: To synchronize playback, the server must temporarily hold your `peerId`, `username`, and the `title` of the video you are watching. Additionally, playback metadata (`mediaTitle`, `playbackState`, `currentTime`, `volume`, `muted`) is held per peer for the duration of the session. All of this is deleted as soon as you leave the room.
|
||||
- **Room Passwords**: If you set a room password, it is stored only as a secure **bcrypt hash** in RAM. The server never sees or stores your plaintext password.
|
||||
- **Routing Maps**: The server maintains ephemeral lookup tables (`socketToRoom`, `peerToSocket`) to route messages between peers. These contain only transport identifiers and are purged on disconnect.
|
||||
|
||||
### Data Retention
|
||||
| Data Type | Maximum Retention | Trigger for Deletion |
|
||||
|:----------|:------------------|:---------------------|
|
||||
| Session data (peerId, username, video metadata) | Duration of session | User leaves room or disconnects |
|
||||
| Room state | 2 hours max | Last peer leaves, or inactivity timeout |
|
||||
| Auth failure records (lockout after 5 failed attempts) | 15 minutes | Periodic cleanup |
|
||||
| Connection rate-limit counters | 60 seconds | Automatic expiry |
|
||||
| Event rate-limit counters | 10 seconds | Automatic expiry + periodic cleanup |
|
||||
|
||||
## 2. Security & Rate Limiting
|
||||
To prevent abuse and brute-force attacks, the following data is processed:
|
||||
- **Brute-Force Protection**: If multiple failed password attempts are detected, the server stores the `IP address` and `Room ID` in a temporary RAM-based lockout list for a maximum of 15 minutes.
|
||||
- **Connection Rate Limiting**: IP addresses are tracked for 60 seconds to prevent connection-flooding (DoS) attacks.
|
||||
- **Event Rate Limiting**: Per-socket event counters are tracked for 10-second windows to prevent event-spamming. These are keyed by ephemeral socket IDs and cleaned up periodically.
|
||||
- **Console Logging**: The official relay server (`syncserver.koalastuff.net`) outputs connection events (including IP addresses) to the server console for real-time monitoring. These logs are ephemeral and are not archived, sold, or linked to any persistent user identity.
|
||||
|
||||
## 3. Extension Permissions
|
||||
The browser extension requires the following permissions:
|
||||
- `storage`: To remember your local preferences (username, server URL, room settings).
|
||||
- `tabs` & `scripting`: To detect and control video elements on the pages you choose to sync.
|
||||
- `<all_urls>` (host permission): Required to detect `<video>` elements on any website the user chooses to synchronize. The extension only activates on the specific tab the user has actively selected — it does not scan, monitor, or interact with any other tabs or pages.
|
||||
- `alarms`: To keep the background service worker alive during active sync sessions.
|
||||
- `notifications`: To display sync status updates (e.g., "Peer joined", "Force Sync initiated").
|
||||
- **No History Access**: We do not read, store, or transmit your browsing history. We only interact with the specific tab you have actively selected for synchronization.
|
||||
|
||||
## 4. Zero Third-Party Requests
|
||||
KoalaSync is completely self-contained:
|
||||
- **No CDNs or External Libraries**: All scripts and styles are self-hosted.
|
||||
- **No Analytics**: We do not use Google Analytics, tracking pixels, or any third-party telemetry.
|
||||
- **No External Fonts**: We use system font stacks to prevent tracking via font services.
|
||||
|
||||
## 5. Self-Hosted Instances
|
||||
This privacy policy applies to the **official KoalaSync relay server** at `syncserver.koalastuff.net`. If you choose to self-host a relay server using our open-source Docker image, the data handling practices of that instance are the responsibility of the server operator.
|
||||
|
||||
---
|
||||
|
||||
**Auditable & Open Source**: Because KoalaSync is open source, you can verify these claims by reviewing the [Server Source Code](https://github.com/Shik3i/KoalaSync/blob/main/server/index.js) and the [Extension Logic](https://github.com/Shik3i/KoalaSync/blob/main/extension/content.js).
|
||||
@@ -1,73 +1,90 @@
|
||||
# KoalaSync
|
||||
<h1 align="center"><img src="extension/icons/icon128.png" width="32" valign="middle"> KoalaSync</h1>
|
||||
|
||||
KoalaSync is a premium, lightweight Chrome Extension and Relay Server for synchronized video playback across any website (YouTube, Twitch, Netflix, and custom HTML5 players).
|
||||
<p align="center">
|
||||
<a href="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml"><img src="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml/badge.svg" alt="Release Status"></a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/github/v/release/Shik3i/KoalaSync" alt="GitHub release"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Shik3i/KoalaSync?color=blue" alt="License"></a>
|
||||
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/"><img src="https://img.shields.io/badge/Firefox-Download-orange?logo=firefoxbrowser&logoColor=white" alt="Firefox Add-on"></a>
|
||||
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white" alt="Chrome Extension"></a>
|
||||
</p>
|
||||
|
||||
**Latest Version**: `v1.2.0` (Episode Auto-Sync)
|
||||
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback across any website—YouTube, Twitch, Netflix, and custom HTML5 players. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
|
||||
|
||||
> [!TIP]
|
||||
> **New Developers & AI Agents**: Please read [AI_INIT.md](AI_INIT.md) before starting work.
|
||||
### 🌟 Why KoalaSync?
|
||||
|
||||
## Repository Structure
|
||||
- `extension/`: Chrome Extension (Manifest V3, Vanilla JS).
|
||||
- `server/`: Node.js + Socket.IO Relay Server (Containerized).
|
||||
- `website/`: Marketing landing page & **Invitation Bridge**.
|
||||
- `shared/`: Protocol constants and domain blacklist.
|
||||
- `scripts/`: Development utilities for protocol synchronization.
|
||||
* **🛡️ Security-First**: Volatile RAM-based relay with built-in brute-force protection and zero-persistence architecture.
|
||||
* **📡 Direct Logic**: Manual Socket.IO wire implementation for reliable synchronization.
|
||||
* **🛠️ Clean Build**: Dependency-free extension runtime with no library overhead.
|
||||
* **🌐 Universal**: Works on any website with a `<video>` tag.
|
||||
|
||||
> [!NOTE]
|
||||
> For deep technical dives, see [ARCHITECTURE.md](ARCHITECTURE.md) and [SYNC_GUIDE.md](SYNC_GUIDE.md).
|
||||
---
|
||||
|
||||
### ✨ Key Features
|
||||
|
||||
## 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.
|
||||
- **Episode Auto-Sync**: Perfectly sync series binges. All peers wait until everyone has loaded the next episode before starting together.
|
||||
- **Smart Matching**: Automatically highlights tabs containing matching video titles.
|
||||
- **Dual Heartbeat Architecture**: Robust session tracking that prevents ghost rooms and stale connections.
|
||||
- **Zero-Latency Relay**: Custom Socket.IO wire protocol implementation for maximum performance.
|
||||
- **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.
|
||||
- **Efficient Relay**: Minimal overhead WebSocket message forwarding.
|
||||
- **Seamless Invitations**: Smart links that automatically configure server and room credentials for your friends.
|
||||
|
||||
---
|
||||
|
||||
## Setup Instructions
|
||||
### 📂 Repository Structure
|
||||
|
||||
### 1. Relay Server (Docker)
|
||||
The server runs on Node.js using Socket.IO, containerized for easy deployment.
|
||||
- `extension/`: Browser Extension (Chrome & Firefox).
|
||||
- `server/`: Node.js + Socket.IO Relay Server (Containerized).
|
||||
- `website/`: Marketing landing page & Invitation Bridge.
|
||||
- `shared/`: **Single Source of Truth** for protocol constants.
|
||||
- `scripts/`: Automated build and synchronization utilities.
|
||||
- `docs/`: Technical deep-dives ([Architecture](docs/ARCHITECTURE.md), [Sync Guide](docs/SYNC_GUIDE.md)).
|
||||
|
||||
---
|
||||
|
||||
### 🚀 Quick Start
|
||||
|
||||
#### For Users (Installation)
|
||||
The easiest way to install KoalaSync is to download the pre-compiled version from the [Releases](https://github.com/Shik3i/KoalaSync/releases) page.
|
||||
1. Download the latest `koalasync-chrome.zip` or `koalasync-firefox.zip`.
|
||||
2. Extract the file and load it as an "Unpacked Extension" in your browser's Developer Mode.
|
||||
|
||||
#### For Developers (Building)
|
||||
To build the extension from source and synchronize protocol constants:
|
||||
```bash
|
||||
# From the root directory
|
||||
docker-compose up -d --build
|
||||
npm install
|
||||
node scripts/build-extension.js
|
||||
```
|
||||
The server will be available at `ws://localhost:3000`.
|
||||
The compiled artifacts will be available in the `dist/` directory.
|
||||
|
||||
### 2. Chrome Extension
|
||||
1. **Synchronize Protocol**: From the root directory, run the sync script to copy the master constants to the extension folder:
|
||||
```bash
|
||||
./scripts/sync-constants.sh
|
||||
```
|
||||
2. Open Chrome and go to `chrome://extensions/`.
|
||||
3. Enable **Developer mode** (top right).
|
||||
4. Click **Load unpacked**.
|
||||
5. Select the `extension/` folder.
|
||||
#### For Self-Hosting (Docker)
|
||||
Deploy your own private relay server using our official image:
|
||||
```bash
|
||||
# Pull the latest image
|
||||
docker pull ghcr.io/shik3i/koalasync:latest
|
||||
|
||||
## Usage
|
||||
1. Open the extension and go to the **Settings** tab to set your **Username**.
|
||||
2. Go to the **Room** tab, enter your Server URL (default: `ws://localhost:3000`), and click **Join / Create Room**.
|
||||
3. In the **Sync** tab, select the tab containing the video you want to sync.
|
||||
4. Share the **Invite Link** from the Room tab. When your friends click it, they will automatically join your room and server.
|
||||
5. Use **Force Sync** to perfectly align everyone to your current timestamp.
|
||||
# Or use our example compose file
|
||||
cp docker-compose.example.yml docker-compose.yml
|
||||
docker-compose up -d
|
||||
```
|
||||
The server will be available at `ws://localhost:3000`. See [docker-compose.example.yml](docker-compose.example.yml) for advanced configuration.
|
||||
|
||||
## Technical Details
|
||||
- **Manifest V3**: Uses a persistent Service Worker with Alarm-based keep-alive.
|
||||
- **Manual Socket.IO Protocol**: The extension implements the Socket.IO v4 wire protocol natively for extreme performance and zero dependencies.
|
||||
- **Dead Peer Pruning**: The server automatically prunes peers after 5 minutes of total inactivity (detected via dual heartbeats).
|
||||
- **Two-Phase Sync**: Ensures all peers are buffered (`readyState >= 3`) before resuming playback.
|
||||
To connect your extension to a self-hosted server, open the popup → **Room** tab → select **Custom Server** → enter your server's WebSocket URL (e.g., `ws://localhost:3000`).
|
||||
|
||||
## Security & Privacy
|
||||
> [!IMPORTANT]
|
||||
> **Privacy First**: KoalaSync stores no data on disk. All room states exist only in RAM and are purged immediately when empty. There is zero telemetry, tracking, or analytics.
|
||||
> **⚠️ Note**: `ws://` only works for `localhost`. If you deploy to a real domain, you **must** use `wss://` (e.g., `wss://sync.yourdomain.com`). This requires a TLS-terminating reverse proxy (e.g., Caddy, Nginx, or Traefik) in front of the relay server. See [Caddyfile.example](Caddyfile.example) for a production-ready template.
|
||||
|
||||
## Troubleshooting
|
||||
- **Logs**: Check the **Dev** tab in the extension popup for live connection logs and video state diagnostics.
|
||||
- **Handshake**: Verify you see `Joined Namespace /` in the logs.
|
||||
- **Permissions**: Ensure the target site hasn't blocked script injection (rare for most video sites).
|
||||
---
|
||||
|
||||
### 📖 Documentation & Links
|
||||
|
||||
- **[PRIVACY.md](PRIVACY.md)**: Data Handling and Privacy Policy.
|
||||
- **[CONTRIBUTING.md](CONTRIBUTING.md)**: How to help make KoalaSync better.
|
||||
- **[HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md)**: Step-by-step walkthrough of the complete user flow.
|
||||
- **[ARCHITECTURE.md](docs/ARCHITECTURE.md)**: Deep-dive into the two-phase sync and heartbeat logic.
|
||||
- **[SECURITY.md](SECURITY.md)**: Disclosure policy and security practices.
|
||||
- **[Caddyfile.example](Caddyfile.example)**: Production Caddy configuration for website and relay.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub>Built with ❤️ by <a href="https://github.com/Shik3i">Shik3i</a>. KoalaSync is Open Source under the <a href="LICENSE">MIT License</a>.</sub>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We take the security of our users and their data very seriously. We actively support and patch the latest stable releases of KoalaSync.
|
||||
|
||||
| Version | Supported |
|
||||
| -------------- | ------------------ |
|
||||
| Latest Release | :white_check_mark: |
|
||||
| Older Versions | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability within KoalaSync (e.g., related to the Node.js relay server, WebSocket wire protocol, or the Chrome/Firefox browser extension), please **DO NOT** report it by creating a public GitHub issue.
|
||||
|
||||
Publicly disclosing a vulnerability before a patch is available puts our users at risk. Instead, please send an email privately to the project administrator at:
|
||||
**koalasync_admin@koalamail.rocks**
|
||||
|
||||
### What to expect
|
||||
1. **Acknowledgment**: You should receive an acknowledgment of your report within 48 hours.
|
||||
2. **Investigation**: We will investigate the issue, confirm its severity, and work on a patch.
|
||||
3. **Resolution**: We will notify you when the patch is deployed to the Chrome Web Store, Mozilla Add-on Store, and our GitHub Docker releases.
|
||||
4. **Disclosure**: Once the fix is confirmed and users have had time to update, we will publicly acknowledge your contribution in our release notes (unless you prefer to remain anonymous).
|
||||
@@ -1,43 +0,0 @@
|
||||
# KoalaSync Protocol Synchronization Guide
|
||||
|
||||
## Why do we need to sync?
|
||||
KoalaSync uses a "Single Source of Truth" for its communication protocol constants located in the root `shared/` directory. However, Chrome Extensions (Manifest V3) are strictly sandboxed and **cannot load or import files from outside their root directory**.
|
||||
|
||||
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:
|
||||
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`.
|
||||
4. **Before committing** changes to the repository if any protocol-related files were touched.
|
||||
5. **Before deploying** the server or releasing the extension.
|
||||
|
||||
## 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:
|
||||
```bash
|
||||
./scripts/sync-constants.sh
|
||||
```
|
||||
|
||||
## 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`.
|
||||
|
||||
## 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.
|
||||
|
||||
> [!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.
|
||||
|
After Width: | Height: | Size: 462 KiB |
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
koala-sync:
|
||||
image: ghcr.io/shik3i/koalasync:latest
|
||||
container_name: KoalaSync
|
||||
restart: always
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- TZ=Europe/Berlin
|
||||
- PORT=3000
|
||||
- MIN_VERSION=1.0.0
|
||||
- MAX_ROOMS=100
|
||||
- MAX_PEERS_PER_ROOM=50
|
||||
# KoalaSync uses in-memory storage for the relay,
|
||||
# so no persistent database volume is required.
|
||||
pids_limit: 2048
|
||||
# Example for custom network (e.g., Unraid/Macvlan)
|
||||
# networks:
|
||||
# custom_network:
|
||||
# ipv4_address: 192.168.1.XXX
|
||||
|
||||
# networks:
|
||||
# custom_network:
|
||||
# external: true
|
||||
# name: br0
|
||||
@@ -1,4 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
server:
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# KoalaSync Architecture
|
||||
|
||||
This document describes the communication flows and internal logic of the KoalaSync system.
|
||||
|
||||
## 1. Extension Startup & Connection
|
||||
- **Initialization**: On startup, `background.js` reads settings (Server URL, Username, Last Room) from `chrome.storage.sync`.
|
||||
- **WebSocket Handshake**:
|
||||
1. Background creates a `new WebSocket` to `/socket.io/?EIO=4&transport=websocket&version=1.0.0`.
|
||||
2. Server performs security checks:
|
||||
- **IP Rate Limit**: Checks if the IP has exceeded connection limits.
|
||||
- **Protocol Version**: Client must match the server's protocol (currently `1.0.0`).
|
||||
3. Server responds with Engine.IO handshake (`0`) and the client joins the namespace (`40`).
|
||||
- **Room Join**: Background emits `JOIN_ROOM` containing `roomId`, `password`, `peerId`, and `username`.
|
||||
- **Deduplication**: If a user joins with a `peerId` that already has an active socket, the server kills the old socket to prevent "Ghost Peers". Deduplication re-validates after acquiring the room creation lock to avoid kicking the wrong socket during concurrent joins.
|
||||
|
||||
## 2. Media Event Synchronization
|
||||
When a user interacts with a video:
|
||||
1. **Detection**: `content.js` listens to native events (`play`, `pause`, `seeked`) on the `<video>` element, including videos inside Shadow DOM (YouTube, Netflix, etc.).
|
||||
2. **Prevention of Loops**: Uses an `expectedEvents` Set to distinguish between user actions and programmatic actions. Expected events are consumed on match and expire via timeout. Timeout IDs are cleaned up immediately to prevent memory leaks.
|
||||
3. **Reporting**: `content.js` sends a `CONTENT_EVENT` to `background.js`.
|
||||
4. **Relay**: The Server forwards the event to all other peers in the room.
|
||||
5. **Execution**: Remote peers receive the command via `SERVER_COMMAND` (which includes the original `senderId` for correct ACK routing) and call `video.play()`, `video.pause()`, or `video.currentTime = targetTime`.
|
||||
6. **ACK Routing**: `content.js` echoes the `commandSenderId` back in `CMD_ACK`, ensuring the `EVENT_ACK` is routed to the correct initiating peer even when multiple commands arrive concurrently.
|
||||
|
||||
## 3. Two-Phase Force Sync
|
||||
Ensures all peers are buffered and synchronized 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`. (Note: `content.js` limits polling to 8000ms).
|
||||
3. **Execute**: Once the Initiator collects ACKs (or after an 8.5s timeout), they send `FORCE_SYNC_EXECUTE`.
|
||||
> [!IMPORTANT]
|
||||
> **Network Transit Buffer Rule**: The orchestrator (`background.js`) must always use a timeout at least 500ms longer than the worker (`content.js`) to account for IPC and network transit time. Never align them exactly 1:1, as this will introduce a race condition on slow connections.
|
||||
4. **Resume**: All peers call `play()` simultaneously.
|
||||
|
||||
## 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.
|
||||
- **Reconnect Strategy**: Aggressive backoff — 500ms base, 1.5x multiplier, capped at 5s. Max 20 attempts before marking as failed. Events are queued during disconnect and flushed after namespace rejoin.
|
||||
|
||||
> [!CAUTION]
|
||||
> **Identity Rule**: Differentiate between `peerId` and `socket.id`. Use `socket.id` exclusively for ephemeral transport routing on the server. Use `peerId` exclusively for identity, state management, and room tracking across the stack.
|
||||
|
||||
## 6. Broadcast Protocol & Routing
|
||||
KoalaSync uses a megaphone routing approach to minimize server logic:
|
||||
- **`emit()` Broadcast Behavior**: Any `emit()` from the extension client is unconditionally broadcast to **all other peers in the room**. It is not a direct message.
|
||||
- **Storm Prevention**: When dispatching state updates in response to a new user joining (e.g., an active lobby state), ensure ONLY the initiator (or a designated leader) calls `emit()` to prevent $O(N)$ broadcast storms.
|
||||
|
||||
## 7. Security & Stability
|
||||
- **Service Worker Lifecycle**: Uses `chrome.alarms` (30s interval) to prevent the Manifest V3 service worker from suspending while in an active room. On wake, runtime state is restored from `chrome.storage.session` via `ensureState()`.
|
||||
- **Reconnect Visualization**: Badge shows "..." (orange) during reconnect. Popup displays "Reconnecting..." with attempt counter.
|
||||
- **Rate Limiting**: Server-side per-socket and per-IP rate limits to prevent sync-spamming or DoS. Real client IP extracted via `x-forwarded-for` header behind proxies/CDNs.
|
||||
- **Room Creation Lock**: Per-room mutex prevents race conditions when multiple peers join a new room simultaneously.
|
||||
- **CORS**: Allows `chrome-extension://` origins for WebSocket fallback compatibility.
|
||||
- **Message Buffer**: `maxHttpBufferSize` set to 4KB to accommodate large `JOIN_ROOM` payloads.
|
||||
- **Process Guards**: `uncaughtException` and `unhandledRejection` handlers prevent silent server crashes.
|
||||
- **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.
|
||||
|
||||
## 8. 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 synchronized copy of `EVENTS` and constants.
|
||||
- **Automation**: The `node scripts/build-extension.js` script automatically injects these constants into `content.js` during the build process, eliminating the risk of manual mirror mismatch.
|
||||
- **Verification**: Any protocol change is automatically propagated across the stack by running the build script.
|
||||
@@ -0,0 +1,213 @@
|
||||
# KoalaSync — How It Works (Step-by-Step)
|
||||
|
||||
This guide walks through the complete user flow of KoalaSync, from creating a room to synchronized playback. It is designed for **store reviewers**, **end-users**, and **manual testers** to understand exactly what happens at each step, what data is sent, and where it goes.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Installing the Extension
|
||||
|
||||
1. Download the extension from the [Releases](https://github.com/Shik3i/KoalaSync/releases) page (or install from the Chrome Web Store / Firefox Add-ons).
|
||||
2. The extension adds a small icon to your browser toolbar.
|
||||
3. On first install, a unique 8-character **Peer ID** is generated locally and stored in `chrome.storage.local`. This ID is never sent to any external service — it only travels to the relay server when you join a room.
|
||||
|
||||
> **What's stored locally**: `peerId` (8-char hex), `username` (customizable, defaults to a readable adjective-noun pair), `serverUrl`, `filterNoise` preference. All stored via `chrome.storage.sync` and `chrome.storage.local`.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Connecting to the Relay Server
|
||||
|
||||
When you open the extension popup, the background service worker connects to the relay server:
|
||||
|
||||
1. **WebSocket Handshake**: `background.js` opens a WebSocket to `wss://syncserver.koalastuff.net/socket.io/?EIO=4&transport=websocket`.
|
||||
2. **Security Checks** (server-side):
|
||||
- The server checks the client's **IP rate limit** (max 10 connections per 60 seconds).
|
||||
- The server validates the **authentication token** (hardcoded in `shared/constants.js`) to verify this is a legitimate KoalaSync client.
|
||||
- The server checks the **extension version** against `MIN_VERSION` to reject outdated clients.
|
||||
3. **Connection Established**: The server responds with an Engine.IO handshake (`0{...}`), followed by a Socket.IO namespace join (`40`). The connection status dot in the popup turns green.
|
||||
|
||||
> **Data sent to server**: `token` (authentication), `version` (e.g., `1.3.1`). No personal data is transmitted during connection.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Creating a Room
|
||||
|
||||
Click **"Create Room"** in the popup's Room tab:
|
||||
|
||||
1. The extension generates a random Room ID (e.g., `happy-koala-42`) and a random 6-character password.
|
||||
2. Room IDs are restricted to `[a-zA-Z0-9-]` (alphanumeric + hyphens only).
|
||||
3. The extension emits a `JOIN_ROOM` event to the server.
|
||||
|
||||
> **Data sent in `JOIN_ROOM`**:
|
||||
> ```json
|
||||
> {
|
||||
> "roomId": "happy-koala-42",
|
||||
> "password": "x7k2m9",
|
||||
> "peerId": "a1b2c3d4",
|
||||
> "username": "MyName",
|
||||
> "tabTitle": "YouTube - My Video",
|
||||
> "protocolVersion": "1.0.0"
|
||||
> }
|
||||
> ```
|
||||
|
||||
4. **Server-side processing**:
|
||||
- All fields are **sanitized**: `roomId` is stripped of invalid characters and clamped to 64 chars; `peerId` clamped to 16 chars; `password` clamped to 128 chars; `username` clamped to 30 chars.
|
||||
- The server **hashes the password** with bcrypt and stores the hash in RAM (the plaintext is never stored).
|
||||
- A new room object is created in memory with the peer's data.
|
||||
- The server responds with `ROOM_DATA` containing the list of peers in the room.
|
||||
|
||||
5. **Popup updates**: The Room tab switches to the "Active Room" view, showing your Room ID and an invitation link.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Sharing an Invitation Link
|
||||
|
||||
Click the **📋 Copy** button next to the invite link:
|
||||
|
||||
1. The extension constructs a URL in this format:
|
||||
```
|
||||
https://sync.koalastuff.net/join.html#join:<roomId>:<password>:<serverFlag>:<encodedServerUrl>
|
||||
```
|
||||
- `serverFlag`: `0` for official server, `1` for custom server.
|
||||
- `encodedServerUrl`: Only populated if using a custom server.
|
||||
|
||||
2. **Important**: The room credentials are in the **URL hash** (`#`), which means they are **never sent to the web server** — the hash fragment stays entirely in the browser. The landing page server never sees your room ID or password.
|
||||
|
||||
3. Send this link to your friend via any messaging app.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Your Friend Opens the Invitation Link
|
||||
|
||||
When your friend opens the link in their browser:
|
||||
|
||||
1. **`join.html` loads** on `sync.koalastuff.net`. The page displays "INVITATION DETECTED" with the Room ID.
|
||||
|
||||
2. **Extension detection**: The page checks for `document.documentElement.dataset.koalasyncInstalled`, which is set by `bridge.js` (a content script injected only on `sync.koalastuff.net`).
|
||||
|
||||
3. **If the extension IS installed**:
|
||||
- The page shows "Joining room automatically..."
|
||||
- After 500ms, the page dispatches a `KOALASYNC_JOIN_REQUEST` custom DOM event with `{ roomId, password, useCustomServer, serverUrl }`.
|
||||
- `bridge.js` catches this event and forwards it to `background.js` via `chrome.runtime.sendMessage`.
|
||||
- `background.js` stores the credentials in `chrome.storage.sync` and emits `JOIN_ROOM` to the server.
|
||||
- The server validates the password against the stored bcrypt hash.
|
||||
- On success, the server responds with `ROOM_DATA` and broadcasts `PEER_STATUS { status: 'joined' }` to all existing peers.
|
||||
- The join page updates to show "✅ Successfully joined!".
|
||||
|
||||
4. **If the extension is NOT installed**:
|
||||
- The page shows download links (Chrome Web Store / GitHub).
|
||||
- The user installs the extension, returns to the link, and the flow continues from step 3.
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Selecting a Video Tab
|
||||
|
||||
Both users now need to select which browser tab contains the video to sync:
|
||||
|
||||
1. Open a video on any website (YouTube, Twitch, Netflix, etc.).
|
||||
2. In the extension popup → **Sync** tab → use the **"Target Tab"** dropdown.
|
||||
3. The dropdown lists all open tabs, filtered to exclude noise (search engines, social media — configurable via Settings).
|
||||
4. Tabs with a **matching video title** are highlighted with a ⭐ prefix for easy identification.
|
||||
5. Selecting a tab causes `background.js` to set `currentTabId` and inject `content.js` into that tab via `chrome.scripting.executeScript`.
|
||||
|
||||
> **What `content.js` does on injection**: Finds the first `<video>` element on the page and attaches event listeners for `play`, `pause`, `seeked`, and `loadeddata`. (Time and volume state are tracked via a 15-second heartbeat interval, not continuous event listeners). It uses an `expectedEvents` Set to distinguish between user actions and programmatic actions (loop prevention).
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Synchronized Playback
|
||||
|
||||
When User A presses **Play** on their video:
|
||||
|
||||
1. `content.js` detects the native `play` event on the `<video>` element.
|
||||
2. It checks the `expectedEvents` Set — if this event was expected (caused by a remote command), it's consumed silently. If not, it's a **user action**.
|
||||
3. For user actions, `content.js` sends `{ type: 'CONTENT_EVENT', action: 'play', payload: { currentTime, ... } }` to `background.js`.
|
||||
4. `background.js` adds an `actionTimestamp` and emits the `PLAY` event to the server.
|
||||
5. **Server relay**: The server sanitizes all fields (strings clamped, numbers validated, booleans type-checked) and constructs a clean `relayPayload` with `senderId` set to User A's `peerId`. The raw client data is never forwarded directly.
|
||||
6. The server broadcasts the sanitized payload to all other peers in the room.
|
||||
7. User B's `background.js` receives the `PLAY` event and calls `routeToContent()`, which sends a `SERVER_COMMAND` message to User B's `content.js`.
|
||||
8. User B's `content.js` adds `'playing'` to its `expectedEvents` Set (so it won't echo the event back), then calls `video.play()`.
|
||||
|
||||
> **The same flow applies to Pause and Seek**, with Seek additionally sending `targetTime` for the time position.
|
||||
|
||||
---
|
||||
|
||||
## Step 8: Force Sync (Two-Phase Protocol)
|
||||
|
||||
If videos drift out of sync, either user can click **"Force Sync"**:
|
||||
|
||||
### Phase 1 — Prepare
|
||||
1. The initiator's `content.js` captures the current `video.currentTime` as the `targetTime`.
|
||||
2. `background.js` emits `FORCE_SYNC_PREPARE` with `{ targetTime }` to all peers.
|
||||
3. All peers (including the initiator) **pause** their video and **seek** to `targetTime`.
|
||||
4. Each peer's `content.js` polls `video.readyState` until it reaches `≥ 3` (buffered enough to play), with an 8-second timeout.
|
||||
5. Once buffered, each peer sends `FORCE_SYNC_ACK` back.
|
||||
|
||||
### Phase 2 — Execute
|
||||
6. Once all ACKs are received (or after 8.5 seconds), the initiator emits `FORCE_SYNC_EXECUTE`.
|
||||
7. All peers call `video.play()` simultaneously, achieving synchronized playback.
|
||||
|
||||
> **Why two phases?** Without buffering confirmation, peers with slower connections would start playing before they've loaded the target timestamp, causing immediate desync.
|
||||
|
||||
---
|
||||
|
||||
## Step 9: Heartbeat & Peer Health
|
||||
|
||||
While in a room, two heartbeats keep the session alive:
|
||||
|
||||
| Heartbeat | Interval | Source | Purpose |
|
||||
|:----------|:---------|:-------|:--------|
|
||||
| **Background** | 30 seconds | `background.js` | Signals "I'm still connected" and triggers aggressive reconnect (500ms base, max 5s) |
|
||||
| **Content** | 15 seconds | `content.js` | Sends video metadata: `currentTime`, `mediaTitle`, `playbackState`, `volume`, `muted` |
|
||||
|
||||
- **Server Reaper**: Every 2 minutes, the server checks for peers with no activity for 5+ minutes and disconnects them ("dead peer pruning").
|
||||
- **Room Cleanup**: Empty rooms are deleted immediately. Inactive rooms are pruned after 2 hours.
|
||||
|
||||
---
|
||||
|
||||
## Step 10: Leaving a Room
|
||||
|
||||
When a user clicks **"Leave"** or closes their browser:
|
||||
|
||||
1. `background.js` emits `LEAVE_ROOM` (or the WebSocket `disconnect` fires automatically).
|
||||
2. The server calls `removePeerFromRoom()`, which:
|
||||
- Removes the peer from the room's `peers` Set, `peerIds` Map, and `peerData` Map.
|
||||
- Removes the socket from the global `socketToRoom` and `peerToSocket` maps.
|
||||
- Broadcasts `PEER_STATUS { status: 'left' }` to remaining peers.
|
||||
- If the room is now empty, **deletes the room entirely** — no data persists.
|
||||
3. The event rate-limit counter for that socket is also cleaned up.
|
||||
|
||||
> **After disconnect, zero data about the user remains on the server.** There is no database, no log file, no analytics record. The session existed only in RAM and is now gone.
|
||||
|
||||
---
|
||||
|
||||
## Episode Auto-Sync Flow
|
||||
|
||||
When watching a series and an episode ends:
|
||||
|
||||
1. `content.js` monitors the [Media Session API](https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API) for title changes.
|
||||
2. When a new title is detected, the peer broadcasts `EPISODE_LOBBY` with the expected new title.
|
||||
3. All peers' videos freeze. The UI shows an "Episode Lobby" card with peer readiness status.
|
||||
4. Each peer's `content.js` polls for the new title to appear in the `<video>` element's metadata.
|
||||
5. Once a peer detects the matching title, they send `EPISODE_READY`.
|
||||
6. When all peers report ready, the lobby resolves and playback resumes simultaneously.
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Summary
|
||||
|
||||
```
|
||||
┌─────────────┐ WebSocket ┌──────────────┐ WebSocket ┌─────────────┐
|
||||
│ Extension │ ←─────────────────→│ Relay Server │←──────────────────→│ Extension │
|
||||
│ (User A) │ JOIN_ROOM │ (RAM only) │ JOIN_ROOM │ (User B) │
|
||||
│ │ PLAY/PAUSE/SEEK │ │ PLAY/PAUSE/SEEK │ │
|
||||
│ │ FORCE_SYNC_* │ Sanitizes & │ FORCE_SYNC_* │ │
|
||||
│ │ PEER_STATUS │ relays only │ PEER_STATUS │ │
|
||||
│ │ EPISODE_* │ │ EPISODE_* │ │
|
||||
└──────┬──────┘ └───────────────┘ └──────┬──────┘
|
||||
│ │
|
||||
┌────┴─────┐ ┌─────┴────┐
|
||||
│ content │ Listens to <video> events │ content │
|
||||
│ .js │ Controls playback │ .js │
|
||||
└──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
> **The relay server is a pure message forwarder.** It never interprets video content, accesses URLs, or stores session history. All media control happens locally inside each user's browser via the `<video>` DOM API.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Technical Documentation
|
||||
|
||||
This directory contains deep-dives into the KoalaSync protocol and architecture.
|
||||
|
||||
- [HOW_IT_WORKS.md](HOW_IT_WORKS.md): Step-by-step walkthrough of every user flow, from room creation to synchronized playback. Ideal for store reviewers and manual testers.
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md): Communication flows, Dual Heartbeat, and Sync logic.
|
||||
- [SYNC_GUIDE.md](SYNC_GUIDE.md): Protocol constants and sync requirements.
|
||||
@@ -0,0 +1,26 @@
|
||||
# KoalaSync Roadmap
|
||||
|
||||
This document tracks planned features, improvements, and their implementation details.
|
||||
|
||||
---
|
||||
|
||||
## Offene technische Fragen
|
||||
|
||||
### 1. Service Worker Fallback bei Room-State Verlust
|
||||
Manifest V3 suspendiert den Service Worker nach ~30s Inaktivität. `chrome.alarms` weckt ihn auf, aber:
|
||||
- **Problem:** Wenn der SW neu startet, sind alle Variablen (`currentRoom`, `socket`, `isNamespaceJoined`) weg
|
||||
- **Aktueller Stand:** `chrome.storage.session` persistiert `currentRoom`, `peerId`, `eventQueue` — der SW stellt diese beim Start wieder her (`ensureState()`)
|
||||
- **Gelöst:** WebSocket wird automatisch via `connect()` neu aufgebaut. Events werden während Reconnect gequeued und nach Namespace-Join geflushed. "Reconnecting..." Status wird im Popup + Badge angezeigt. KeepAlive-Alarm auf 30s reduziert. Reconnect-Backoff: 500ms Basis, max 5s (statt vorher 1s→30s).
|
||||
|
||||
### 7. Tests für Extensions
|
||||
Stimmt, sind aufwändig. Praktische Ansätze:
|
||||
- **Unit Tests:** `jest` + `jest-chrome` (mockt `chrome.*` APIs) — testet `popup.js` Logik, Server-Logik
|
||||
- **Integration Tests:** `puppeteer` mit `--load-extension` Flag — testet Extension im echten Browser
|
||||
- **Server Tests:** `supertest` + `socket.io-client` — testet WebSocket-Flows
|
||||
- **Aufwand:** ~400-600 LOC für sinnvolle Testabdeckung der Kernlogik
|
||||
|
||||
---
|
||||
|
||||
## Zukünftige Features
|
||||
|
||||
Neue Features werden nur nach expliziter Freigabe hinzugefügt.
|
||||
@@ -0,0 +1,37 @@
|
||||
# KoalaSync Protocol Synchronization Guide
|
||||
|
||||
## Why do we need to sync?
|
||||
KoalaSync uses a "Single Source of Truth" for its communication protocol constants located in the root `shared/` directory. However, Browser Extensions (Manifest V3) are strictly sandboxed and **cannot load or import files from outside their root directory**.
|
||||
|
||||
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 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`.
|
||||
4. **Before committing** changes to the repository if any protocol-related files were touched.
|
||||
5. **Before deploying** the server or releasing the extension.
|
||||
|
||||
## How to sync
|
||||
|
||||
Run the Node.js build script from the repository root:
|
||||
```bash
|
||||
node scripts/build-extension.js
|
||||
```
|
||||
|
||||
## What does it do?
|
||||
The build script performs the following actions:
|
||||
1. Synchronizes protocol constants by copying `shared/constants.js`, `shared/blacklist.js`, and `shared/README.md` into `extension/shared/`.
|
||||
2. Injects `EVENTS` and `HEARTBEAT_INTERVAL` into `content.js` via marker-based replacement.
|
||||
3. Compiles browser-specific manifest files.
|
||||
4. Packages the final ready-to-publish extension artifacts for Chrome and Firefox into the `dist/` directory.
|
||||
|
||||
## Protocol Versioning
|
||||
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 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 build script is run. Always edit the files in the root `shared/` directory and then run the build script.
|
||||
@@ -0,0 +1,68 @@
|
||||
export default [
|
||||
{
|
||||
ignores: ["dist/**", "node_modules/**", "scratch/**"]
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: "module",
|
||||
globals: {
|
||||
chrome: "readonly",
|
||||
window: "readonly",
|
||||
document: "readonly",
|
||||
navigator: "readonly",
|
||||
console: "readonly",
|
||||
localStorage: "readonly",
|
||||
setTimeout: "readonly",
|
||||
setInterval: "readonly",
|
||||
clearTimeout: "readonly",
|
||||
clearInterval: "readonly",
|
||||
fetch: "readonly",
|
||||
CustomEvent: "readonly",
|
||||
MutationObserver: "readonly",
|
||||
Uint32Array: "readonly",
|
||||
Set: "readonly",
|
||||
Map: "readonly",
|
||||
Promise: "readonly",
|
||||
Array: "readonly",
|
||||
Object: "readonly",
|
||||
JSON: "readonly",
|
||||
Math: "readonly",
|
||||
Number: "readonly",
|
||||
String: "readonly",
|
||||
Date: "readonly",
|
||||
Error: "readonly",
|
||||
URL: "readonly",
|
||||
WebSocket: "readonly",
|
||||
self: "readonly"
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
"no-undef": "error",
|
||||
"no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", "caughtErrorsIgnorePattern": "^_" }],
|
||||
"no-unreachable": "error",
|
||||
"no-constant-condition": "error",
|
||||
"no-dupe-keys": "error",
|
||||
"no-duplicate-case": "error",
|
||||
"no-empty": "error",
|
||||
"no-extra-semi": "error",
|
||||
"no-prototype-builtins": "warn",
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"valid-typeof": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["server/**/*.js", "scripts/**/*.js"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
require: "readonly",
|
||||
__dirname: "readonly",
|
||||
__filename: "readonly",
|
||||
process: "readonly",
|
||||
module: "readonly",
|
||||
exports: "readonly",
|
||||
Buffer: "readonly"
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -1,6 +1,6 @@
|
||||
# KoalaSync Chrome Extension
|
||||
# KoalaSync Browser Extension
|
||||
|
||||
A Manifest V3 Chrome Extension for synchronized video playback across any website.
|
||||
A Manifest V3 Browser Extension (Chrome & Firefox) for synchronized video playback across any website.
|
||||
|
||||
## Key Features
|
||||
- **Manifest V3**: Optimized Service Worker architecture with session persistence.
|
||||
@@ -20,14 +20,20 @@ KoalaSync requires `<all_urls>` permission to detect and interact with video ele
|
||||
- **No Browsing History**: We do not track or store your browsing history.
|
||||
- **State Management**: Sensitive data (Room Passwords) is stored locally using `chrome.storage`.
|
||||
- **Zero Telemetry**: No analytics or external tracking scripts.
|
||||
- **Zero Runtime Dependencies**: The extension is built with pure Vanilla JS and contains no external libraries or tracking scripts, ensuring performance and privacy.
|
||||
|
||||
## Installation
|
||||
1. **Sync Protocol**: Run `./scripts/sync-constants.sh` (macOS/Linux) or `scripts\sync-constants.bat` (Windows) from the root.
|
||||
1. **Prepare Extension**: From the repository root, run:
|
||||
```bash
|
||||
node scripts/build-extension.js
|
||||
```
|
||||
2. Open Chrome and go to `chrome://extensions/`.
|
||||
3. Enable **Developer mode** (top right).
|
||||
4. Click **Load unpacked** and select the `extension` folder.
|
||||
4. Click **Load unpacked** and select the `dist/chrome` folder.
|
||||
|
||||
## Development
|
||||
If you modify `shared/constants.js`, you must synchronize the changes across the extension and server:
|
||||
- **Windows**: Run `scripts\sync-constants.bat`
|
||||
- **Linux/macOS**: Run `scripts/sync-constants.sh`
|
||||
If you modify `shared/constants.js`, you must synchronize the changes by running the build script from the root:
|
||||
```bash
|
||||
node scripts/build-extension.js
|
||||
```
|
||||
This ensures that the `extension/shared` folder is updated with the latest protocol constants.
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, APP_VERSION, EPISODE_LOBBY_TIMEOUT } from './shared/constants.js';
|
||||
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, APP_VERSION, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT } from './shared/constants.js';
|
||||
|
||||
// --- State Management ---
|
||||
let socket = null;
|
||||
let reconnectDelay = 1000;
|
||||
const MAX_RECONNECT_DELAY = 30000;
|
||||
let isConnecting = false;
|
||||
let peerId = null; // initialized via getPeerId()
|
||||
let currentRoom = null;
|
||||
let lastPeersJson = null;
|
||||
let currentTabId = null;
|
||||
let currentTabTitle = null; // New: for Smart Matching
|
||||
let logs = [];
|
||||
@@ -18,7 +15,6 @@ let pendingHistory = [];
|
||||
let eventQueue = [];
|
||||
let isNamespaceJoined = false;
|
||||
let lastActionState = { action: null, senderId: null, timestamp: 0, acks: [] };
|
||||
let currentCommandSenderId = null; // Track who sent the last command we are executing
|
||||
|
||||
// --- Boot Sequence Lock ---
|
||||
let restorationTask = null;
|
||||
@@ -26,12 +22,22 @@ let restorationTask = null;
|
||||
function ensureState() {
|
||||
if (!restorationTask) {
|
||||
restorationTask = new Promise(resolve => {
|
||||
let resolved = false;
|
||||
const done = () => { if (!resolved) { resolved = true; resolve(); } };
|
||||
|
||||
const storageTimeout = setTimeout(() => {
|
||||
addLog('Storage restoration timed out, continuing with defaults', 'warn');
|
||||
storageInitialized = true;
|
||||
done();
|
||||
}, 10000);
|
||||
|
||||
chrome.storage.session.get([
|
||||
'logs', 'history', 'currentRoom', 'lastActionState',
|
||||
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
|
||||
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'currentTabId', 'currentTabTitle',
|
||||
'episodeLobby'
|
||||
], (data) => {
|
||||
clearTimeout(storageTimeout);
|
||||
if (data.currentTabId !== undefined) currentTabId = data.currentTabId;
|
||||
if (data.currentTabTitle !== undefined) currentTabTitle = data.currentTabTitle;
|
||||
// Merge data from storage with any early-arriving state
|
||||
@@ -92,7 +98,7 @@ function ensureState() {
|
||||
pendingHistory = [];
|
||||
}
|
||||
|
||||
resolve();
|
||||
done();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -103,8 +109,12 @@ function ensureState() {
|
||||
ensureState();
|
||||
|
||||
let reconnectTimer = null;
|
||||
let reconnectStartTime = null; // New: track when reconnection started
|
||||
let reconnectFailed = false; // New: true if we hit the 5-min cap
|
||||
let reconnectStartTime = null;
|
||||
let reconnectFailed = false;
|
||||
let reconnectAttempts = 0;
|
||||
const MAX_RECONNECT_ATTEMPTS = 20;
|
||||
const _RECONNECT_BASE_DELAY = 500;
|
||||
const _RECONNECT_MAX_DELAY = 5000;
|
||||
|
||||
// Force Sync Coordination
|
||||
let isForceSyncInitiator = false;
|
||||
@@ -137,6 +147,28 @@ function createPeerData(raw) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates properties of a peer in the room and instantly broadcasts the changes to the popup UI.
|
||||
* Also tracks lastReactiveUpdate to guard against older heartbeats in transit overwriting state.
|
||||
*/
|
||||
function updateLocalPeerState(targetPeerId, updates) {
|
||||
if (!currentRoom || !Array.isArray(currentRoom.peers)) return;
|
||||
const peer = currentRoom.peers.find(p => typeof p === 'object' ? p.peerId === targetPeerId : p === targetPeerId);
|
||||
if (peer && typeof peer === 'object') {
|
||||
Object.keys(updates).forEach(key => {
|
||||
if (updates[key] !== undefined && updates[key] !== null) {
|
||||
peer[key] = updates[key];
|
||||
}
|
||||
});
|
||||
peer.lastReactiveUpdate = Date.now(); // Race condition guard lock
|
||||
if (updates.currentTime !== undefined && updates.currentTime !== null) {
|
||||
peer.lastHeartbeat = Date.now(); // reset time interpolation baseline
|
||||
}
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function getPeerId() {
|
||||
const data = await chrome.storage.local.get(['peerId']);
|
||||
if (data.peerId) return data.peerId;
|
||||
@@ -146,15 +178,35 @@ async function getPeerId() {
|
||||
}
|
||||
|
||||
async function getSettings() {
|
||||
return new Promise(resolve => {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username'], (data) => {
|
||||
resolve({
|
||||
serverUrl: data.serverUrl || '',
|
||||
useCustomServer: data.useCustomServer || false,
|
||||
roomId: data.roomId || '',
|
||||
password: data.password || '',
|
||||
username: data.username || ''
|
||||
});
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
}
|
||||
let username = data.username;
|
||||
if (!username) {
|
||||
const adjs = ['Happy', 'Cool', 'Fast', 'Smart', 'Brave', 'Calm', 'Sneaky', 'Lazy', 'Wild', 'Chill', 'Lucky', 'Epic', 'Swift', 'Bold', 'Mighty', 'Cosmic', 'Neon', 'Shadow', 'Crystal', 'Thunder', 'Silent', 'Golden', 'Fierce', 'Noble', 'Mystic', 'Frozen', 'Blazing', 'Sapphire', 'Iron', 'Crimson'];
|
||||
const nouns = ['Koala', 'Panda', 'Tiger', 'Eagle', 'Fox', 'Bear', 'Wolf', 'Lion', 'Hawk', 'Seal', 'Owl', 'Shark', 'Dragon', 'Phoenix', 'Falcon', 'Panther', 'Raven', 'Cobra', 'Lynx', 'Jaguar', 'Orca', 'Mantis', 'Viper', 'Condor', 'Badger', 'Otter', 'Rhino', 'Crane', 'Mongoose', 'Specter'];
|
||||
username = `${adjs[Math.floor(Math.random() * adjs.length)]}${nouns[Math.floor(Math.random() * nouns.length)]}`;
|
||||
chrome.storage.sync.set({ username }, () => {
|
||||
resolve({
|
||||
serverUrl: data.serverUrl || '',
|
||||
useCustomServer: data.useCustomServer || false,
|
||||
roomId: data.roomId || '',
|
||||
password: data.password || '',
|
||||
username: username
|
||||
});
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
serverUrl: data.serverUrl || '',
|
||||
useCustomServer: data.useCustomServer || false,
|
||||
roomId: data.roomId || '',
|
||||
password: data.password || '',
|
||||
username: username
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -216,7 +268,7 @@ async function connect() {
|
||||
return;
|
||||
}
|
||||
|
||||
broadcastConnectionStatus('connecting');
|
||||
broadcastConnectionStatus('reconnecting');
|
||||
const isCustomServer = settings.serverUrl && settings.useCustomServer;
|
||||
finalUrl = isCustomServer ? settings.serverUrl : OFFICIAL_SERVER_URL;
|
||||
|
||||
@@ -239,7 +291,7 @@ async function connect() {
|
||||
throw new Error(`[URL Error] ${e.message}`);
|
||||
}
|
||||
|
||||
addLog(`Connecting to ${isCustomServer ? finalUrl : 'Official Server'}...`, 'info');
|
||||
addLog(`Connecting to ${isCustomServer ? finalUrl : 'Official Server'}... (attempt ${reconnectAttempts + 1})`, 'info');
|
||||
|
||||
// --- Phase 4: WebSocket Init ---
|
||||
try {
|
||||
@@ -257,10 +309,11 @@ async function connect() {
|
||||
|
||||
// --- Phase 5: Event Listeners ---
|
||||
socket.onopen = () => {
|
||||
reconnectDelay = 1000;
|
||||
reconnectAttempts = 0;
|
||||
addLog('WebSocket Connection Opened', 'success');
|
||||
reconnectStartTime = null;
|
||||
reconnectFailed = false;
|
||||
chrome.storage.session.set({ reconnectFailed: false });
|
||||
isNamespaceJoined = false;
|
||||
socket.send('40');
|
||||
};
|
||||
@@ -299,8 +352,12 @@ async function connect() {
|
||||
} else if (msg.startsWith('42')) {
|
||||
try {
|
||||
const payload = JSON.parse(msg.substring(2));
|
||||
handleServerEvent(payload[0], payload[1]);
|
||||
} catch (e) {
|
||||
try {
|
||||
handleServerEvent(payload[0], payload[1]);
|
||||
} catch (handlerErr) {
|
||||
addLog(`Handler error for ${payload[0]}: ${handlerErr.message}`, 'error');
|
||||
}
|
||||
} catch (_e) {
|
||||
addLog(`Failed to parse message: ${msg}`, 'error');
|
||||
}
|
||||
}
|
||||
@@ -310,7 +367,6 @@ async function connect() {
|
||||
isConnecting = false;
|
||||
isNamespaceJoined = false;
|
||||
|
||||
// Clear Force Sync state
|
||||
isForceSyncInitiator = false;
|
||||
forceSyncAcks.clear();
|
||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||
@@ -319,20 +375,22 @@ async function connect() {
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null
|
||||
});
|
||||
|
||||
|
||||
if (currentRoom) {
|
||||
currentRoom.peers = [];
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
}
|
||||
broadcastConnectionStatus('disconnected');
|
||||
addLog(`Disconnected. Retrying in ${reconnectDelay / 1000}s...`, 'warn');
|
||||
addLog('Disconnected. Scheduling reconnect...', 'warn');
|
||||
socket = null;
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
socket.onerror = (err) => {
|
||||
socket.onerror = () => {
|
||||
broadcastConnectionStatus('disconnected');
|
||||
addLog(`WebSocket Error: ${err.message || 'Handshake failed or server unreachable'}`, 'error');
|
||||
socket.close();
|
||||
addLog('WebSocket Error: Connection failed', 'error');
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
@@ -351,11 +409,15 @@ function broadcastConnectionStatus(status) {
|
||||
|
||||
function updateBadgeStatus() {
|
||||
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
|
||||
const status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : 'disconnected');
|
||||
const isReconnecting = !isConnected && reconnectAttempts > 0 && !reconnectFailed;
|
||||
const status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
|
||||
|
||||
if (reconnectFailed) {
|
||||
chrome.action.setBadgeText({ text: 'ERR' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#ef4444' });
|
||||
} else if (status === 'reconnecting') {
|
||||
chrome.action.setBadgeText({ text: '...' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#f59e0b' });
|
||||
} else if (status === 'connecting') {
|
||||
chrome.action.setBadgeText({ text: '...' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#fbbf24' });
|
||||
@@ -368,48 +430,73 @@ function updateBadgeStatus() {
|
||||
}
|
||||
|
||||
function showNotification(senderName, action) {
|
||||
const label = action === 'play' ? 'started playback' :
|
||||
action === 'pause' ? 'paused playback' :
|
||||
action === 'seek' ? 'seeked the video' :
|
||||
action === 'force_sync_execute' ? 'synchronized everyone' : action;
|
||||
|
||||
// Find username in current room if available
|
||||
let displayName = senderName || 'A peer';
|
||||
if (currentRoom && currentRoom.peers) {
|
||||
const peer = currentRoom.peers.find(p => (p.peerId || p) === senderName);
|
||||
if (peer && peer.username) displayName = peer.username;
|
||||
}
|
||||
chrome.storage.sync.get(['browserNotifications'], (settings) => {
|
||||
if (!settings.browserNotifications) return;
|
||||
|
||||
chrome.notifications.create(`sync_${Date.now()}`, {
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon128.png',
|
||||
title: 'KoalaSync',
|
||||
message: `${displayName} ${label}.`,
|
||||
priority: 1
|
||||
const label = action === 'play' ? 'started playback' :
|
||||
action === 'pause' ? 'paused playback' :
|
||||
action === 'seek' ? 'seeked the video' :
|
||||
action === 'force_sync_prepare' ? 'started force sync' :
|
||||
action === 'force_sync_execute' ? 'synchronized everyone' : action;
|
||||
|
||||
let displayName = senderName || 'A peer';
|
||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||
const peer = currentRoom.peers.find(p => (p.peerId || p) === senderName);
|
||||
if (peer && peer.username) displayName = peer.username;
|
||||
}
|
||||
|
||||
chrome.notifications.create(`sync_${Date.now()}`, {
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon128.png',
|
||||
title: 'KoalaSync',
|
||||
message: `${displayName} ${label}.`,
|
||||
priority: 1
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer || reconnectFailed) return;
|
||||
|
||||
if (reconnectTimer) return;
|
||||
|
||||
if (reconnectFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reconnectStartTime) reconnectStartTime = Date.now();
|
||||
|
||||
// Check 5 minute cap (300,000ms)
|
||||
if (Date.now() - reconnectStartTime > 300000) {
|
||||
reconnectFailed = true;
|
||||
chrome.storage.session.set({ reconnectFailed: true });
|
||||
addLog('Reconnection failed after 5 minutes. Please try again manually.', 'error');
|
||||
addLog('Reconnection failed after 5 minutes.', 'error');
|
||||
broadcastConnectionStatus('reconnect_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
reconnectAttempts++;
|
||||
|
||||
// Cap at max attempts to prevent infinite loops
|
||||
if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnectFailed = true;
|
||||
chrome.storage.session.set({ reconnectFailed: true });
|
||||
addLog('Reconnection failed after max attempts.', 'error');
|
||||
broadcastConnectionStatus('reconnect_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Aggressive reconnect: 500ms base, cap at 5s, no exponential growth beyond that
|
||||
const delay = Math.min(_RECONNECT_BASE_DELAY * Math.pow(1.5, reconnectAttempts - 1), _RECONNECT_MAX_DELAY);
|
||||
|
||||
addLog(`Reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`, 'warn');
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// Slow reconnect logic is now handled in the keepAlive alarm
|
||||
|
||||
function emit(event, data) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
|
||||
const msg = `42${JSON.stringify([event, data])}`;
|
||||
@@ -445,8 +532,13 @@ function handleServerEvent(event, data) {
|
||||
switch (event) {
|
||||
case EVENTS.ROOM_DATA:
|
||||
currentRoom = data;
|
||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||
currentRoom.peers = currentRoom.peers.map(p => typeof p === 'object' ? createPeerData(p) : { peerId: p, username: null, tabTitle: null, mediaTitle: null, playbackState: null, currentTime: null, volume: null, muted: null, lastHeartbeat: Date.now() });
|
||||
} else if (currentRoom) {
|
||||
currentRoom.peers = [];
|
||||
}
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
addLog(`Joined Room: ${data.roomId}`, 'success');
|
||||
addLog(`Joined Room: ${data?.roomId || 'unknown'}`, 'success');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: data.peers }).catch(() => {});
|
||||
|
||||
// Inform Website Bridge & Popup
|
||||
@@ -488,6 +580,14 @@ function handleServerEvent(event, data) {
|
||||
addToHistory(event, data.senderId);
|
||||
showNotification(data.senderId, event);
|
||||
updateLastAction(event, data.senderId);
|
||||
lastActionState.targetTime = data.targetTime !== undefined ? data.targetTime : data.currentTime;
|
||||
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
||||
|
||||
// Remote Reactive Update
|
||||
updateLocalPeerState(data.senderId, {
|
||||
playbackState: event === EVENTS.PLAY ? 'playing' : (event === EVENTS.PAUSE ? 'paused' : undefined),
|
||||
currentTime: data.currentTime !== undefined ? data.currentTime : (data.targetTime !== undefined ? data.targetTime : undefined)
|
||||
});
|
||||
}
|
||||
routeToContent(event, data);
|
||||
break;
|
||||
@@ -505,24 +605,46 @@ function handleServerEvent(event, data) {
|
||||
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
||||
chrome.runtime.sendMessage({ type: 'ACTION_UPDATE', state: lastActionState }).catch(() => {});
|
||||
}
|
||||
|
||||
// Force Sync ACK Reactive Update
|
||||
updateLocalPeerState(data.senderId, {
|
||||
playbackState: 'paused', // Preparing for force sync always pauses the player
|
||||
currentTime: lastActionState.targetTime
|
||||
});
|
||||
}
|
||||
|
||||
// Check if all peers responded
|
||||
const peerCount = currentRoom ? currentRoom.peers.length : 1;
|
||||
const peerCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1;
|
||||
if (forceSyncAcks.size >= peerCount) {
|
||||
executeForceSync();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case EVENTS.FORCE_SYNC_EXECUTE:
|
||||
if (data.senderId) {
|
||||
if (data?.senderId) {
|
||||
addToHistory(event, data.senderId);
|
||||
showNotification(data.senderId, event);
|
||||
|
||||
// (The sender's state is updated below with everyone else)
|
||||
}
|
||||
|
||||
// Force Sync Execute Remote Reactive Update:
|
||||
// Set all peers to playing and apply a reactive lock to block stale heartbeats
|
||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||
currentRoom.peers.forEach(peer => {
|
||||
if (peer && typeof peer === 'object') {
|
||||
peer.playbackState = 'playing';
|
||||
peer.lastReactiveUpdate = Date.now();
|
||||
}
|
||||
});
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
}
|
||||
|
||||
routeToContent(event, data);
|
||||
break;
|
||||
case EVENTS.EVENT_ACK:
|
||||
if (lastActionState && lastActionState.action && data.senderId) {
|
||||
if (lastActionState && lastActionState.action && data?.senderId) {
|
||||
// Correlation Check: Only accept ACK if it matches our current action's timestamp
|
||||
if (data.actionTimestamp === lastActionState.timestamp) {
|
||||
if (!Array.isArray(lastActionState.acks)) lastActionState.acks = [];
|
||||
@@ -530,6 +652,12 @@ function handleServerEvent(event, data) {
|
||||
lastActionState.acks.push(data.senderId);
|
||||
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
||||
chrome.runtime.sendMessage({ type: 'ACTION_UPDATE', state: lastActionState }).catch(() => {});
|
||||
|
||||
// ACK Reactive Update
|
||||
updateLocalPeerState(data.senderId, {
|
||||
playbackState: lastActionState.action === EVENTS.PLAY ? 'playing' : (lastActionState.action === EVENTS.PAUSE ? 'paused' : undefined),
|
||||
currentTime: (lastActionState.action === EVENTS.SEEK || lastActionState.action === EVENTS.FORCE_SYNC_PREPARE) ? lastActionState.targetTime : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -542,29 +670,44 @@ function handleServerEvent(event, data) {
|
||||
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 = Array.isArray(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);
|
||||
const peer = currentRoom.peers.find(p => (typeof p === 'object' ? p.peerId : p) === data.peerId);
|
||||
if (peer) {
|
||||
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();
|
||||
|
||||
const timeSinceReactive = peer.lastReactiveUpdate ? (Date.now() - peer.lastReactiveUpdate) : Infinity;
|
||||
const ignoreStatus = timeSinceReactive < 1000;
|
||||
|
||||
if (!ignoreStatus) {
|
||||
peer.playbackState = data.playbackState !== undefined ? data.playbackState : peer.playbackState;
|
||||
peer.currentTime = data.currentTime !== undefined ? data.currentTime : peer.currentTime;
|
||||
peer.lastHeartbeat = Date.now();
|
||||
}
|
||||
} else {
|
||||
// Migration: replace string peer with normalized object
|
||||
const idx = currentRoom.peers.indexOf(peer);
|
||||
@@ -636,8 +779,24 @@ function executeForceSync() {
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null
|
||||
});
|
||||
emit(EVENTS.FORCE_SYNC_EXECUTE, {});
|
||||
routeToContent(EVENTS.FORCE_SYNC_EXECUTE, {});
|
||||
|
||||
// Set all peers to playing and apply a reactive lock to block stale heartbeats
|
||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||
currentRoom.peers.forEach(peer => {
|
||||
if (peer && typeof peer === 'object') {
|
||||
peer.playbackState = 'playing';
|
||||
peer.lastReactiveUpdate = Date.now();
|
||||
}
|
||||
});
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
}
|
||||
|
||||
const executionTimestamp = Date.now();
|
||||
updateLastAction(EVENTS.FORCE_SYNC_EXECUTE, 'You', executionTimestamp);
|
||||
|
||||
emit(EVENTS.FORCE_SYNC_EXECUTE, { actionTimestamp: executionTimestamp });
|
||||
routeToContent(EVENTS.FORCE_SYNC_EXECUTE, { actionTimestamp: executionTimestamp });
|
||||
addLog('Force Sync Executed', 'success');
|
||||
}
|
||||
|
||||
@@ -688,10 +847,13 @@ function executeEpisodeLobby() {
|
||||
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() + 5000;
|
||||
const deadline = Date.now() + FORCE_SYNC_TIMEOUT;
|
||||
const timestamp = Date.now();
|
||||
updateLastAction(EVENTS.FORCE_SYNC_PREPARE, 'You', timestamp);
|
||||
lastActionState.targetTime = 0.0;
|
||||
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: true,
|
||||
forceSyncAcks: [],
|
||||
@@ -699,15 +861,15 @@ function executeEpisodeLobby() {
|
||||
});
|
||||
|
||||
const syncPayload = { targetTime: 0.0 };
|
||||
emit(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, peerId });
|
||||
routeToContent(EVENTS.FORCE_SYNC_PREPARE, syncPayload);
|
||||
emit(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, peerId, actionTimestamp: timestamp });
|
||||
routeToContent(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, actionTimestamp: timestamp });
|
||||
|
||||
forceSyncTimeout = setTimeout(() => {
|
||||
if (isForceSyncInitiator) {
|
||||
addLog('Force Sync (Episode): Timeout waiting for ACKs, executing anyway...', 'warn');
|
||||
executeForceSync();
|
||||
}
|
||||
}, 5000);
|
||||
}, FORCE_SYNC_TIMEOUT);
|
||||
}
|
||||
|
||||
function checkEpisodeLobbyCompletion() {
|
||||
@@ -720,6 +882,7 @@ function checkEpisodeLobbyCompletion() {
|
||||
|
||||
function checkEpisodeLobbyPeerDeparture() {
|
||||
if (!episodeLobby || !currentRoom) return;
|
||||
if (!Array.isArray(currentRoom.peers)) return;
|
||||
const remainingPeerIds = currentRoom.peers.map(p => typeof p === 'object' ? p.peerId : p);
|
||||
|
||||
// If only we remain, cancel the lobby
|
||||
@@ -754,23 +917,23 @@ async function routeToContent(action, payload) {
|
||||
const tabId = parseInt(currentTabId);
|
||||
if (isNaN(tabId)) return;
|
||||
|
||||
currentCommandSenderId = payload.senderId || null;
|
||||
const actionTimestamp = payload.actionTimestamp || Date.now();
|
||||
const actionTimestamp = payload?.actionTimestamp || Date.now();
|
||||
const commandSenderId = payload?.senderId || null;
|
||||
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
type: 'SERVER_COMMAND',
|
||||
action,
|
||||
payload,
|
||||
actionTimestamp
|
||||
actionTimestamp,
|
||||
commandSenderId
|
||||
}).catch(err => {
|
||||
// Auto-Reinject if content script is missing or extension was reloaded
|
||||
if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['content.js']
|
||||
}).then(() => {
|
||||
setTimeout(() => routeToContent(action, payload), 500);
|
||||
}).catch(err => {
|
||||
}).catch(_err => {
|
||||
addLog(`Auto-reinject failed for tab ${tabId}`, 'warn');
|
||||
});
|
||||
} else {
|
||||
@@ -782,13 +945,15 @@ async function routeToContent(action, payload) {
|
||||
}
|
||||
|
||||
// --- Keep-Alive Mechanism ---
|
||||
chrome.alarms.create('keepAlive', { periodInMinutes: 1 });
|
||||
chrome.alarms.create('keepAlive', { periodInMinutes: 0.5 });
|
||||
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
await ensureState();
|
||||
if (alarm.name === 'keepAlive') {
|
||||
chrome.storage.session.get('keepAlive', () => {});
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
connect();
|
||||
if (!reconnectFailed) {
|
||||
connect();
|
||||
}
|
||||
} else if (currentRoom) {
|
||||
// Heartbeat Logic: Always include identity metadata
|
||||
const settings = await getSettings();
|
||||
@@ -802,7 +967,30 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
}
|
||||
});
|
||||
|
||||
function leaveOldRoomIfSwitching(newRoomId) {
|
||||
if (currentRoom && currentRoom.roomId !== newRoomId) {
|
||||
addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
|
||||
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
}
|
||||
currentRoom = null;
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom: null });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
|
||||
// Reset force sync states
|
||||
isForceSyncInitiator = false;
|
||||
forceSyncAcks.clear();
|
||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: false,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null
|
||||
});
|
||||
|
||||
// Cancel any active episode lobby
|
||||
clearEpisodeLobbyState();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Extension Message Listeners ---
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
@@ -816,9 +1004,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
if (message.type === 'CONNECT') {
|
||||
reconnectFailed = false;
|
||||
reconnectStartTime = null;
|
||||
reconnectAttempts = 0;
|
||||
chrome.storage.session.set({ reconnectFailed: false });
|
||||
const settings = await getSettings();
|
||||
if (settings.roomId) {
|
||||
leaveOldRoomIfSwitching(settings.roomId);
|
||||
}
|
||||
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
|
||||
// Already connected, but maybe room changed or we need to refresh room state
|
||||
const settings = await getSettings();
|
||||
if (settings.roomId) {
|
||||
emit(EVENTS.JOIN_ROOM, {
|
||||
roomId: settings.roomId,
|
||||
@@ -836,12 +1028,18 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
} else if (message.type === 'RETRY_CONNECT') {
|
||||
reconnectFailed = false;
|
||||
reconnectStartTime = null;
|
||||
reconnectDelay = 1000;
|
||||
reconnectAttempts = 0;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
chrome.storage.session.set({ reconnectFailed: false });
|
||||
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');
|
||||
const isReconnecting = !isConnected && reconnectAttempts > 0 && !reconnectFailed;
|
||||
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
|
||||
if (reconnectFailed) status = 'reconnect_failed';
|
||||
sendResponse({
|
||||
status,
|
||||
@@ -849,7 +1047,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
peers: currentRoom ? currentRoom.peers : [],
|
||||
lastActionState,
|
||||
targetTabId: currentTabId,
|
||||
episodeLobby: episodeLobby
|
||||
episodeLobby: episodeLobby,
|
||||
reconnectAttempts
|
||||
});
|
||||
} else if (message.type === 'LEAVE_ROOM') {
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
@@ -883,9 +1082,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
} else if (message.type === 'GET_HISTORY') {
|
||||
sendResponse(history);
|
||||
} else if (message.type === 'GET_ROOM_LIST') {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(`42${JSON.stringify([EVENTS.GET_ROOMS])}`);
|
||||
}
|
||||
emit(EVENTS.GET_ROOMS, {});
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'WEB_JOIN_REQUEST') {
|
||||
const { roomId, password, useCustomServer, serverUrl } = message;
|
||||
@@ -896,6 +1093,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
serverUrl: serverUrl || ''
|
||||
}, async () => {
|
||||
broadcastConnectionStatus('connecting');
|
||||
leaveOldRoomIfSwitching(roomId);
|
||||
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
|
||||
// FORCE TRANSITION: Emit Join Room directly if already connected
|
||||
const settings = await getSettings();
|
||||
@@ -938,12 +1136,20 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
const processEvent = () => {
|
||||
const timestamp = Date.now();
|
||||
updateLastAction(message.action, 'You', timestamp);
|
||||
lastActionState.targetTime = message.payload?.targetTime !== undefined ? message.payload.targetTime : message.payload?.currentTime;
|
||||
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
||||
message.payload.actionTimestamp = timestamp;
|
||||
|
||||
// Local Reactive Update
|
||||
updateLocalPeerState(peerId, {
|
||||
playbackState: message.action === EVENTS.PLAY ? 'playing' : (message.action === EVENTS.PAUSE ? 'paused' : undefined),
|
||||
currentTime: message.payload.currentTime !== undefined ? message.payload.currentTime : (message.payload.targetTime !== undefined ? message.payload.targetTime : undefined)
|
||||
});
|
||||
|
||||
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
|
||||
isForceSyncInitiator = true;
|
||||
forceSyncAcks.clear();
|
||||
const deadline = Date.now() + 5000;
|
||||
const deadline = Date.now() + FORCE_SYNC_TIMEOUT;
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: true,
|
||||
forceSyncAcks: [],
|
||||
@@ -958,7 +1164,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
addLog('Force Sync: Timeout waiting for ACKs, executing anyway...', 'warn');
|
||||
executeForceSync();
|
||||
}
|
||||
}, 5000);
|
||||
}, FORCE_SYNC_TIMEOUT);
|
||||
}
|
||||
addToHistory(message.action, 'You');
|
||||
emit(message.action, { ...message.payload, peerId });
|
||||
@@ -986,7 +1192,16 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
forceSyncAcks.add(peerId);
|
||||
chrome.storage.session.set({ forceSyncAcks: Array.from(forceSyncAcks) });
|
||||
addLog(`Local ACK received (${forceSyncAcks.size})`, 'info');
|
||||
const peerCount = currentRoom ? currentRoom.peers.length : 1;
|
||||
|
||||
// Local Force Sync ACK Reactive Update
|
||||
if (lastActionState && lastActionState.action === EVENTS.FORCE_SYNC_PREPARE) {
|
||||
updateLocalPeerState(peerId, {
|
||||
playbackState: 'paused',
|
||||
currentTime: lastActionState.targetTime
|
||||
});
|
||||
}
|
||||
|
||||
const peerCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1;
|
||||
if (forceSyncAcks.size >= peerCount) {
|
||||
executeForceSync();
|
||||
}
|
||||
@@ -995,11 +1210,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
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) {
|
||||
const commandSenderId = message.commandSenderId;
|
||||
if (commandSenderId && commandSenderId !== peerId) {
|
||||
emit(EVENTS.EVENT_ACK, {
|
||||
senderId: peerId,
|
||||
targetId: currentCommandSenderId,
|
||||
targetId: commandSenderId,
|
||||
actionTimestamp: message.actionTimestamp
|
||||
});
|
||||
}
|
||||
@@ -1022,21 +1237,25 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle };
|
||||
emit(EVENTS.PEER_STATUS, statusPayload);
|
||||
|
||||
if (currentRoom && currentRoom.peers) {
|
||||
if (currentRoom && Array.isArray(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.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();
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
}
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
}).catch(err => {
|
||||
addLog('Heartbeat settings error: ' + err.message, 'error');
|
||||
sendResponse({ status: 'ok' });
|
||||
});
|
||||
} else if (message.type === 'SET_TARGET_TAB') {
|
||||
currentTabId = message.tabId;
|
||||
@@ -1075,7 +1294,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
|
||||
// Check setting
|
||||
const epSettings = await chrome.storage.sync.get(['autoSyncNextEpisode']);
|
||||
if (!epSettings.autoSyncNextEpisode) {
|
||||
if (epSettings.autoSyncNextEpisode === false) {
|
||||
addLog(`Episode change detected ("${newTitle}") but Auto-Sync is disabled.`, 'info');
|
||||
sendResponse({ status: 'disabled' });
|
||||
return;
|
||||
@@ -1165,7 +1384,7 @@ chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
});
|
||||
|
||||
// Re-inject on full page refresh
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, _tab) => {
|
||||
if (currentTabId && tabId === parseInt(currentTabId) && changeInfo.status === 'complete') {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* KoalaSync Bridge Script
|
||||
* Injected into koalasync.shik3i.net to facilitate communication between
|
||||
* Injected into sync.koalastuff.net to facilitate communication between
|
||||
* the landing page and the extension.
|
||||
*/
|
||||
|
||||
|
||||
@@ -9,12 +9,13 @@
|
||||
if (window.koalaSyncInjected && chrome.runtime.id) {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// Context invalidated, proceed with re-injection
|
||||
}
|
||||
window.koalaSyncInjected = true;
|
||||
|
||||
// Local Protocol Constants (Mirroring shared/constants.js)
|
||||
// --- SHARED_EVENTS_INJECT_START ---
|
||||
// This block is automatically updated by /scripts/build-extension.js
|
||||
const EVENTS = {
|
||||
PLAY: "play",
|
||||
PAUSE: "pause",
|
||||
@@ -26,6 +27,7 @@
|
||||
EPISODE_LOBBY: "episode_lobby",
|
||||
EPISODE_READY: "episode_ready"
|
||||
};
|
||||
// --- SHARED_EVENTS_INJECT_END ---
|
||||
|
||||
let expectedEvents = new Set();
|
||||
let expectedTimeouts = {};
|
||||
@@ -40,15 +42,19 @@
|
||||
// --- Episode Auto-Sync State ---
|
||||
let lastKnownMediaTitle = null;
|
||||
let episodeTransitionDebounce = null;
|
||||
let pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby)
|
||||
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]);
|
||||
if (expectedTimeouts[state]) {
|
||||
clearTimeout(expectedTimeouts[state]);
|
||||
delete expectedTimeouts[state];
|
||||
}
|
||||
const timeout = state === 'seek' ? 10000 : 1500;
|
||||
expectedTimeouts[state] = setTimeout(() => {
|
||||
expectedEvents.delete(state);
|
||||
delete expectedTimeouts[state];
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
@@ -57,9 +63,16 @@
|
||||
}
|
||||
|
||||
// --- Helper: find the best video element on the page ---
|
||||
function findVideo() {
|
||||
const videos = document.querySelectorAll('video');
|
||||
return videos.length > 0 ? videos[0] : null;
|
||||
function findVideo(root = document) {
|
||||
const video = root.querySelector('video');
|
||||
if (video) return video;
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
if (el.shadowRoot) {
|
||||
const found = findVideo(el.shadowRoot);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Episode Auto-Sync: Detection ---
|
||||
@@ -129,7 +142,7 @@
|
||||
|
||||
function startLobbyPoll(expectedTitle) {
|
||||
stopLobbyPoll();
|
||||
pendingLobbyTitle = expectedTitle;
|
||||
_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.
|
||||
@@ -149,7 +162,7 @@
|
||||
|
||||
|
||||
function stopLobbyPoll() {
|
||||
pendingLobbyTitle = null;
|
||||
_pendingLobbyTitle = null;
|
||||
if (lobbyPollTimer) {
|
||||
clearInterval(lobbyPollTimer);
|
||||
lobbyPollTimer = null;
|
||||
@@ -167,7 +180,7 @@
|
||||
reportLog(`Media Action Error: Invalid seek payload - ${JSON.stringify(data)}`, 'error');
|
||||
return;
|
||||
}
|
||||
data.targetTime = target;
|
||||
data = { ...data, targetTime: target };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -221,7 +234,7 @@
|
||||
expectEvent('seek');
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e) {
|
||||
reportLog(`Media Action Error: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
@@ -257,22 +270,26 @@
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.action === 'get_current_time') {
|
||||
const video = findVideo();
|
||||
sendResponse({ currentTime: video ? video.currentTime : undefined });
|
||||
sendResponse({ currentTime: video ? video.currentTime : null });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'SERVER_COMMAND') {
|
||||
const { action, payload } = message;
|
||||
let actionCompleted = false;
|
||||
|
||||
if (action === EVENTS.PLAY) {
|
||||
tryMediaAction(EVENTS.PLAY);
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
|
||||
actionCompleted = true;
|
||||
} else if (action === EVENTS.PAUSE) {
|
||||
tryMediaAction(EVENTS.PAUSE);
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
|
||||
actionCompleted = true;
|
||||
} else if (action === EVENTS.SEEK) {
|
||||
tryMediaAction(EVENTS.SEEK, payload);
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
|
||||
actionCompleted = true;
|
||||
} else if (action === EVENTS.FORCE_SYNC_PREPARE) {
|
||||
if (!payload || payload.targetTime === undefined) return;
|
||||
const video = findVideo();
|
||||
@@ -286,13 +303,23 @@
|
||||
video.pause();
|
||||
video.currentTime = payload.targetTime;
|
||||
pollSeekReady(payload.targetTime).then((ready) => {
|
||||
if (ready) chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' });
|
||||
});
|
||||
chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' }).catch(() => {});
|
||||
if (ready) {
|
||||
scheduleProactiveHeartbeat();
|
||||
} else {
|
||||
reportLog('Force Sync: Seek ready timeout, proceeding anyway', 'warn');
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
} else if (action === EVENTS.FORCE_SYNC_EXECUTE) {
|
||||
stopLobbyPoll(); // Clear any pending lobby on force sync
|
||||
stopLobbyPoll();
|
||||
tryMediaAction(EVENTS.PLAY);
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
|
||||
actionCompleted = true;
|
||||
}
|
||||
|
||||
if (actionCompleted) {
|
||||
scheduleProactiveHeartbeat();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,8 +410,12 @@
|
||||
const eventState = action === EVENTS.PLAY ? 'playing' : (action === EVENTS.PAUSE ? 'paused' : (action === EVENTS.SEEK ? 'seek' : null));
|
||||
|
||||
if (eventState && expectedEvents.has(eventState)) {
|
||||
expectedEvents.delete(eventState); // Consume the match
|
||||
return; // Ignore event caused by our programmatic action
|
||||
expectedEvents.delete(eventState);
|
||||
if (expectedTimeouts[eventState]) {
|
||||
clearTimeout(expectedTimeouts[eventState]);
|
||||
delete expectedTimeouts[eventState];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.runtime.sendMessage({
|
||||
@@ -397,6 +428,9 @@
|
||||
timestamp: Date.now()
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger proactive heartbeat to push stabilized state
|
||||
scheduleProactiveHeartbeat();
|
||||
}
|
||||
|
||||
const handlePlay = () => reportEvent(EVENTS.PLAY);
|
||||
@@ -413,7 +447,11 @@
|
||||
// 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
|
||||
if (expectedTimeouts['seek']) {
|
||||
clearTimeout(expectedTimeouts['seek']);
|
||||
delete expectedTimeouts['seek'];
|
||||
}
|
||||
lastReportedSeekTime = current;
|
||||
// No log — this is routine programmatic behavior (Force Sync, lobby, peer command)
|
||||
return;
|
||||
}
|
||||
@@ -444,7 +482,7 @@
|
||||
};
|
||||
|
||||
|
||||
let lastVideoSrc = null;
|
||||
let lastVideoSrc = undefined;
|
||||
|
||||
// Episode detection handler for loadeddata event
|
||||
const handleLoadedData = () => {
|
||||
@@ -454,19 +492,22 @@
|
||||
function setupListeners() {
|
||||
const video = findVideo();
|
||||
if (video) {
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
video.removeEventListener('seeked', handleSeeked);
|
||||
video.removeEventListener('loadeddata', handleLoadedData);
|
||||
const existing = video._koalaHandlers;
|
||||
if (existing) {
|
||||
video.removeEventListener('play', existing.play);
|
||||
video.removeEventListener('pause', existing.pause);
|
||||
video.removeEventListener('seeked', existing.seeked);
|
||||
video.removeEventListener('loadeddata', existing.loadeddata);
|
||||
}
|
||||
video._koalaHandlers = { play: handlePlay, pause: handlePause, seeked: handleSeeked, 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;
|
||||
lastVideoSrc = video.currentSrc || video.src || null;
|
||||
|
||||
// Initialize episode tracking title on first attach
|
||||
if (!lastKnownMediaTitle) {
|
||||
lastKnownMediaTitle = getMediaTitle();
|
||||
}
|
||||
@@ -480,13 +521,19 @@
|
||||
function checkVideo() {
|
||||
lastMutate = Date.now();
|
||||
const video = findVideo();
|
||||
|
||||
if (!video && lastVideoSrc !== undefined) {
|
||||
reportLog('Video element removed from page', 'warn');
|
||||
lastVideoSrc = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!video) return;
|
||||
|
||||
const currentSrc = video.currentSrc || video.src;
|
||||
const currentSrc = video.currentSrc || video.src || null;
|
||||
|
||||
if (!video.dataset.koalaAttached || (lastVideoSrc && currentSrc && lastVideoSrc !== currentSrc)) {
|
||||
// If src changed, also check for episode transition
|
||||
if (lastVideoSrc && currentSrc && lastVideoSrc !== currentSrc) {
|
||||
if (!video.dataset.koalaAttached || (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc)) {
|
||||
if (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc) {
|
||||
checkEpisodeTransition();
|
||||
}
|
||||
setupListeners();
|
||||
@@ -502,41 +549,70 @@
|
||||
observerTimeout = setTimeout(checkVideo, 1000 - (now - lastMutate));
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
|
||||
// Heartbeat
|
||||
// --- SHARED_HEARTBEAT_INJECT_START ---
|
||||
const HEARTBEAT_INTERVAL_VAL = 15000;
|
||||
// --- SHARED_HEARTBEAT_INJECT_END ---
|
||||
|
||||
// Heartbeat Refactoring (Self-scheduling setTimeout with proactive heartbeat scheduling)
|
||||
let heartbeatTimeout = null;
|
||||
let proactiveHeartbeatTimeout = null;
|
||||
let heartbeatErrorCount = 0;
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
|
||||
function sendHeartbeat() {
|
||||
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,
|
||||
mediaTitle: mediaTitle,
|
||||
volume: video.volume,
|
||||
muted: video.muted
|
||||
if (!video) return;
|
||||
|
||||
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,
|
||||
mediaTitle: mediaTitle,
|
||||
volume: video.volume,
|
||||
muted: video.muted
|
||||
}
|
||||
}).catch(err => {
|
||||
if (err.message.includes('Extension context invalidated')) {
|
||||
heartbeatErrorCount++;
|
||||
if (heartbeatErrorCount === 1) {
|
||||
reportLog('Extension reloaded. Please refresh the page if sync stops working.', 'warn');
|
||||
}
|
||||
}).catch(err => {
|
||||
if (err.message.includes('Extension context invalidated')) {
|
||||
heartbeatErrorCount++;
|
||||
if (heartbeatErrorCount === 1) {
|
||||
reportLog('Extension reloaded. Please refresh the page if sync stops working.', 'warn');
|
||||
}
|
||||
clearInterval(heartbeatInterval);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 15000);
|
||||
if (heartbeatTimeout) clearTimeout(heartbeatTimeout);
|
||||
if (proactiveHeartbeatTimeout) clearTimeout(proactiveHeartbeatTimeout);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function schedulePeriodicHeartbeat() {
|
||||
if (heartbeatTimeout) clearTimeout(heartbeatTimeout);
|
||||
heartbeatTimeout = setTimeout(() => {
|
||||
sendHeartbeat();
|
||||
schedulePeriodicHeartbeat();
|
||||
}, HEARTBEAT_INTERVAL_VAL);
|
||||
}
|
||||
|
||||
function scheduleProactiveHeartbeat() {
|
||||
if (proactiveHeartbeatTimeout) clearTimeout(proactiveHeartbeatTimeout);
|
||||
proactiveHeartbeatTimeout = setTimeout(() => {
|
||||
sendHeartbeat();
|
||||
schedulePeriodicHeartbeat(); // Reschedules the next periodic check to be exactly 15s from now
|
||||
}, 500); // 500ms stabilization delay
|
||||
}
|
||||
|
||||
// Initial Setup
|
||||
setupListeners();
|
||||
schedulePeriodicHeartbeat();
|
||||
|
||||
// Immediate heartbeat on injection — populate peer data without waiting 15s
|
||||
setTimeout(() => sendHeartbeat(), 300);
|
||||
|
||||
// Episode Auto-Sync: Boot recovery — check if background has an active lobby
|
||||
chrome.runtime.sendMessage({ type: 'CONTENT_BOOT' }, (res) => {
|
||||
if (chrome.runtime.lastError) return;
|
||||
if (res && res.lobbyActive && res.expectedTitle) {
|
||||
reportLog(`Boot: Active lobby detected for "${res.expectedTitle}"`, 'info');
|
||||
startLobbyPoll(res.expectedTitle);
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 889 B |
|
After Width: | Height: | Size: 3.8 KiB |
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "KoalaSync",
|
||||
"version": "1.2.1",
|
||||
"description": "Synchronize video playback across different tabs and users.",
|
||||
"version": "1.7.3",
|
||||
"description": "Watch party extension to synchronize video playback on YouTube, Twitch, Netflix, and HTML5 sites in real-time with friends.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
"tabs",
|
||||
@@ -17,25 +17,25 @@
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon128.png",
|
||||
"48": "icons/icon128.png",
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["https://koalasync.shik3i.net/*"],
|
||||
"js": ["bridge.js"],
|
||||
"matches": [
|
||||
"https://sync.koalastuff.net/*"
|
||||
],
|
||||
"js": [
|
||||
"bridge.js"
|
||||
],
|
||||
"run_at": "document_start"
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "icons/icon128.png",
|
||||
"48": "icons/icon128.png",
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
}
|
||||
@@ -31,9 +31,19 @@
|
||||
font-size: 18px;
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--accent);
|
||||
text-align: center;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
h1 img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
@@ -189,10 +199,49 @@
|
||||
}
|
||||
.invite-box input { flex: 1; font-size: 11px; }
|
||||
.invite-box button { width: 40px; padding: 0; }
|
||||
|
||||
/* Toast Notifications */
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
max-width: 280px;
|
||||
text-align: center;
|
||||
animation: toastSlideIn 0.3s ease-out, toastFadeOut 0.3s ease-in 2.7s forwards;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
.toast-success { background: var(--success); color: white; }
|
||||
.toast-error { background: var(--error); color: white; }
|
||||
.toast-info { background: var(--accent); color: white; }
|
||||
.toast-warning { background: #f59e0b; color: white; }
|
||||
|
||||
@keyframes toastSlideIn {
|
||||
from { opacity: 0; transform: translateY(-20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes toastFadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; transform: translateY(-10px); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>KoalaSync</h1>
|
||||
<div id="toast-container"></div>
|
||||
<h1><img src="icons/icon128.png" alt="KoalaSync Logo">KoalaSync</h1>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="tab-room">Room</button>
|
||||
@@ -279,12 +328,18 @@
|
||||
<button id="pauseBtn" class="primary" style="flex:1; background: var(--error);">⏸ Pause</button>
|
||||
</div>
|
||||
|
||||
<button id="forceSyncBtn" class="primary" style="background: linear-gradient(135deg, #6366f1, #a855f7); width: 100%; margin-bottom: 15px;">⚡ Force Sync Everyone</button>
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 15px; align-items: stretch;">
|
||||
<button id="forceSyncBtn" class="primary" style="background: linear-gradient(135deg, #6366f1, #a855f7); flex: 1;">⚡ Force Sync</button>
|
||||
<select id="forceSyncMode" style="width: auto; min-width: 130px; background: var(--card); border: 1px solid #334155; color: white; padding: 10px 8px; border-radius: 8px; font-size: 11px; font-family: inherit; cursor: pointer; align-self: stretch;" title="Choose sync target">
|
||||
<option value="jump-to-others">Jump to Others</option>
|
||||
<option value="jump-to-me">Jump to Me</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- NEW: Last Action Status Card -->
|
||||
<label>Last Activity Status</label>
|
||||
<div id="lastActionCard" class="info-card" style="margin-bottom: 15px; min-height: 70px;">
|
||||
<div style="text-align:center; color: var(--text-muted); font-size: 11px; padding-top: 20px;">No recent commands</div>
|
||||
<div id="lastActionCard" class="info-card" style="margin-bottom: 15px; max-height: 120px; overflow-y: auto;">
|
||||
<div style="text-align:center; color: var(--text-muted); font-size: 10px;">No recent commands</div>
|
||||
</div>
|
||||
|
||||
<!-- Episode Auto-Sync Lobby Status -->
|
||||
@@ -316,6 +371,11 @@
|
||||
<label style="margin-bottom: 0;">Auto-Sync Next Episode</label>
|
||||
<input type="checkbox" id="autoSyncNextEpisode" style="width: auto;">
|
||||
</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;">Browser Notifications</label>
|
||||
<input type="checkbox" id="browserNotifications" style="width: auto;">
|
||||
</div>
|
||||
|
||||
<div style="font-size: 11px; color: var(--text-muted); padding: 8px;">
|
||||
<p>• Username helps others identify you.</p>
|
||||
@@ -365,5 +425,19 @@
|
||||
</div>
|
||||
|
||||
<script src="popup.js" type="module"></script>
|
||||
|
||||
<!-- Onboarding Overlay -->
|
||||
<div id="onboarding-overlay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:1000; align-items:center; justify-content:center;">
|
||||
<div id="onboarding-card" style="background:var(--card); padding:24px; border-radius:16px; max-width:280px; width:90%; text-align:center; box-shadow:0 8px 32px rgba(0,0,0,0.5);">
|
||||
<div id="onboarding-icon" style="font-size:48px; margin-bottom:12px;">\u{1F44B}</div>
|
||||
<h2 id="onboarding-title" style="color:var(--accent); margin:0 0 8px; font-size:16px;">Welcome to KoalaSync!</h2>
|
||||
<p id="onboarding-text" style="color:var(--text-muted); font-size:13px; margin:0 0 16px; line-height:1.4;">Let's get you started.</p>
|
||||
<div style="display:flex; gap:8px; justify-content:center;">
|
||||
<button id="onboarding-skip" class="secondary" style="width:auto; padding:8px 16px;">Skip</button>
|
||||
<button id="onboarding-next" class="primary" style="width:auto; padding:8px 16px;">Next</button>
|
||||
</div>
|
||||
<div id="onboarding-dots" style="margin-top:12px; display:flex; gap:6px; justify-content:center;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,6 +8,7 @@ const elements = {
|
||||
copyInvite: document.getElementById('copyInvite'),
|
||||
targetTab: document.getElementById('targetTab'),
|
||||
forceSyncBtn: document.getElementById('forceSyncBtn'),
|
||||
forceSyncMode: document.getElementById('forceSyncMode'),
|
||||
peerList: document.getElementById('peerList'),
|
||||
logList: document.getElementById('logList'),
|
||||
clearLogs: document.getElementById('clearLogs'),
|
||||
@@ -44,22 +45,41 @@ const elements = {
|
||||
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
|
||||
episodeLobbyCard: document.getElementById('episodeLobbyCard'),
|
||||
lobbyTitle: document.getElementById('lobbyTitle'),
|
||||
lobbyPeerStatus: document.getElementById('lobbyPeerStatus')
|
||||
lobbyPeerStatus: document.getElementById('lobbyPeerStatus'),
|
||||
browserNotifications: document.getElementById('browserNotifications')
|
||||
};
|
||||
|
||||
let localPeerId = null;
|
||||
let lastPeersJson = null;
|
||||
let lastKnownPeers = [];
|
||||
let isDevTabVisible = false;
|
||||
let joinBtnTimeout = null;
|
||||
let forceSyncResetTimer = null;
|
||||
let popupIntervals = [];
|
||||
let populateTabsToken = null;
|
||||
let errorToken = 0;
|
||||
let forceSyncDone = false;
|
||||
|
||||
// --- Initialization ---
|
||||
async function init() {
|
||||
// Load Settings
|
||||
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode']);
|
||||
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications']);
|
||||
let username = data.username;
|
||||
if (!username) {
|
||||
const adjs = ['Happy', 'Cool', 'Fast', 'Smart', 'Brave', 'Calm', 'Sneaky', 'Lazy', 'Wild', 'Chill', 'Lucky', 'Epic', 'Swift', 'Bold', 'Mighty', 'Cosmic', 'Neon', 'Shadow', 'Crystal', 'Thunder', 'Silent', 'Golden', 'Fierce', 'Noble', 'Mystic', 'Frozen', 'Blazing', 'Sapphire', 'Iron', 'Crimson'];
|
||||
const nouns = ['Koala', 'Panda', 'Tiger', 'Eagle', 'Fox', 'Bear', 'Wolf', 'Lion', 'Hawk', 'Seal', 'Owl', 'Shark', 'Dragon', 'Phoenix', 'Falcon', 'Panther', 'Raven', 'Cobra', 'Lynx', 'Jaguar', 'Orca', 'Mantis', 'Viper', 'Condor', 'Badger', 'Otter', 'Rhino', 'Crane', 'Mongoose', 'Specter'];
|
||||
username = `${adjs[Math.floor(Math.random() * adjs.length)]}${nouns[Math.floor(Math.random() * nouns.length)]}`;
|
||||
chrome.storage.sync.set({ username });
|
||||
}
|
||||
|
||||
elements.serverUrl.value = data.serverUrl || '';
|
||||
elements.roomId.value = data.roomId || '';
|
||||
elements.password.value = data.password || '';
|
||||
elements.username.value = data.username || '';
|
||||
elements.username.value = username;
|
||||
elements.filterNoise.checked = data.filterNoise !== false;
|
||||
elements.autoSyncNextEpisode.checked = !!data.autoSyncNextEpisode;
|
||||
elements.autoSyncNextEpisode.checked = data.autoSyncNextEpisode !== false;
|
||||
elements.forceSyncMode.value = data.forceSyncMode || 'jump-to-others';
|
||||
elements.browserNotifications.checked = data.browserNotifications === true;
|
||||
|
||||
// Set Version Info
|
||||
const versionEl = document.getElementById('appVersion');
|
||||
@@ -80,10 +100,16 @@ async function init() {
|
||||
|
||||
// Initial Status Check
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.warn('[Popup] Background not responding:', chrome.runtime.lastError.message);
|
||||
await populateTabs();
|
||||
return;
|
||||
}
|
||||
if (res) {
|
||||
localPeerId = res.peerId;
|
||||
applyConnectionStatus(res.status);
|
||||
updatePeerList(res.peers);
|
||||
lastKnownPeers = res.peers || [];
|
||||
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
||||
|
||||
// Populate Tabs using the background's targetTabId
|
||||
@@ -103,7 +129,12 @@ async function init() {
|
||||
chrome.runtime.sendMessage({ type: 'GET_ROOM_LIST' });
|
||||
|
||||
// Debug Info Refresh
|
||||
setInterval(refreshDebugInfo, 2000);
|
||||
popupIntervals.push(setInterval(refreshDebugInfo, 2000));
|
||||
|
||||
// Show onboarding on first visit
|
||||
chrome.storage.sync.get(['onboardingComplete'], (data) => {
|
||||
if (!data.onboardingComplete) showOnboarding();
|
||||
});
|
||||
}
|
||||
|
||||
// --- UI Logic ---
|
||||
@@ -124,19 +155,33 @@ function updateUI(roomId, password, useCustomServer = false, serverUrl = '') {
|
||||
if (elements.activeRoomId) elements.activeRoomId.textContent = roomId;
|
||||
if (elements.activeServer) {
|
||||
elements.activeServer.textContent = useCustomServer ? (serverUrl || 'Custom Server') : 'Official Server';
|
||||
elements.activeServer.title = useCustomServer ? (serverUrl || '') : 'sync.shik3i.net';
|
||||
elements.activeServer.title = useCustomServer ? (serverUrl || '') : 'syncserver.koalastuff.net';
|
||||
}
|
||||
} else {
|
||||
updatePeerList([]);
|
||||
if (elements.inviteLink) elements.inviteLink.value = '';
|
||||
if (elements.activeRoomId) elements.activeRoomId.textContent = '';
|
||||
if (elements.activeServer) {
|
||||
elements.activeServer.textContent = '';
|
||||
elements.activeServer.title = '';
|
||||
}
|
||||
lastKnownPeers = [];
|
||||
}
|
||||
}
|
||||
|
||||
function updateLastActionUI(state, peers) {
|
||||
if (!state || !state.action) {
|
||||
elements.lastActionCard.innerHTML = '<div style="text-align:center; color: var(--text-muted); font-size: 11px; padding-top: 20px;">No recent commands</div>';
|
||||
elements.lastActionCard.replaceChildren();
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'text-align:center; color: var(--text-muted); font-size: 10px;';
|
||||
el.textContent = 'No recent commands';
|
||||
elements.lastActionCard.appendChild(el);
|
||||
return;
|
||||
}
|
||||
|
||||
const safePeers = peers || [];
|
||||
const safeAcks = state.acks || [];
|
||||
|
||||
const actionNames = {
|
||||
'play': 'PLAY',
|
||||
'pause': 'PAUSE',
|
||||
@@ -146,39 +191,51 @@ function updateLastActionUI(state, peers) {
|
||||
};
|
||||
|
||||
let senderName = state.senderId === 'You' ? 'You' : state.senderId;
|
||||
const senderPeer = peers.find(p => (p.peerId || p) === state.senderId);
|
||||
const senderPeer = safePeers.find(p => (p.peerId || p) === state.senderId);
|
||||
if (senderPeer && senderPeer.username) senderName = senderPeer.username;
|
||||
|
||||
const timeStr = new Date(state.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const ts = state.timestamp ? new Date(state.timestamp) : new Date();
|
||||
const timeStr = isNaN(ts.getTime()) ? '--:--' : ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
|
||||
// Clear previous content
|
||||
elements.lastActionCard.innerHTML = '';
|
||||
|
||||
// Create Header
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'display:flex; justify-content:space-between; margin-bottom:10px; align-items:baseline;';
|
||||
header.style.cssText = 'display:flex; justify-content:space-between; margin-bottom:6px; align-items:baseline;';
|
||||
|
||||
const actionSpan = document.createElement('span');
|
||||
actionSpan.style.cssText = 'font-weight:700; color:var(--accent); font-size:13px;';
|
||||
actionSpan.style.cssText = 'font-weight:700; color:var(--accent); font-size:11px;';
|
||||
actionSpan.textContent = actionNames[state.action] || state.action.toUpperCase();
|
||||
|
||||
const infoSpan = document.createElement('span');
|
||||
infoSpan.style.cssText = 'font-size:10px; color:var(--text-muted);';
|
||||
infoSpan.style.cssText = 'font-size:9px; color:var(--text-muted);';
|
||||
infoSpan.textContent = `${senderName} @ ${timeStr}`;
|
||||
|
||||
header.appendChild(actionSpan);
|
||||
header.appendChild(infoSpan);
|
||||
elements.lastActionCard.appendChild(header);
|
||||
|
||||
// Create Grid
|
||||
const grid = document.createElement('div');
|
||||
grid.style.cssText = 'display:grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); gap: 6px;';
|
||||
if (state.targetTime !== undefined && state.action === 'seek') {
|
||||
const timeInfo = document.createElement('div');
|
||||
timeInfo.style.cssText = 'font-size:9px; color:var(--text-muted); margin-top:4px;';
|
||||
timeInfo.textContent = `Target: ${formatTime(state.targetTime)}`;
|
||||
elements.lastActionCard.appendChild(timeInfo);
|
||||
}
|
||||
|
||||
peers.forEach(peer => {
|
||||
if (state.targetTime !== undefined && state.action.includes('force_sync')) {
|
||||
const timeInfo = document.createElement('div');
|
||||
timeInfo.style.cssText = 'font-size:9px; color:var(--text-muted); margin-top:4px;';
|
||||
timeInfo.textContent = `Sync to: ${formatTime(state.targetTime)}`;
|
||||
elements.lastActionCard.appendChild(timeInfo);
|
||||
}
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.style.cssText = 'display:grid; grid-template-columns: repeat(auto-fill, minmax(36px, 1fr)); gap: 5px;';
|
||||
|
||||
safePeers.forEach(peer => {
|
||||
const pId = typeof peer === 'object' ? peer.peerId : peer;
|
||||
if (pId === localPeerId) return; // Exclude local user from acknowledgment list
|
||||
if (pId === localPeerId) return;
|
||||
const pName = (typeof peer === 'object' && peer.username) ? peer.username : pId.substring(0, 4);
|
||||
const isAcked = state.acks.includes(pId) || pId === state.senderId;
|
||||
const isAcked = safeAcks.includes(pId) || pId === state.senderId;
|
||||
const color = isAcked ? 'var(--success)' : '#475569';
|
||||
const icon = isAcked ? '✓' : '...';
|
||||
|
||||
@@ -187,11 +244,11 @@ function updateLastActionUI(state, peers) {
|
||||
peerItem.style.cssText = `display:flex; flex-direction:column; align-items:center; opacity: ${isAcked ? 1 : 0.6};`;
|
||||
|
||||
const dot = document.createElement('div');
|
||||
dot.style.cssText = `width:20px; height:20px; border-radius:50%; background:${color}; color:white; display:flex; align-items:center; justify-content:center; font-size:10px; font-weight:bold; margin-bottom:2px;`;
|
||||
dot.style.cssText = `width:18px; height:18px; border-radius:50%; background:${color}; color:white; display:flex; align-items:center; justify-content:center; font-size:9px; font-weight:bold; margin-bottom:1px;`;
|
||||
dot.textContent = icon;
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.style.cssText = 'font-size:8px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40px;';
|
||||
nameSpan.style.cssText = 'font-size:7px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:36px;';
|
||||
nameSpan.textContent = pName;
|
||||
|
||||
peerItem.appendChild(dot);
|
||||
@@ -203,7 +260,7 @@ function updateLastActionUI(state, peers) {
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
if (seconds === null || seconds === undefined || isNaN(seconds)) return '--:--';
|
||||
if (seconds === null || seconds === undefined || isNaN(seconds) || seconds < 0) return '--:--';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
@@ -221,6 +278,13 @@ function getVolumeIcon(volume, muted) {
|
||||
let activePeers = [];
|
||||
let interpolationInterval = null;
|
||||
|
||||
function stopInterpolation() {
|
||||
if (interpolationInterval) {
|
||||
clearInterval(interpolationInterval);
|
||||
interpolationInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startInterpolation() {
|
||||
if (interpolationInterval) return;
|
||||
interpolationInterval = setInterval(() => {
|
||||
@@ -236,10 +300,39 @@ function startInterpolation() {
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function renderEmpty(container, type) {
|
||||
const states = {
|
||||
peers: { icon: '\u{1F465}', title: 'No peers yet', hint: 'Share your invite link to get started' },
|
||||
history: { icon: '\u{1F4CB}', title: 'No activity yet', hint: 'Play, pause, or seek to see history' },
|
||||
logs: { icon: '\u{1F4DD}', title: 'No logs', hint: 'Connection events will appear here' },
|
||||
rooms: { icon: '\u{1F50D}', title: 'No active rooms', hint: 'Create a room or refresh to find public ones' }
|
||||
};
|
||||
const state = states[type] || { icon: '', title: '', hint: '' };
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.cssText = 'text-align:center; padding:16px 8px; color:var(--text-muted);';
|
||||
const iconDiv = document.createElement('div');
|
||||
iconDiv.style.cssText = 'font-size:24px; margin-bottom:6px;';
|
||||
iconDiv.textContent = state.icon;
|
||||
const titleDiv = document.createElement('div');
|
||||
titleDiv.style.cssText = 'font-size:12px; font-weight:600; margin-bottom:4px;';
|
||||
titleDiv.textContent = state.title;
|
||||
const hintDiv = document.createElement('div');
|
||||
hintDiv.style.cssText = 'font-size:10px; opacity:0.7;';
|
||||
hintDiv.textContent = state.hint;
|
||||
wrapper.appendChild(iconDiv);
|
||||
wrapper.appendChild(titleDiv);
|
||||
wrapper.appendChild(hintDiv);
|
||||
container.replaceChildren(wrapper);
|
||||
}
|
||||
|
||||
function updatePeerList(peers) {
|
||||
if (!peers) return;
|
||||
activePeers = peers;
|
||||
if (!interpolationInterval) startInterpolation();
|
||||
if (peers.length === 0) {
|
||||
stopInterpolation();
|
||||
} else if (!interpolationInterval) {
|
||||
startInterpolation();
|
||||
}
|
||||
|
||||
// UI Throttle: Only re-render if the peer state actually changed (excluding time interpolation)
|
||||
const stateToHash = peers.map(p => ({
|
||||
@@ -258,10 +351,7 @@ function updatePeerList(peers) {
|
||||
const renderPeers = (container) => {
|
||||
container.innerHTML = '';
|
||||
if (peers.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.style.cssText = 'text-align:center; color: var(--text-muted); font-size: 12px;';
|
||||
empty.textContent = 'No peers connected';
|
||||
container.appendChild(empty);
|
||||
renderEmpty(container, 'peers');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -371,27 +461,68 @@ function updatePeerList(peers) {
|
||||
populateTabs(peers);
|
||||
}
|
||||
|
||||
function detectPeerChanges(newPeers) {
|
||||
const oldIds = new Set(lastKnownPeers.map(p => p.peerId || p));
|
||||
const newIds = new Set(newPeers.map(p => p.peerId || p));
|
||||
|
||||
for (const peer of newPeers) {
|
||||
const id = peer.peerId || peer;
|
||||
if (!oldIds.has(id)) {
|
||||
const name = peer.username || id.substring(0, 4);
|
||||
showToast(`${name} joined the room`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
for (const oldPeer of lastKnownPeers) {
|
||||
const id = oldPeer.peerId || oldPeer;
|
||||
if (!newIds.has(id)) {
|
||||
const name = oldPeer.username || id.substring(0, 4);
|
||||
showToast(`${name} left the room`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
lastKnownPeers = newPeers;
|
||||
}
|
||||
|
||||
async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||
const token = {};
|
||||
populateTabsToken = token;
|
||||
|
||||
const data = await chrome.storage.sync.get(['filterNoise']);
|
||||
const isFilterActive = data.filterNoise !== false;
|
||||
|
||||
// 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;
|
||||
if (chrome.runtime.lastError) {
|
||||
if (populateTabsToken !== token) return;
|
||||
currentTargetTabId = null;
|
||||
} else {
|
||||
currentTargetTabId = status?.targetTabId;
|
||||
}
|
||||
}
|
||||
|
||||
// Use provided peers or fetch if missing
|
||||
let peerIds = providedPeers;
|
||||
if (!peerIds) {
|
||||
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
|
||||
peerIds = status?.peers || [];
|
||||
if (chrome.runtime.lastError) {
|
||||
if (populateTabsToken !== token) return;
|
||||
peerIds = [];
|
||||
} else {
|
||||
peerIds = status?.peers || [];
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = await chrome.tabs.query({});
|
||||
let tabs = [];
|
||||
try {
|
||||
tabs = await chrome.tabs.query({});
|
||||
} catch (e) {
|
||||
console.warn('[Popup] tabs.query failed:', e.message);
|
||||
if (populateTabsToken !== token) return;
|
||||
}
|
||||
|
||||
// Clear existing options except placeholder
|
||||
if (!elements.targetTab) return;
|
||||
if (populateTabsToken !== token) return;
|
||||
while (elements.targetTab.options.length > 1) {
|
||||
elements.targetTab.remove(1);
|
||||
}
|
||||
@@ -405,13 +536,17 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||
return true;
|
||||
});
|
||||
|
||||
// Smart Matching Logic — exclude own tabTitle to prevent self-match (computed once)
|
||||
const peerTitles = peerIds
|
||||
.filter(p => (typeof p === 'object' ? p.peerId : p) !== localPeerId)
|
||||
.map(p => (typeof p === 'object' ? p.tabTitle : null))
|
||||
.filter(t => t && t.length > 3);
|
||||
|
||||
filteredTabs.forEach(tab => {
|
||||
const option = document.createElement('option');
|
||||
option.value = tab.id;
|
||||
const title = (tab.title || 'Loading...');
|
||||
|
||||
// Smart Matching Logic
|
||||
const peerTitles = peerIds.map(p => p.tabTitle).filter(t => t && t.length > 3);
|
||||
const isMatch = peerTitles.some(pt => {
|
||||
const t1 = title.toLowerCase();
|
||||
const t2 = pt.toLowerCase();
|
||||
@@ -429,10 +564,25 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||
elements.targetTab.appendChild(option);
|
||||
});
|
||||
|
||||
// Sort: Matches first
|
||||
// Sort: 1. Current tab first, 2. Matches, 3. Rest alphabetically
|
||||
const options = Array.from(elements.targetTab.options);
|
||||
const placeholder = options.shift(); // Remove placeholder
|
||||
options.sort((a, b) => (b.textContent.includes('⭐') ? 1 : 0) - (a.textContent.includes('⭐') ? 1 : 0));
|
||||
const placeholder = options.shift();
|
||||
const currentTabId = providedTargetTabId ? parseInt(providedTargetTabId) : null;
|
||||
|
||||
options.sort((a, b) => {
|
||||
const aId = parseInt(a.value);
|
||||
const bId = parseInt(b.value);
|
||||
|
||||
if (aId === currentTabId) return -1;
|
||||
if (bId === currentTabId) return 1;
|
||||
|
||||
const aMatch = a.textContent.includes('⭐');
|
||||
const bMatch = b.textContent.includes('⭐');
|
||||
if (aMatch && !bMatch) return -1;
|
||||
if (!aMatch && bMatch) return 1;
|
||||
|
||||
return a.textContent.localeCompare(b.textContent);
|
||||
});
|
||||
elements.targetTab.innerHTML = '';
|
||||
elements.targetTab.appendChild(placeholder);
|
||||
options.forEach(opt => elements.targetTab.appendChild(opt));
|
||||
@@ -445,37 +595,47 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||
function applyConnectionStatus(status) {
|
||||
const connected = status === 'connected';
|
||||
const connecting = status === 'connecting';
|
||||
const reconnecting = status === 'reconnecting';
|
||||
const failed = status === 'reconnect_failed';
|
||||
|
||||
elements.connDot.className = 'status-dot ' + (connected ? 'status-online' : (failed ? 'status-offline' : (connecting ? 'status-online' : 'status-offline')));
|
||||
|
||||
if (connecting) {
|
||||
elements.connDot.style.background = '#fbbf24';
|
||||
elements.connDot.style.boxShadow = '0 0 8px #fbbf24';
|
||||
} else if (failed) {
|
||||
elements.connDot.style.background = '#ef4444';
|
||||
elements.connDot.style.boxShadow = 'none';
|
||||
} else {
|
||||
elements.connDot.style.background = '';
|
||||
elements.connDot.style.boxShadow = '';
|
||||
if (elements.connDot) {
|
||||
elements.connDot.className = 'status-dot ' + (connected ? 'status-online' : (failed ? 'status-offline' : ((connecting || reconnecting) ? 'status-online' : 'status-offline')));
|
||||
|
||||
if (reconnecting) {
|
||||
elements.connDot.style.background = '#f59e0b';
|
||||
elements.connDot.style.boxShadow = '0 0 8px #f59e0b';
|
||||
} else if (connecting) {
|
||||
elements.connDot.style.background = '#fbbf24';
|
||||
elements.connDot.style.boxShadow = '0 0 8px #fbbf24';
|
||||
} else if (failed) {
|
||||
elements.connDot.style.background = '#ef4444';
|
||||
elements.connDot.style.boxShadow = 'none';
|
||||
} else {
|
||||
elements.connDot.style.background = '';
|
||||
elements.connDot.style.boxShadow = '';
|
||||
}
|
||||
}
|
||||
|
||||
elements.connText.textContent = connected ? 'Connected' : (connecting ? 'Connecting...' : (failed ? 'Failed' : 'Disconnected'));
|
||||
elements.retryBtn.style.display = failed ? 'block' : 'none';
|
||||
|
||||
// Update Join Button during auto-transition
|
||||
if (connecting) {
|
||||
elements.joinBtn.disabled = true;
|
||||
elements.joinBtn.textContent = '🚀 Joining...';
|
||||
} else {
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = 'Join Room';
|
||||
if (elements.connText) {
|
||||
elements.connText.textContent = connected ? 'Connected' : (reconnecting ? 'Reconnecting...' : (connecting ? 'Connecting...' : (failed ? 'Failed' : 'Disconnected')));
|
||||
}
|
||||
if (elements.retryBtn) {
|
||||
elements.retryBtn.style.display = failed ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Preserve icons for Remote Control buttons
|
||||
elements.playBtn.textContent = '▶ Play';
|
||||
elements.pauseBtn.textContent = '⏸ Pause';
|
||||
elements.forceSyncBtn.textContent = '⚡ Force Sync Everyone';
|
||||
if (elements.joinBtn) {
|
||||
if (connecting || reconnecting) {
|
||||
elements.joinBtn.disabled = true;
|
||||
elements.joinBtn.textContent = connecting ? '🚀 Joining...' : '🔄 Reconnecting...';
|
||||
} else {
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = 'Join Room';
|
||||
}
|
||||
}
|
||||
|
||||
if (elements.playBtn) elements.playBtn.textContent = '▶ Play';
|
||||
if (elements.pauseBtn) elements.pauseBtn.textContent = '⏸ Pause';
|
||||
if (elements.forceSyncBtn) elements.forceSyncBtn.textContent = '⚡ Force Sync';
|
||||
}
|
||||
|
||||
function updateHistory(history) {
|
||||
@@ -483,10 +643,7 @@ function updateHistory(history) {
|
||||
elements.historyList.innerHTML = '';
|
||||
|
||||
if (history.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.style.cssText = 'text-align:center; padding: 10px;';
|
||||
empty.textContent = 'No activity yet';
|
||||
elements.historyList.appendChild(empty);
|
||||
renderEmpty(elements.historyList, 'history');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -534,10 +691,7 @@ function updateRoomList(rooms) {
|
||||
elements.publicRooms.innerHTML = '';
|
||||
|
||||
if (!rooms || rooms.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.style.cssText = 'text-align:center; padding: 10px; color:var(--text-muted);';
|
||||
empty.textContent = 'No active rooms';
|
||||
elements.publicRooms.appendChild(empty);
|
||||
renderEmpty(elements.publicRooms, 'rooms');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -584,39 +738,42 @@ function checkInviteLink() {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const tab = tabs[0];
|
||||
if (tab && tab.url && tab.url.includes(OFFICIAL_LANDING_PAGE_URL) && tab.url.includes('#join:')) {
|
||||
const rawHash = tab.url.split('#join:')[1];
|
||||
const parts = rawHash.split(':');
|
||||
if (parts.length >= 2) {
|
||||
const roomId = parts.shift();
|
||||
let useCustomServer = false;
|
||||
let serverUrl = '';
|
||||
try {
|
||||
const rawHash = tab.url.split('#join:')[1];
|
||||
if (!rawHash) return;
|
||||
const parts = rawHash.split(':');
|
||||
if (parts.length >= 2) {
|
||||
const roomId = parts.shift();
|
||||
let useCustomServer = false;
|
||||
let serverUrl = '';
|
||||
|
||||
// Smart Link: Parse Server Config if present
|
||||
const last = parts[parts.length - 1];
|
||||
const secondToLast = parts[parts.length - 2];
|
||||
const decodedLast = decodeURIComponent(last || '');
|
||||
const isCustom = secondToLast === '1' && (decodedLast.startsWith('ws://') || decodedLast.startsWith('wss://'));
|
||||
const isOfficial = secondToLast === '0' && last === '';
|
||||
const last = parts[parts.length - 1];
|
||||
const secondToLast = parts[parts.length - 2];
|
||||
const decodedLast = decodeURIComponent(last || '');
|
||||
const isCustom = secondToLast === '1' && (decodedLast.startsWith('ws://') || decodedLast.startsWith('wss://'));
|
||||
const isOfficial = secondToLast === '0' && last === '';
|
||||
|
||||
if (parts.length >= 3 && (isCustom || isOfficial)) {
|
||||
serverUrl = decodeURIComponent(parts.pop());
|
||||
useCustomServer = parts.pop() === '1';
|
||||
if (parts.length >= 3 && (isCustom || isOfficial)) {
|
||||
serverUrl = decodeURIComponent(parts.pop());
|
||||
useCustomServer = parts.pop() === '1';
|
||||
}
|
||||
|
||||
const password = parts.join(':');
|
||||
|
||||
elements.roomId.value = roomId;
|
||||
elements.password.value = password;
|
||||
|
||||
if (serverUrl || useCustomServer) {
|
||||
elements.serverUrl.value = serverUrl;
|
||||
setServerMode(useCustomServer);
|
||||
chrome.storage.sync.set({ serverUrl, useCustomServer });
|
||||
}
|
||||
|
||||
elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)';
|
||||
setTimeout(() => elements.joinBtn.style.boxShadow = '', 2000);
|
||||
}
|
||||
|
||||
const password = parts.join(':');
|
||||
|
||||
elements.roomId.value = roomId;
|
||||
elements.password.value = password;
|
||||
|
||||
if (serverUrl || useCustomServer) {
|
||||
elements.serverUrl.value = serverUrl;
|
||||
setServerMode(useCustomServer);
|
||||
chrome.storage.sync.set({ serverUrl, useCustomServer });
|
||||
}
|
||||
|
||||
// Visual feedback
|
||||
elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)';
|
||||
setTimeout(() => elements.joinBtn.style.boxShadow = '', 2000);
|
||||
} catch (_e) {
|
||||
// Malformed invite link, ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -626,7 +783,11 @@ function setServerMode(custom) {
|
||||
elements.serverOfficial.classList.toggle('active', !custom);
|
||||
elements.serverCustom.classList.toggle('active', custom);
|
||||
elements.serverUrl.style.display = custom ? 'block' : 'none';
|
||||
chrome.storage.sync.set({ useCustomServer: custom });
|
||||
chrome.storage.sync.get(['useCustomServer'], (data) => {
|
||||
if (data.useCustomServer !== custom) {
|
||||
chrome.storage.sync.set({ useCustomServer: custom });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
elements.serverOfficial.addEventListener('click', () => setServerMode(false));
|
||||
@@ -642,6 +803,14 @@ elements.autoSyncNextEpisode.addEventListener('change', () => {
|
||||
chrome.storage.sync.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
|
||||
});
|
||||
|
||||
elements.browserNotifications.addEventListener('change', () => {
|
||||
chrome.storage.sync.set({ browserNotifications: elements.browserNotifications.checked });
|
||||
});
|
||||
|
||||
elements.forceSyncMode.addEventListener('change', () => {
|
||||
chrome.storage.sync.set({ forceSyncMode: elements.forceSyncMode.value });
|
||||
});
|
||||
|
||||
elements.serverUrl.addEventListener('input', () => {
|
||||
chrome.storage.sync.set({ serverUrl: elements.serverUrl.value });
|
||||
});
|
||||
@@ -665,29 +834,34 @@ elements.tabs.forEach(btn => {
|
||||
elements.contents.forEach(c => c.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById(btn.dataset.tab).classList.add('active');
|
||||
isDevTabVisible = btn.dataset.tab === 'tab-dev';
|
||||
if (isDevTabVisible) refreshLogs();
|
||||
if (btn.dataset.tab === 'tab-sync') refreshHistory();
|
||||
});
|
||||
});
|
||||
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
const container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), duration);
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
if (!elements.roomError) return;
|
||||
const currentToken = ++errorToken;
|
||||
elements.roomError.textContent = msg;
|
||||
elements.roomError.style.display = 'block';
|
||||
elements.roomId.style.borderColor = 'var(--error)';
|
||||
elements.password.style.borderColor = 'var(--error)';
|
||||
|
||||
// Shake effect
|
||||
const activeTab = document.querySelector('.tab-content.active');
|
||||
if (activeTab) {
|
||||
activeTab.animate([
|
||||
{ transform: 'translateX(0)' },
|
||||
{ transform: 'translateX(-5px)' },
|
||||
{ transform: 'translateX(5px)' },
|
||||
{ transform: 'translateX(0)' }
|
||||
], { duration: 200, iterations: 2 });
|
||||
}
|
||||
showToast(msg, 'error', 5000);
|
||||
|
||||
setTimeout(() => {
|
||||
if (currentToken !== errorToken) return;
|
||||
if (elements.roomError) elements.roomError.style.display = 'none';
|
||||
elements.roomId.style.borderColor = '';
|
||||
elements.password.style.borderColor = '';
|
||||
@@ -703,6 +877,14 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
elements.joinBtn.disabled = true;
|
||||
elements.joinBtn.textContent = isCreating ? 'Creating Room...' : 'Joining...';
|
||||
|
||||
if (joinBtnTimeout) clearTimeout(joinBtnTimeout);
|
||||
joinBtnTimeout = setTimeout(() => {
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = 'Join Room';
|
||||
joinBtnTimeout = null;
|
||||
showError('Connection timed out. Please try again.');
|
||||
}, 15000);
|
||||
|
||||
const serverUrl = elements.serverUrl.value.trim();
|
||||
const useCustom = elements.serverCustom.classList.contains('active');
|
||||
|
||||
@@ -711,7 +893,7 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const urlToCheck = serverUrl.includes('://') ? serverUrl : 'ws://' + serverUrl;
|
||||
new URL(urlToCheck);
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
showError('Invalid Server URL format.');
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = 'Join Room';
|
||||
@@ -737,6 +919,7 @@ elements.leaveBtn.addEventListener('click', async () => {
|
||||
await chrome.storage.sync.set({ roomId: '', password: '' });
|
||||
elements.roomId.value = '';
|
||||
elements.password.value = '';
|
||||
lastKnownPeers = [];
|
||||
updateUI(null, null);
|
||||
});
|
||||
|
||||
@@ -744,7 +927,9 @@ elements.createRoomBtn.addEventListener('click', () => {
|
||||
const animals = ['koala', 'panda', 'tiger', 'eagle', 'fox', 'bear'];
|
||||
const adj = ['happy', 'cool', 'fast', 'smart', 'brave', 'calm'];
|
||||
const id = `${adj[Math.floor(Math.random() * adj.length)]}-${animals[Math.floor(Math.random() * animals.length)]}-${Math.floor(Math.random() * 100)}`;
|
||||
const pass = Math.random().toString(36).substring(2, 8);
|
||||
const array = new Uint32Array(1);
|
||||
window.crypto.getRandomValues(array);
|
||||
const pass = array[0].toString(36).substring(0, 6);
|
||||
|
||||
elements.roomId.value = id;
|
||||
elements.password.value = pass;
|
||||
@@ -752,7 +937,11 @@ elements.createRoomBtn.addEventListener('click', () => {
|
||||
});
|
||||
|
||||
elements.refreshRooms.addEventListener('click', () => {
|
||||
elements.publicRooms.innerHTML = '<div style="text-align:center; padding: 10px; color:var(--text-muted);">Refreshing...</div>';
|
||||
elements.publicRooms.replaceChildren();
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'text-align:center; padding: 10px; color:var(--text-muted);';
|
||||
el.textContent = 'Refreshing...';
|
||||
elements.publicRooms.appendChild(el);
|
||||
chrome.runtime.sendMessage({ type: 'GET_ROOM_LIST' });
|
||||
});
|
||||
|
||||
@@ -771,17 +960,42 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
if (elements.forceSyncBtn.disabled) return;
|
||||
|
||||
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
|
||||
if (!status || !status.targetTabId) return;
|
||||
if (chrome.runtime.lastError || !status || !status.targetTabId) return;
|
||||
|
||||
const mode = elements.forceSyncMode.value;
|
||||
let targetTime = null;
|
||||
|
||||
if (mode === 'jump-to-others') {
|
||||
if (!localPeerId) {
|
||||
showError('Identity not yet loaded. Wait a moment and try again.');
|
||||
return;
|
||||
}
|
||||
const peers = status.peers || [];
|
||||
const otherTimes = peers
|
||||
.filter(p => typeof p === 'object' && p.peerId !== localPeerId && p.currentTime != null && !isNaN(p.currentTime))
|
||||
.map(p => p.currentTime);
|
||||
|
||||
if (otherTimes.length === 0) {
|
||||
showError('No other peers with a known time. Switch to "Jump to Me".');
|
||||
return;
|
||||
}
|
||||
|
||||
otherTimes.sort((a, b) => a - b);
|
||||
const mid = Math.floor(otherTimes.length / 2);
|
||||
targetTime = otherTimes.length % 2 !== 0 ? otherTimes[mid] : (otherTimes[mid - 1] + otherTimes[mid]) / 2;
|
||||
}
|
||||
|
||||
// Lockout to prevent spamming
|
||||
const originalText = elements.forceSyncBtn.textContent;
|
||||
elements.forceSyncBtn.disabled = true;
|
||||
elements.forceSyncBtn.textContent = 'Syncing...';
|
||||
setTimeout(() => {
|
||||
elements.forceSyncBtn.disabled = false;
|
||||
elements.forceSyncBtn.textContent = originalText;
|
||||
}, 5000);
|
||||
|
||||
elements.forceSyncBtn.textContent = mode === 'jump-to-others' ? `Syncing to group (${formatTime(targetTime)})...` : 'Syncing...';
|
||||
forceSyncDone = false;
|
||||
const forceSyncReset = () => {
|
||||
if (!forceSyncDone) {
|
||||
elements.forceSyncBtn.disabled = false;
|
||||
elements.forceSyncBtn.textContent = originalText;
|
||||
}
|
||||
};
|
||||
forceSyncResetTimer = setTimeout(forceSyncReset, 12000);
|
||||
const tabId = parseInt(status.targetTabId);
|
||||
|
||||
const sendForceSync = (time) => {
|
||||
@@ -792,26 +1006,34 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
});
|
||||
};
|
||||
|
||||
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (response) => {
|
||||
if (chrome.runtime.lastError || !response || response.currentTime === undefined) {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['content.js']
|
||||
}).then(() => {
|
||||
setTimeout(() => {
|
||||
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => {
|
||||
if (retryResponse && retryResponse.currentTime !== undefined) {
|
||||
sendForceSync(retryResponse.currentTime);
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
}).catch(() => {
|
||||
showError('Could not connect to video tab.');
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendForceSync(response.currentTime);
|
||||
});
|
||||
if (mode === 'jump-to-me') {
|
||||
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (response) => {
|
||||
if (chrome.runtime.lastError || !response || response.currentTime === undefined) {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['content.js']
|
||||
}).then(() => {
|
||||
setTimeout(() => {
|
||||
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => {
|
||||
if (chrome.runtime.lastError) return;
|
||||
if (retryResponse && retryResponse.currentTime !== undefined) {
|
||||
sendForceSync(retryResponse.currentTime);
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
}).catch(() => {
|
||||
showError('Could not connect to video tab.');
|
||||
forceSyncDone = true;
|
||||
elements.forceSyncBtn.disabled = false;
|
||||
elements.forceSyncBtn.textContent = originalText;
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendForceSync(response.currentTime);
|
||||
});
|
||||
} else {
|
||||
sendForceSync(targetTime);
|
||||
}
|
||||
});
|
||||
|
||||
elements.playBtn.addEventListener('click', () => {
|
||||
@@ -837,20 +1059,35 @@ elements.clearLogs.addEventListener('click', () => {
|
||||
});
|
||||
|
||||
elements.copyInvite.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(elements.inviteLink.value);
|
||||
elements.copyInvite.textContent = '✅';
|
||||
setTimeout(() => { elements.copyInvite.textContent = '📋'; }, 2000);
|
||||
navigator.clipboard.writeText(elements.inviteLink.value).then(() => {
|
||||
const original = elements.copyInvite.textContent;
|
||||
elements.copyInvite.textContent = '✓';
|
||||
elements.copyInvite.style.background = 'var(--success)';
|
||||
elements.copyInvite.style.color = 'white';
|
||||
showToast('Invite link copied!', 'success', 2000);
|
||||
setTimeout(() => {
|
||||
elements.copyInvite.textContent = original;
|
||||
elements.copyInvite.style.background = '';
|
||||
elements.copyInvite.style.color = '';
|
||||
}, 2000);
|
||||
}).catch(() => {
|
||||
showToast('Failed to copy to clipboard', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Logs & Status ---
|
||||
async function refreshLogs() {
|
||||
chrome.runtime.sendMessage({ type: 'GET_LOGS' }, (logs) => {
|
||||
if (logs && elements.logList) {
|
||||
if (elements.logList) {
|
||||
if (!logs || logs.length === 0) {
|
||||
renderEmpty(elements.logList, 'logs');
|
||||
return;
|
||||
}
|
||||
elements.logList.innerHTML = '';
|
||||
logs.forEach(log => {
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `log-entry log-${log.type}`;
|
||||
const timeStr = log.timestamp.split('T')[1].split('.')[0];
|
||||
const timeStr = log.timestamp?.split('T')?.[1]?.split('.')[0] || '?';
|
||||
entry.textContent = `[${timeStr}] ${log.message}`;
|
||||
elements.logList.appendChild(entry);
|
||||
});
|
||||
@@ -861,12 +1098,39 @@ async function refreshLogs() {
|
||||
chrome.runtime.onMessage.addListener((msg) => {
|
||||
if (msg.type === 'LOG_UPDATE') {
|
||||
refreshLogs();
|
||||
if (msg.log && msg.log.type === 'error') {
|
||||
showError(msg.log.message);
|
||||
}
|
||||
} else if (msg.type === 'ACTION_UPDATE') {
|
||||
const state = msg.state;
|
||||
if (state && state.senderId && state.senderId !== 'You') {
|
||||
const actionNames = {
|
||||
'play': '▶ Play',
|
||||
'pause': '⏸ Pause',
|
||||
'seek': '⏩ Seek',
|
||||
'force_sync_prepare': '⚡ Force Sync',
|
||||
'force_sync_execute': '⚡ Force Play'
|
||||
};
|
||||
const action = actionNames[state.action] || state.action;
|
||||
showToast(`${state.senderId} ${action}`, 'info', 2000);
|
||||
}
|
||||
if (state && state.action === 'force_sync_execute') {
|
||||
forceSyncDone = true;
|
||||
if (forceSyncResetTimer) {
|
||||
clearTimeout(forceSyncResetTimer);
|
||||
forceSyncResetTimer = null;
|
||||
}
|
||||
if (elements.forceSyncBtn) {
|
||||
elements.forceSyncBtn.disabled = false;
|
||||
elements.forceSyncBtn.textContent = '⚡ Force Sync';
|
||||
}
|
||||
}
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
||||
if (res && res.peers) updateLastActionUI(msg.state, res.peers);
|
||||
});
|
||||
} else if (msg.type === 'PEER_UPDATE') {
|
||||
updatePeerList(msg.peers);
|
||||
if (msg.peers) detectPeerChanges(msg.peers);
|
||||
} else if (msg.type === 'CONNECTION_STATUS') {
|
||||
applyConnectionStatus(msg.status);
|
||||
if (msg.status === 'connected') {
|
||||
@@ -879,12 +1143,20 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = 'Join Room';
|
||||
}
|
||||
if (msg.status === 'reconnecting') {
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
||||
if (chrome.runtime.lastError) return;
|
||||
if (res && res.reconnectAttempts !== undefined) {
|
||||
if (elements.connText) {
|
||||
elements.connText.textContent = `Reconnecting... (${res.reconnectAttempts})`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (msg.type === 'HISTORY_UPDATE') {
|
||||
updateHistory(msg.history);
|
||||
} else if (msg.type === 'ROOM_LIST') {
|
||||
updateRoomList(msg.rooms);
|
||||
} else if (msg.type === 'LOG_UPDATE' && msg.log && msg.log.type === 'error') {
|
||||
showError(msg.log.message);
|
||||
} else if (msg.type === 'JOIN_STATUS') {
|
||||
if (msg.success) {
|
||||
// Final confirmation of join from background
|
||||
@@ -915,6 +1187,8 @@ elements.copyLogs.addEventListener('click', () => {
|
||||
const original = elements.copyLogs.textContent;
|
||||
elements.copyLogs.textContent = 'Copied!';
|
||||
setTimeout(() => elements.copyLogs.textContent = original, 2000);
|
||||
}).catch(() => {
|
||||
showToast('Failed to copy to clipboard', 'error');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -966,7 +1240,7 @@ function refreshDebugInfo() {
|
||||
};
|
||||
|
||||
addField('STATE', state.paused ? 'PAUSED' : 'PLAYING', 'var(--accent)');
|
||||
addField('TIME', `${state.currentTime.toFixed(2)}s / ${state.duration.toFixed(2)}s`);
|
||||
addField('TIME', `${state.currentTime.toFixed(2)}s / ${(state.duration || 0).toFixed(2)}s`);
|
||||
addField('READY', state.readyState);
|
||||
|
||||
addSection('Identification');
|
||||
@@ -997,7 +1271,23 @@ function refreshDebugInfo() {
|
||||
}
|
||||
|
||||
init();
|
||||
setInterval(refreshLogs, 5000);
|
||||
popupIntervals.push(setInterval(() => {
|
||||
if (isDevTabVisible) refreshLogs();
|
||||
}, 5000));
|
||||
|
||||
window.addEventListener('unload', () => {
|
||||
stopInterpolation();
|
||||
popupIntervals.forEach(clearInterval);
|
||||
popupIntervals = [];
|
||||
if (joinBtnTimeout) {
|
||||
clearTimeout(joinBtnTimeout);
|
||||
joinBtnTimeout = null;
|
||||
}
|
||||
if (forceSyncResetTimer) {
|
||||
clearTimeout(forceSyncResetTimer);
|
||||
forceSyncResetTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Episode Lobby UI ---
|
||||
function updateLobbyUI(lobby, peers) {
|
||||
@@ -1026,15 +1316,77 @@ function updateLobbyUI(lobby, peers) {
|
||||
});
|
||||
}
|
||||
|
||||
if (peerLines.length > 0) {
|
||||
if (peerLines.length > 0 && elements.lobbyPeerStatus) {
|
||||
elements.lobbyPeerStatus.textContent = peerLines.join(' | ');
|
||||
} else {
|
||||
} else if (elements.lobbyPeerStatus) {
|
||||
elements.lobbyPeerStatus.textContent = 'Waiting for peers...';
|
||||
}
|
||||
|
||||
// Show elapsed time
|
||||
if (lobby.createdAt) {
|
||||
if (lobby.createdAt && elements.lobbyPeerStatus) {
|
||||
const elapsed = Math.floor((Date.now() - lobby.createdAt) / 1000);
|
||||
elements.lobbyPeerStatus.textContent += ` (${elapsed}s)`;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Onboarding Tour ---
|
||||
const onboardingSteps = [
|
||||
{ icon: '\u{1F44B}', title: 'Welcome to KoalaSync!', text: 'Watch videos together in perfect sync — no matter where you are. Let\'s take a quick tour!' },
|
||||
{ icon: '\u{1F3E0}', title: 'Room Tab', text: 'Create a room and share the invite link with friends. Anyone with the link can join instantly.' },
|
||||
{ icon: '\u{1F3AC}', title: 'Sync Tab', text: 'Pick the tab with your video. Play, pause, and seek — everyone stays in sync. Drift? Just hit Force Sync.' },
|
||||
{ icon: '\u2699\uFE0F', title: 'Settings', text: 'Pick a fun username, hide distracting tabs, and enable notifications so you never miss a moment.' },
|
||||
{ icon: '\u{1F389}', title: 'You\'re all set!', text: 'Open a video, create a room, and start watching together. Enjoy!' }
|
||||
];
|
||||
|
||||
let onboardingStep = 0;
|
||||
|
||||
function showOnboarding() {
|
||||
const overlay = document.getElementById('onboarding-overlay');
|
||||
if (!overlay) return;
|
||||
overlay.style.display = 'flex';
|
||||
renderOnboardingStep();
|
||||
}
|
||||
|
||||
function renderOnboardingStep() {
|
||||
const step = onboardingSteps[onboardingStep];
|
||||
const icon = document.getElementById('onboarding-icon');
|
||||
const title = document.getElementById('onboarding-title');
|
||||
const text = document.getElementById('onboarding-text');
|
||||
const nextBtn = document.getElementById('onboarding-next');
|
||||
const dots = document.getElementById('onboarding-dots');
|
||||
if (!icon || !title || !text || !nextBtn || !dots) return;
|
||||
|
||||
icon.textContent = step.icon;
|
||||
title.textContent = step.title;
|
||||
text.textContent = step.text;
|
||||
|
||||
dots.replaceChildren();
|
||||
onboardingSteps.forEach((_, i) => {
|
||||
const dot = document.createElement('div');
|
||||
dot.style.cssText = `width:8px; height:8px; border-radius:50%; background:${i === onboardingStep ? 'var(--accent)' : '#475569'};`;
|
||||
dots.appendChild(dot);
|
||||
});
|
||||
|
||||
nextBtn.textContent = onboardingStep === onboardingSteps.length - 1 ? 'Done!' : 'Next';
|
||||
}
|
||||
|
||||
function completeOnboarding() {
|
||||
const overlay = document.getElementById('onboarding-overlay');
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
chrome.storage.sync.set({ onboardingComplete: true });
|
||||
}
|
||||
|
||||
document.getElementById('onboarding-next')?.addEventListener('click', () => {
|
||||
onboardingStep++;
|
||||
if (onboardingStep >= onboardingSteps.length) {
|
||||
completeOnboarding();
|
||||
} else {
|
||||
renderOnboardingStep();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('onboarding-skip')?.addEventListener('click', completeOnboarding);
|
||||
|
||||
document.getElementById('onboarding-overlay')?.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'onboarding-overlay') completeOnboarding();
|
||||
});
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# ⚠️ 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,16 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "1.7.3",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:extension": "node scripts/build-extension.js",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"eslint": "^10.4.0",
|
||||
"fs-extra": "^11.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# Development Scripts
|
||||
|
||||
This directory contains utility scripts for the KoalaSync development workflow.
|
||||
|
||||
## build-extension.js
|
||||
|
||||
The primary build tool for KoalaSync. This Node.js script automates two critical tasks:
|
||||
|
||||
1. **Protocol Synchronization**: Copies the "Single Source of Truth" constants (`shared/constants.js`) and the domain blacklist (`shared/blacklist.js`) from the root `/shared` directory into the `extension/shared/` directory.
|
||||
2. **Content Script Injection**: Injects protocol constants directly into `content.js` using marker-based replacement. This is necessary because `content.js` executes synchronously and cannot use ES module imports.
|
||||
3. **Artifact Generation**: Compiles the extension into browser-specific bundles for Chrome and Firefox, located in the `dist/` directory.
|
||||
|
||||
### Usage
|
||||
|
||||
From the **repository root**, run:
|
||||
|
||||
```bash
|
||||
node scripts/build-extension.js
|
||||
```
|
||||
|
||||
### Why this script exists
|
||||
KoalaSync uses **Vanilla JS** in the extension to maintain zero runtime dependencies and maximum privacy. Since we don't use a bundler (like Webpack or Vite) inside the extension, this script serves as our lightweight "pre-build" step to ensure that the protocol constants remain synchronized between the extension and the relay server.
|
||||
|
||||
### Content Injection Markers
|
||||
|
||||
The build script uses marker comments in `content.js` to locate and replace constant blocks:
|
||||
|
||||
| Marker Pair | Injected Value | Source |
|
||||
|:---|:---|:---|
|
||||
| `SHARED_EVENTS_INJECT_START` / `END` | The full `EVENTS` object | `shared/constants.js` |
|
||||
| `SHARED_HEARTBEAT_INJECT_START` / `END` | `HEARTBEAT_INTERVAL` value | `shared/constants.js` |
|
||||
|
||||
> **⚠️ Do NOT remove or modify these marker comments in `content.js`.** They are required for the build script to function. If the markers are missing, the build will fail with a clear error message.
|
||||
@@ -0,0 +1,180 @@
|
||||
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', 'README.md'];
|
||||
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, blacklist.js, and README.md 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
|
||||
// Also injects shared constants into content.js
|
||||
function copyExtensionFiles(targetDir) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
// Read master constants for injection
|
||||
const masterConstantsPath = path.join(rootDir, 'shared', 'constants.js');
|
||||
const constantsContent = fs.readFileSync(masterConstantsPath, 'utf8');
|
||||
|
||||
// Robust Extraction using flexible regex
|
||||
const eventsMatch = constantsContent.match(/export const EVENTS\s*=\s*({[\s\S]+?});/);
|
||||
const heartbeatMatch = constantsContent.match(/export const HEARTBEAT_INTERVAL\s*=\s*(\d+);/);
|
||||
|
||||
if (!eventsMatch) {
|
||||
throw new Error('CRITICAL: Could not find EVENTS object in shared/constants.js');
|
||||
}
|
||||
if (!heartbeatMatch) {
|
||||
throw new Error('CRITICAL: Could not find HEARTBEAT_INTERVAL in shared/constants.js');
|
||||
}
|
||||
|
||||
const eventsObject = eventsMatch[1];
|
||||
const heartbeatVal = heartbeatMatch[1];
|
||||
|
||||
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 {
|
||||
if (item === 'content.js') {
|
||||
// Perform injection
|
||||
let content = fs.readFileSync(srcPath, 'utf8');
|
||||
|
||||
// 1. Inject Events
|
||||
const eStart = '// --- SHARED_EVENTS_INJECT_START ---';
|
||||
const eEnd = '// --- SHARED_EVENTS_INJECT_END ---';
|
||||
const ePattern = new RegExp(`${eStart}[\\s\\S]+?${eEnd}`);
|
||||
const eRep = `${eStart}\n // This block is automatically updated by /scripts/build-extension.js\n const EVENTS = ${eventsObject};\n ${eEnd}`;
|
||||
|
||||
if (ePattern.test(content)) {
|
||||
content = content.replace(ePattern, eRep);
|
||||
} else {
|
||||
console.warn('⚠️ WARNING: Event markers not found in content.js');
|
||||
}
|
||||
|
||||
// 2. Inject Heartbeat
|
||||
const hStart = '// --- SHARED_HEARTBEAT_INJECT_START ---';
|
||||
const hEnd = '// --- SHARED_HEARTBEAT_INJECT_END ---';
|
||||
const hPattern = new RegExp(`${hStart}[\\s\\S]+?${hEnd}`);
|
||||
const hRep = `${hStart}\n const HEARTBEAT_INTERVAL_VAL = ${heartbeatVal};\n ${hEnd}`;
|
||||
|
||||
if (hPattern.test(content)) {
|
||||
content = content.replace(hPattern, hRep);
|
||||
} else {
|
||||
console.warn('⚠️ WARNING: Heartbeat markers not found in content.js');
|
||||
}
|
||||
|
||||
fs.writeFileSync(destPath, content);
|
||||
console.log('✓ Injected shared constants into content.js');
|
||||
} 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@koalastuff.net",
|
||||
data_collection_permissions: {
|
||||
required: ["none"]
|
||||
}
|
||||
}
|
||||
};
|
||||
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/"
|
||||
@@ -1,11 +1,11 @@
|
||||
# KoalaSync Relay Server
|
||||
|
||||
A high-performance Node.js relay server for synchronized video playback.
|
||||
A Node.js relay server for synchronized video playback.
|
||||
|
||||
## Key Features
|
||||
- **Zero-Persistence**: No database. All state is held in RAM.
|
||||
- **Privacy First**: No tracking, no logging of user data.
|
||||
- **WebSocket Only**: High performance with minimal overhead.
|
||||
- **Privacy First**: No tracking, no persistent logging of user data.
|
||||
- **WebSocket Only**: Minimal overhead with efficient transport.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -13,17 +13,21 @@ A high-performance Node.js relay server for synchronized video playback.
|
||||
Copy `.env.example` to `.env` and configure your settings.
|
||||
```bash
|
||||
PORT=3000
|
||||
MAX_ROOMS=100
|
||||
MAX_PEERS_PER_ROOM=20
|
||||
MAX_ROOMS=1000
|
||||
MAX_PEERS_PER_ROOM=50
|
||||
MIN_VERSION=1.0.0
|
||||
```
|
||||
|
||||
### Docker (Recommended)
|
||||
The server is designed to run in a Docker container.
|
||||
The server is available as a pre-built image on GHCR.
|
||||
```bash
|
||||
# Build from the repository root
|
||||
# Pull from GHCR
|
||||
docker pull ghcr.io/shik3i/koalasync:latest
|
||||
|
||||
# Or build from the repository root
|
||||
docker build -t koala-sync-server -f server/Dockerfile .
|
||||
```
|
||||
See [docker-compose.example.yml](../docker-compose.example.yml) in the root directory for a ready-to-use configuration.
|
||||
|
||||
### Manual Setup
|
||||
```bash
|
||||
@@ -34,5 +38,6 @@ npm start
|
||||
|
||||
## Security
|
||||
- **Rate Limiting**: IP-based connection limits and socket-based event limits.
|
||||
- **Token Handshake**: Requires a valid token defined in `shared/constants.js`.
|
||||
- **Token Handshake**: Requires a valid token defined in the root `shared/constants.js`.
|
||||
- **Single Source of Truth**: The server imports constants directly from the root `shared/` directory.
|
||||
- **In-Memory**: Rooms are automatically pruned after 2 hours of inactivity.
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createServer } from 'http';
|
||||
import { Server } from 'socket.io';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import dotenv from 'dotenv';
|
||||
import { EVENTS, OFFICIAL_SERVER_TOKEN } from '../shared/constants.js';
|
||||
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION } from '../shared/constants.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -15,18 +15,44 @@ const MIN_VERSION = process.env.MIN_VERSION || '1.0.0';
|
||||
const app = express();
|
||||
app.set('trust proxy', 1); // For real client IP through reverse proxy
|
||||
|
||||
// Health Check
|
||||
app.get('/', (req, res) => res.json({ status: 'online', service: 'KoalaSync Relay' }));
|
||||
// Health Check with Rate Limiting
|
||||
app.get('/', (req, res) => {
|
||||
const clientIp = req.ip;
|
||||
if (!checkHealthRate(clientIp)) {
|
||||
return res.status(429).json({ error: 'Too many requests. Try again later.' });
|
||||
}
|
||||
res.json({ status: 'online', service: 'KoalaSync Relay' });
|
||||
});
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
const clientIp = req.ip;
|
||||
if (!checkHealthRate(clientIp)) {
|
||||
return res.status(429).json({ error: 'Rate limited' });
|
||||
}
|
||||
res.json({
|
||||
status: 'ok',
|
||||
uptime: process.uptime(),
|
||||
rooms: rooms.size,
|
||||
connections: io.engine?.clientsCount ?? 0,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
const httpServer = createServer(app);
|
||||
|
||||
// Socket.IO setup with security constraints
|
||||
const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: ["https://koalasync.shik3i.net"],
|
||||
origin: (origin, callback) => {
|
||||
if (!origin || origin === 'https://sync.koalastuff.net' || origin.startsWith('chrome-extension://')) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
}
|
||||
},
|
||||
methods: ["GET", "POST"]
|
||||
},
|
||||
maxHttpBufferSize: 1024, // 1KB max per message
|
||||
maxHttpBufferSize: 4096, // 4KB max per message (headroom for JOIN_ROOM payloads)
|
||||
transports: ['websocket'],
|
||||
allowUpgrades: false
|
||||
});
|
||||
@@ -37,6 +63,7 @@ const io = new Server(httpServer, {
|
||||
const rooms = new Map();
|
||||
const socketToRoom = new Map();
|
||||
const peerToSocket = new Map(); // peerId -> socketId (Global lookup)
|
||||
const roomCreationLocks = new Map(); // roomId -> Promise (prevents race on room creation)
|
||||
|
||||
function log(type, message, details = '') {
|
||||
const timestamp = new Date().toISOString();
|
||||
@@ -73,19 +100,20 @@ function recordAuthFailure(ip, roomId) {
|
||||
failedAuthAttempts.set(key, record);
|
||||
}
|
||||
|
||||
// Periodically clean up old auth failure records (every hour)
|
||||
// Periodically clean up old auth failure records (every 15 minutes)
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, record] of failedAuthAttempts.entries()) {
|
||||
if (now - record.lastAttempt > 60 * 60 * 1000) {
|
||||
if (now - record.lastAttempt > 15 * 60 * 1000) {
|
||||
failedAuthAttempts.delete(key);
|
||||
}
|
||||
}
|
||||
}, 60 * 60 * 1000);
|
||||
}, 15 * 60 * 1000);
|
||||
|
||||
const eventCounts = new Map(); // socketId -> { count, resetTime }
|
||||
const healthCounts = new Map(); // ip -> { count, resetTime }
|
||||
|
||||
// Clean up connection counts to prevent memory leak
|
||||
// Clean up connection counts and event counts to prevent memory leak
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of connectionCounts.entries()) {
|
||||
@@ -93,6 +121,16 @@ setInterval(() => {
|
||||
connectionCounts.delete(ip);
|
||||
}
|
||||
}
|
||||
for (const [socketId, entry] of eventCounts.entries()) {
|
||||
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
|
||||
eventCounts.delete(socketId);
|
||||
}
|
||||
}
|
||||
for (const [ip, entry] of healthCounts.entries()) {
|
||||
if (now > entry.resetTime) {
|
||||
healthCounts.delete(ip);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
function checkConnectionRate(ip) {
|
||||
@@ -113,6 +151,15 @@ function checkEventRate(socketId) {
|
||||
return entry.count <= 30;
|
||||
}
|
||||
|
||||
function checkHealthRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
|
||||
entry.count++;
|
||||
healthCounts.set(ip, entry);
|
||||
return entry.count <= 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central peer teardown. Removes a socket from all room state and notifies
|
||||
* remaining peers. Call this from every disconnect/leave/reaper/dedupe path.
|
||||
@@ -120,12 +167,8 @@ function checkEventRate(socketId) {
|
||||
* @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) {
|
||||
function removePeerFromRoom(socketId, roomId, reason) {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return;
|
||||
|
||||
@@ -141,7 +184,8 @@ function removePeerFromRoom(socketId, roomId, reason, emitLeave = true) {
|
||||
|
||||
// 2. Remove from global maps
|
||||
socketToRoom.delete(socketId);
|
||||
if (peerToSocket.get(peerId) === socketId) {
|
||||
const currentSocketId = peerToSocket.get(peerId);
|
||||
if (currentSocketId === socketId) {
|
||||
peerToSocket.delete(peerId);
|
||||
}
|
||||
|
||||
@@ -159,7 +203,10 @@ function removePeerFromRoom(socketId, roomId, reason, emitLeave = true) {
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const clientIp = socket.handshake.address;
|
||||
// Get real client IP behind proxy/CDN
|
||||
const forwardedFor = socket.handshake.headers['x-forwarded-for'];
|
||||
const clientIp = forwardedFor ? forwardedFor.split(',')[0].trim() : socket.handshake.address;
|
||||
socket._clientIp = clientIp;
|
||||
|
||||
// 1. Connection Rate Limit
|
||||
if (!checkConnectionRate(clientIp)) {
|
||||
@@ -180,8 +227,15 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
|
||||
if (clientVersion) {
|
||||
const [cMaj, cMin, cPatch] = clientVersion.split('.').map(Number);
|
||||
const parts = clientVersion.split('.').map(Number);
|
||||
const cMaj = parts[0], cMin = parts[1], cPatch = parts[2] || 0;
|
||||
const [mMaj, mMin, mPatch] = MIN_VERSION.split('.').map(Number);
|
||||
if (isNaN(cMaj) || isNaN(cMin) || isNaN(cPatch)) {
|
||||
log('AUTH', `Invalid version format (${clientVersion}) from ${clientIp}`);
|
||||
socket.emit(EVENTS.ERROR, { message: 'Invalid version format' });
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
const tooOld = cMaj < mMaj || (cMaj === mMaj && cMin < mMin) || (cMaj === mMaj && cMin === mMin && cPatch < mPatch);
|
||||
if (tooOld) {
|
||||
log('AUTH', `Version too old (${clientVersion}) from ${clientIp}`);
|
||||
@@ -200,19 +254,21 @@ io.on('connection', (socket) => {
|
||||
return;
|
||||
}
|
||||
if (!payload || typeof payload.roomId !== 'string') return;
|
||||
const { password, peerId, protocolVersion } = payload;
|
||||
|
||||
// --- M-2: Sanitize and clamp all string fields ---
|
||||
const roomId = String(payload.roomId || '').substring(0, 64);
|
||||
// --- S-1 & S-5: Sanitize and clamp all incoming fields ---
|
||||
const password = typeof payload.password === 'string' ? payload.password.substring(0, 128) : null;
|
||||
const peerId = typeof payload.peerId === 'string' ? payload.peerId.substring(0, 16) : null;
|
||||
const protocolVersion = typeof payload.protocolVersion === 'string' ? payload.protocolVersion.substring(0, 16) : null;
|
||||
const roomId = String(payload.roomId || '').replace(/[^a-zA-Z0-9\-]/g, '').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
|
||||
if (!roomId || !peerId) return; // Guard: empty or invalid after sanitization
|
||||
|
||||
try {
|
||||
// Protocol check
|
||||
if (protocolVersion !== '1.0.0') {
|
||||
if (protocolVersion !== PROTOCOL_VERSION) {
|
||||
log('AUTH', `Protocol mismatch from ${peerId}: ${protocolVersion}`);
|
||||
socket.emit(EVENTS.ERROR, { message: 'Incompatible protocol version' });
|
||||
return;
|
||||
@@ -220,15 +276,15 @@ io.on('connection', (socket) => {
|
||||
|
||||
// Cleanup old room if re-joining
|
||||
const oldMapping = socketToRoom.get(socket.id);
|
||||
if (oldMapping && oldMapping.roomId === roomId) {
|
||||
return; // Already in this room, ignore to prevent spam
|
||||
if (oldMapping && oldMapping.roomId === roomId && oldMapping.peerId === peerId) {
|
||||
return; // Already in this room with same peerId, ignore to prevent spam
|
||||
}
|
||||
if (oldMapping && oldMapping.roomId !== roomId) {
|
||||
socket.leave(oldMapping.roomId);
|
||||
removePeerFromRoom(socket.id, oldMapping.roomId, 'room-switch');
|
||||
}
|
||||
|
||||
const ip = socket.handshake.address;
|
||||
const ip = socket._clientIp || socket.handshake.address;
|
||||
if (!checkAuthRate(ip, roomId)) {
|
||||
socket.emit(EVENTS.ERROR, { message: "Too many failed attempts. Try again later." });
|
||||
return;
|
||||
@@ -237,21 +293,41 @@ io.on('connection', (socket) => {
|
||||
let room = rooms.get(roomId);
|
||||
|
||||
if (!room) {
|
||||
if (rooms.size >= MAX_ROOMS) {
|
||||
socket.emit(EVENTS.ERROR, { message: "Server capacity reached" });
|
||||
return;
|
||||
// Acquire per-room creation lock to prevent race conditions
|
||||
let lockPromise = roomCreationLocks.get(roomId);
|
||||
if (lockPromise) {
|
||||
await lockPromise;
|
||||
room = rooms.get(roomId);
|
||||
if (room) {
|
||||
// Another concurrent request created it, fall through to password check
|
||||
}
|
||||
}
|
||||
if (!room) {
|
||||
// Create and store lock before async boundary
|
||||
let resolveLock;
|
||||
lockPromise = new Promise(resolve => { resolveLock = resolve; });
|
||||
roomCreationLocks.set(roomId, lockPromise);
|
||||
try {
|
||||
if (rooms.size >= MAX_ROOMS) {
|
||||
socket.emit(EVENTS.ERROR, { message: "Server capacity reached" });
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = password ? await bcrypt.hash(password, 10) : null;
|
||||
room = {
|
||||
passwordHash,
|
||||
peers: new Set(),
|
||||
peerIds: new Map(),
|
||||
peerData: new Map(), // socketId -> { peerId, tabTitle }
|
||||
lastActivity: Date.now()
|
||||
};
|
||||
rooms.set(roomId, room);
|
||||
log('ROOM', `Created room: ${roomId.substring(0, 3)}***`);
|
||||
const passwordHash = password ? await bcrypt.hash(password, 10) : null;
|
||||
room = {
|
||||
passwordHash,
|
||||
peers: new Set(),
|
||||
peerIds: new Map(),
|
||||
peerData: new Map(),
|
||||
lastActivity: Date.now()
|
||||
};
|
||||
rooms.set(roomId, room);
|
||||
log('ROOM', `Created room: ${roomId.substring(0, 3)}***`);
|
||||
} finally {
|
||||
roomCreationLocks.delete(roomId);
|
||||
resolveLock();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (room.passwordHash) {
|
||||
if (!password || !(await bcrypt.compare(password, room.passwordHash))) {
|
||||
@@ -266,18 +342,26 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
|
||||
// Peer Deduplication: Remove existing socket for the same peerId
|
||||
const dedupeSids = [];
|
||||
for (const [sid, data] of room.peerData.entries()) {
|
||||
if (data.peerId === peerId && sid !== socket.id) {
|
||||
const oldSocket = io.sockets.sockets.get(sid);
|
||||
if (oldSocket) {
|
||||
oldSocket.emit(EVENTS.ERROR, { message: 'Deduplication: Another session with this ID joined. Disconnecting...' });
|
||||
oldSocket.leave(roomId);
|
||||
oldSocket.disconnect(true);
|
||||
log('DEDUPE', `Kicked old session for peer ${peerId}`);
|
||||
}
|
||||
removePeerFromRoom(sid, roomId, 'dedupe');
|
||||
dedupeSids.push(sid);
|
||||
}
|
||||
}
|
||||
for (const sid of dedupeSids) {
|
||||
// Re-check: the socket might have been replaced by another concurrent join
|
||||
const currentMapping = room.peerData.get(sid);
|
||||
if (!currentMapping || currentMapping.peerId !== peerId) continue;
|
||||
|
||||
const oldSocket = io.sockets.sockets.get(sid);
|
||||
if (oldSocket) {
|
||||
oldSocket.emit(EVENTS.ERROR, { message: 'Deduplication: Another session with this ID joined. Disconnecting...' });
|
||||
oldSocket.leave(roomId);
|
||||
oldSocket.disconnect(true);
|
||||
log('DEDUPE', `Kicked old session for peer ${peerId}`);
|
||||
}
|
||||
removePeerFromRoom(sid, roomId, 'dedupe');
|
||||
}
|
||||
}
|
||||
|
||||
socket.join(roomId);
|
||||
@@ -301,7 +385,9 @@ io.on('connection', (socket) => {
|
||||
log('ROOM', `Peer ${peerId} joined: ${roomId.substring(0, 3)}***`);
|
||||
} catch (err) {
|
||||
log('ERROR', `Join error for ${socket.id}`, err);
|
||||
socket.emit(EVENTS.ERROR, { message: "Join error" });
|
||||
if (socket.connected) {
|
||||
socket.emit(EVENTS.ERROR, { message: "Join error" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -315,39 +401,64 @@ io.on('connection', (socket) => {
|
||||
|
||||
relayEvents.forEach(eventName => {
|
||||
socket.on(eventName, (data) => {
|
||||
if (!checkEventRate(socket.id)) {
|
||||
log('SECURITY', `Event rate limit exceeded for socket: ${socket.id}`);
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!checkEventRate(socket.id)) {
|
||||
log('SECURITY', `Event rate limit exceeded for socket: ${socket.id}`);
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data || typeof data !== 'object') return; // Prevent null/invalid payload crash
|
||||
if (!data || typeof data !== 'object') return;
|
||||
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (mapping) {
|
||||
const room = rooms.get(mapping.roomId);
|
||||
if (room) {
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (mapping) {
|
||||
const room = rooms.get(mapping.roomId);
|
||||
if (room) {
|
||||
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;
|
||||
// --- S-2 & S-3: Sanitize ALL relay fields (strings, numbers, booleans) ---
|
||||
const clamp = (val, max) => typeof val === 'string' ? val.substring(0, max) : undefined;
|
||||
const clampNum = (val, min, max) => typeof val === 'number' && Number.isFinite(val) ? Math.max(min, Math.min(max, val)) : undefined;
|
||||
const validState = (val) => (val === 'playing' || val === 'paused') ? val : undefined;
|
||||
const validBool = (val) => typeof val === 'boolean' ? val : undefined;
|
||||
|
||||
const existing = room.peerData.get(socket.id) || { peerId: mapping.peerId };
|
||||
room.peerData.set(socket.id, {
|
||||
...existing,
|
||||
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,
|
||||
username: data.username !== undefined ? (clamp(data.username, 30) ?? existing.username) : existing.username,
|
||||
tabTitle: data.tabTitle !== undefined ? (clamp(data.tabTitle, 100) ?? existing.tabTitle) : existing.tabTitle,
|
||||
mediaTitle: data.mediaTitle !== undefined ? (clamp(data.mediaTitle, 100) ?? existing.mediaTitle) : existing.mediaTitle,
|
||||
playbackState: data.playbackState !== undefined ? (validState(data.playbackState) ?? existing.playbackState) : existing.playbackState,
|
||||
currentTime: data.currentTime !== undefined ? (clampNum(data.currentTime, 0, 86400) ?? existing.currentTime) : existing.currentTime,
|
||||
volume: data.volume !== undefined ? (clampNum(data.volume, 0, 1) ?? existing.volume) : existing.volume,
|
||||
muted: data.muted !== undefined ? (validBool(data.muted) ?? existing.muted) : existing.muted,
|
||||
lastSeen: Date.now()
|
||||
});
|
||||
|
||||
socket.to(mapping.roomId).emit(eventName, { ...data, senderId: mapping.peerId });
|
||||
// --- S-3: Construct clean relay payload — never forward raw client data ---
|
||||
const relayPayload = {
|
||||
senderId: mapping.peerId,
|
||||
currentTime: clampNum(data.currentTime, 0, 86400),
|
||||
targetTime: clampNum(data.targetTime, 0, 86400),
|
||||
playbackState: validState(data.playbackState),
|
||||
username: clamp(data.username, 30),
|
||||
tabTitle: clamp(data.tabTitle, 100),
|
||||
mediaTitle: clamp(data.mediaTitle, 100),
|
||||
volume: clampNum(data.volume, 0, 1),
|
||||
muted: validBool(data.muted),
|
||||
peerId: typeof data.peerId === 'string' ? data.peerId.substring(0, 16) : undefined,
|
||||
status: typeof data.status === 'string' ? data.status.substring(0, 16) : undefined,
|
||||
expectedTitle: clamp(data.expectedTitle, 100),
|
||||
title: clamp(data.title, 100),
|
||||
actionTimestamp: clampNum(data.actionTimestamp, 0, Number.MAX_SAFE_INTEGER),
|
||||
};
|
||||
// Strip undefined keys for clean wire format
|
||||
Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]);
|
||||
socket.to(mapping.roomId).emit(eventName, relayPayload);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log('ERROR', `Relay handler error for ${eventName}: ${err.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -369,23 +480,30 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(EVENTS.EVENT_ACK, (data) => {
|
||||
if (!data.targetId) return;
|
||||
|
||||
const senderMapping = socketToRoom.get(socket.id);
|
||||
const targetSocketId = peerToSocket.get(data.targetId);
|
||||
const targetMapping = targetSocketId ? socketToRoom.get(targetSocketId) : null;
|
||||
socket.on(EVENTS.EVENT_ACK, (data) => {
|
||||
if (!checkEventRate(socket.id)) {
|
||||
log('SECURITY', `Event rate limit exceeded for socket (ACK): ${socket.id}`);
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
if (!data || typeof data !== 'object') return;
|
||||
if (typeof data.targetId !== 'string') return;
|
||||
if (data.actionTimestamp !== undefined && (typeof data.actionTimestamp !== 'number' || !Number.isFinite(data.actionTimestamp))) return;
|
||||
|
||||
const senderMapping = socketToRoom.get(socket.id);
|
||||
const targetSocketId = peerToSocket.get(data.targetId);
|
||||
const targetMapping = targetSocketId ? socketToRoom.get(targetSocketId) : null;
|
||||
|
||||
// Security: Only relay ACK if both peers are in the same room
|
||||
if (senderMapping && targetMapping && senderMapping.roomId === targetMapping.roomId) {
|
||||
io.to(targetSocketId).emit(EVENTS.EVENT_ACK, {
|
||||
senderId: senderMapping.peerId,
|
||||
actionTimestamp: data.actionTimestamp
|
||||
});
|
||||
} else {
|
||||
log('SECURITY', `Blocked cross-room ACK attempt from ${socket.id} to ${data.targetId}`);
|
||||
}
|
||||
});
|
||||
// Security: Only relay ACK if both peers are in the same room
|
||||
if (senderMapping && targetMapping && senderMapping.roomId === targetMapping.roomId) {
|
||||
io.to(targetSocketId).emit(EVENTS.EVENT_ACK, {
|
||||
senderId: senderMapping.peerId,
|
||||
actionTimestamp: data.actionTimestamp
|
||||
});
|
||||
} else {
|
||||
log('SECURITY', `Blocked cross-room ACK attempt from ${socket.id} to ${data.targetId}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
eventCounts.delete(socket.id);
|
||||
@@ -405,7 +523,11 @@ setInterval(() => {
|
||||
const roomCutoff = now - (2 * 60 * 60 * 1000); // 2 hours
|
||||
const peerCutoff = now - (5 * 60 * 1000); // 5 minutes
|
||||
|
||||
for (const [roomId, room] of rooms) {
|
||||
// Snapshot room keys to avoid mutation during iteration
|
||||
const roomIds = Array.from(rooms.keys());
|
||||
for (const roomId of roomIds) {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) continue; // Room may have been deleted between snapshot and now
|
||||
// 1. Prune dead peers
|
||||
// Snapshot keys first — we must not mutate peerData while iterating it.
|
||||
const staleSids = [];
|
||||
@@ -417,14 +539,15 @@ setInterval(() => {
|
||||
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);
|
||||
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) {
|
||||
const currentRoom = rooms.get(roomId);
|
||||
if (currentRoom && (currentRoom.peers.size === 0 || currentRoom.lastActivity < roomCutoff)) {
|
||||
io.to(roomId).emit(EVENTS.ERROR, { message: 'Room closed' });
|
||||
rooms.delete(roomId);
|
||||
log('CLEANUP', `Deleted room ${roomId.substring(0, 3)}*** (Empty/Inactive)`);
|
||||
@@ -455,3 +578,11 @@ function gracefulShutdown(signal) {
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
log('ERROR', `Uncaught exception: ${err.message}`, err.stack);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
log('ERROR', `Unhandled rejection: ${reason}`);
|
||||
});
|
||||
|
||||
@@ -4,20 +4,30 @@ This directory contains constants and protocol definitions used by both the exte
|
||||
|
||||
## Syncing with the Extension
|
||||
> [!IMPORTANT]
|
||||
> Every time this file is modified, you must run `scripts/sync-constants.sh` to keep the extension's copy up to date.
|
||||
> Every time this directory is modified, you must run `node scripts/build-extension.js` to keep the extension's copy up to date.
|
||||
|
||||
Because Chrome Extensions cannot load files outside their root directory, `constants.js` must be copied to `extension/shared/constants.js` whenever it is modified.
|
||||
Because Browser Extensions (Manifest V3) cannot load files outside their root directory, all files in this directory must be copied to `extension/shared/` whenever they are modified. The build script handles this automatically.
|
||||
|
||||
## Security & Versioning Constants
|
||||
- `OFFICIAL_SERVER_TOKEN`: A 32-byte hex token required to connect to the official relay server.
|
||||
- `APP_VERSION`: The current version of the extension. Used by the server to enforce minimum version requirements (Revocation). This must always be in sync with `manifest.json`.
|
||||
- `APP_VERSION`: The current version of the extension. Automatically injected from the git tag during CI release builds.
|
||||
- `OFFICIAL_SERVER_URL`: The default endpoint for the official KoalaSync relay.
|
||||
- `ROOM_DATA`: Server response with current room state (peers).
|
||||
- `PLAY`: Sync command to start playback.
|
||||
- `PAUSE`: Sync command to pause playback.
|
||||
- `SEEK`: Sync command to change the current time.
|
||||
- `PEER_STATUS`: Heartbeat or join/leave notification for peers.
|
||||
- `FORCE_SYNC_PREPARE`: Phase 1 of Force Sync (Pause & Seek).
|
||||
- `FORCE_SYNC_ACK`: Peer confirmation of Phase 1 readiness.
|
||||
- `FORCE_SYNC_EXECUTE`: Phase 2 of Force Sync (Start Playback).
|
||||
- `ERROR`: Generic error message from the server.
|
||||
|
||||
## Protocol Events
|
||||
For the complete and current event list, see the `EVENTS` object in [`constants.js`](constants.js). Key events include:
|
||||
|
||||
| Event | Direction | Purpose |
|
||||
|:------|:----------|:--------|
|
||||
| `JOIN_ROOM` | Client → Server | Request to join a room with credentials |
|
||||
| `LEAVE_ROOM` | Client → Server | Leave the current room |
|
||||
| `ROOM_DATA` | Server → Client | Current room state (peers list) |
|
||||
| `PLAY` / `PAUSE` / `SEEK` | Bidirectional relay | Media control commands |
|
||||
| `PEER_STATUS` | Bidirectional relay | Heartbeat or join/leave notification |
|
||||
| `FORCE_SYNC_PREPARE` | Bidirectional relay | Phase 1: Pause & seek to target time |
|
||||
| `FORCE_SYNC_ACK` | Bidirectional relay | Phase 1 confirmation: peer is buffered |
|
||||
| `FORCE_SYNC_EXECUTE` | Bidirectional relay | Phase 2: Resume playback simultaneously |
|
||||
| `EVENT_ACK` | Server → Client | Delivery confirmation for UI feedback |
|
||||
| `EPISODE_LOBBY` | Bidirectional relay | Episode transition: waiting for all peers |
|
||||
| `EPISODE_READY` | Bidirectional relay | Episode confirmation: peer has loaded |
|
||||
| `GET_ROOMS` / `ROOM_LIST` | Client ↔ Server | Room discovery |
|
||||
| `ERROR` | Server → Client | Error message |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* blacklist.js
|
||||
*
|
||||
* ⚠️ WARNING: This is the SINGLE SOURCE OF TRUTH.
|
||||
* If you edit this file, you MUST run /scripts/sync-constants.bat
|
||||
* If you edit this file, you MUST run: node scripts/build-extension.js
|
||||
* to propagate changes to the extension and relay server.
|
||||
*
|
||||
* Domains to be filtered out from the tab selection dropdown to reduce "noise".
|
||||
@@ -17,13 +17,37 @@ export const BLACKLIST_DOMAINS = [
|
||||
'msn.com',
|
||||
'baidu.com',
|
||||
'yandex.ru',
|
||||
'ecosia.org',
|
||||
'startpage.com',
|
||||
'search.brave.com',
|
||||
'qwant.com',
|
||||
'you.com',
|
||||
'perplexity.ai',
|
||||
'ask.com',
|
||||
'search.yahoo.com',
|
||||
'swisscows.ch',
|
||||
'mojeek.com',
|
||||
|
||||
// Mail Providers
|
||||
'mail.google.com',
|
||||
'gmail.com',
|
||||
'outlook.live.com',
|
||||
'outlook.office.com',
|
||||
'mail.yahoo.com',
|
||||
'gmx.net',
|
||||
'gmx.de',
|
||||
'gmx.com',
|
||||
'web.de',
|
||||
'protonmail.com',
|
||||
'proton.me',
|
||||
't-online.de',
|
||||
'posteo.de',
|
||||
'mailbox.org',
|
||||
'mail.de',
|
||||
'zoho.com',
|
||||
'fastmail.com',
|
||||
'tutanota.com',
|
||||
'mail.ru',
|
||||
|
||||
// Cloud Storage & Documents
|
||||
'docs.google.com',
|
||||
@@ -61,6 +85,13 @@ export const BLACKLIST_DOMAINS = [
|
||||
'instagram.com',
|
||||
'reddit.com',
|
||||
'quora.com',
|
||||
'threads.net',
|
||||
'bsky.app',
|
||||
'mastodon.social',
|
||||
'vk.com',
|
||||
'weibo.com',
|
||||
'9gag.com',
|
||||
'imgur.com',
|
||||
|
||||
// E-Commerce
|
||||
'amazon.',
|
||||
@@ -78,13 +109,63 @@ export const BLACKLIST_DOMAINS = [
|
||||
'myanimelist.net',
|
||||
|
||||
// Development & Utilities
|
||||
'timer.shik3i.net',
|
||||
'koalastuff.net',
|
||||
'auth.koalastuff.net',
|
||||
'blog.koalastuff.net',
|
||||
'clicker.koalastuff.net',
|
||||
'cookies.koalastuff.net',
|
||||
'multibox.koalastuff.net',
|
||||
'snippets.koalastuff.net',
|
||||
'status.koalastuff.net',
|
||||
'sync.koalastuff.net',
|
||||
'timer.koalastuff.net',
|
||||
'localhost',
|
||||
'zoom.us',
|
||||
'teams.microsoft.com',
|
||||
'meet.google.com',
|
||||
'chrome.google.com',
|
||||
|
||||
// Music Streaming
|
||||
'music.youtube.com',
|
||||
'open.spotify.com',
|
||||
'soundcloud.com',
|
||||
'deezer.com',
|
||||
'tidal.com',
|
||||
|
||||
// Knowledge & Blogs
|
||||
'wikipedia.org',
|
||||
'medium.com',
|
||||
'dev.to',
|
||||
'news.ycombinator.com',
|
||||
|
||||
// Design & Creative Tools
|
||||
'figma.com',
|
||||
'canva.com',
|
||||
'miro.com',
|
||||
|
||||
// Online IDEs & Hosting
|
||||
'vscode.dev',
|
||||
'replit.com',
|
||||
'codesandbox.io',
|
||||
'vercel.com',
|
||||
'netlify.com',
|
||||
|
||||
// Social & Image Sharing
|
||||
'pinterest.com',
|
||||
'tumblr.com',
|
||||
|
||||
// Language Learning
|
||||
'duolingo.com',
|
||||
'hellotalk.com',
|
||||
|
||||
// Google Utilities
|
||||
'calendar.google.com',
|
||||
'keep.google.com',
|
||||
|
||||
// Finance & Payments
|
||||
'paypal.com',
|
||||
'stripe.com',
|
||||
|
||||
// Games & Idle Sites
|
||||
'milkywayidle.com',
|
||||
'melvoridle.com',
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
* 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
|
||||
* If you edit this file, you MUST run: node scripts/build-extension.js
|
||||
* to propagate changes to the extension and relay server.
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = "1.0.0";
|
||||
export const APP_VERSION = "1.2.1";
|
||||
export const APP_VERSION = "1.3.1";
|
||||
|
||||
export const OFFICIAL_SERVER_URL = 'wss://sync.shik3i.net';
|
||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://koalasync.shik3i.net';
|
||||
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
|
||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
|
||||
export const OFFICIAL_SERVER_TOKEN = '62170b705234c4f4807a9b22420bb93cf1a2aacfa4c5d3b47804482babb8eb50';
|
||||
|
||||
export const EVENTS = {
|
||||
@@ -40,5 +40,5 @@ export const EVENTS = {
|
||||
};
|
||||
|
||||
export const HEARTBEAT_INTERVAL = 15000; // 15s
|
||||
export const FORCE_SYNC_TIMEOUT = 5000; // 5s timeout for ACKs
|
||||
export const FORCE_SYNC_TIMEOUT = 8500; // 8.5s timeout for force sync ACKs (must be > content.js poll timeout of 8s)
|
||||
export const EPISODE_LOBBY_TIMEOUT = 60000; // 60s timeout for episode lobby
|
||||
|
||||
@@ -1,33 +1,50 @@
|
||||
# KoalaSync Landing Page
|
||||
# KoalaSync Website & Invitation Bridge
|
||||
|
||||
This directory contains the static marketing website for KoalaSync. It is built using vanilla HTML, CSS, and JavaScript to ensure maximum performance, zero tracking, and easy hosting.
|
||||
This directory contains the KoalaSync website. It serves a dual purpose: it is both the **marketing landing page** and the **technical bridge** for joining synchronized rooms.
|
||||
|
||||
## Features
|
||||
- **Privacy First**: No external fonts, scripts, or trackers.
|
||||
- **Modern Tech Aesthetic**: Pure CSS animated gradients and glassmorphism.
|
||||
- **Smart Join**: Integrated bridge for communication with the KoalaSync browser extension.
|
||||
## Core Roles
|
||||
|
||||
### 1. Marketing & Onboarding
|
||||
Provides a premium, bilingual (EN/DE) overview of features, setup instructions, and direct links to the extension stores.
|
||||
|
||||
### 2. The Invitation Bridge (`join.html`)
|
||||
The website handles incoming invitation links. When a user clicks a link like `sync.koalastuff.net/join.html#join:roomID:pass`, the website:
|
||||
- **Detects the Extension**: Verifies if KoalaSync is installed via the `bridge.js` content script.
|
||||
- **Privacy-First Handshake**: The room credentials (ID/Password) are stored in the **URL Hash (#)**. This ensures the sensitive credentials **never reach the web server** and are processed entirely within the user's browser.
|
||||
- **Auto-Join**: If the extension is detected, it automatically triggers the join flow without requiring user input.
|
||||
|
||||
## Architecture
|
||||
|
||||
The website is 100% **Static HTML, CSS, and JS**.
|
||||
- **Zero Backend**: No Node.js, PHP, or databases are required to host the website.
|
||||
- **Zero Tracking**: All assets (fonts, icons) are self-hosted to prevent third-party tracking.
|
||||
- **Responsive**: Fully optimized for mobile with a native-feel hamburger menu.
|
||||
|
||||
## Hosting with Caddy
|
||||
|
||||
Caddy is the recommended web server for KoalaSync due to its automatic HTTPS and simple configuration.
|
||||
Caddy is the recommended web server. It provides automatic HTTPS and high-performance static file serving.
|
||||
|
||||
### Example Caddyfile
|
||||
### Recommended Caddyfile
|
||||
|
||||
To host the website on `koalasync.shik3i.net`, you can use the following configuration:
|
||||
For a more comprehensive configuration that includes the Relay Server reverse proxy, see the root [Caddyfile.example](../Caddyfile.example).
|
||||
|
||||
```caddy
|
||||
koalasync.shik3i.net {
|
||||
# Path to the website directory
|
||||
sync.koalastuff.net {
|
||||
root * /var/www/koalasync/website
|
||||
|
||||
# Enable static file serving
|
||||
file_server
|
||||
|
||||
# Enable Gzip/Zstd compression
|
||||
encode zstd gzip
|
||||
|
||||
# Security Headers
|
||||
|
||||
# Static Caching for high-performance PageSpeed (1 year with validation)
|
||||
@static {
|
||||
file
|
||||
path *.ico *.css *.js *.png *.svg *.webp
|
||||
}
|
||||
header @static Cache-Control "public, max-age=31536000, must-revalidate"
|
||||
|
||||
# Security Headers & Content Security Policy (CSP)
|
||||
header {
|
||||
# Strict Content Security Policy (restricts scripts and connections to self, forbids frames)
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';"
|
||||
# Prevent FLoC tracking
|
||||
Permissions-Policy interest-cohort=()
|
||||
# Security best practices
|
||||
@@ -36,16 +53,10 @@ koalasync.shik3i.net {
|
||||
X-Frame-Options DENY
|
||||
Referrer-Policy no-referrer-when-downgrade
|
||||
}
|
||||
|
||||
# Custom 404 page
|
||||
handle_errors {
|
||||
rewrite * /{err.status_code}.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Deployment Steps
|
||||
1. Copy the contents of this folder to `/var/www/koalasync/website` on your server.
|
||||
2. Update the path in your `Caddyfile`.
|
||||
3. Reload Caddy: `caddy reload`.
|
||||
## Local Development
|
||||
|
||||
1. Open `index.html` directly in any browser.
|
||||
2. To test the invitation flow locally, use a local server (e.g., `npx serve .`) and navigate to `http://localhost:5000/join.html#join:test-room:test-pass`.
|
||||
|
||||
@@ -71,17 +71,48 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
if (actions) {
|
||||
if (!isInstalled) {
|
||||
actions.innerHTML = `
|
||||
<a href="#" class="primary" style="text-align:center; text-decoration:none; display:block; padding: 1.2rem; background: var(--accent); color: white; border-radius: 12px; font-weight: 700;">GET IT ON CHROME WEBSTORE</a>
|
||||
<a href="https://github.com/shik3i/KoalaSync" style="text-align:center; color:var(--accent); text-decoration:underline; font-size:0.85rem; margin-top:0.8rem; display:block; font-weight: 600;">Download via GitHub</a>
|
||||
<p style="text-align:center; font-size:0.8rem; opacity:0.7; margin-top: 1.2rem; color: var(--text-muted);">The extension is required to join and sync videos.</p>
|
||||
`;
|
||||
const isFirefox = navigator.userAgent.includes('Firefox');
|
||||
if (isFirefox) {
|
||||
actions.innerHTML = `
|
||||
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/" class="primary btn-firefox" style="text-align:center; text-decoration:none; display:flex; align-items:center; justify-content:center; gap: 8px; padding: 1.2rem; border-radius: 12px; font-weight: 700;">
|
||||
<span>🦊</span>
|
||||
<span lang="en">GET IT ON MOZILLA ADD-ONS</span><span lang="de">IM FIREFOX ADD-ON STORE HERUNTERLADEN</span>
|
||||
</a>
|
||||
<a href="https://github.com/shik3i/KoalaSync" style="text-align:center; text-decoration:none; display:flex; align-items:center; justify-content:center; gap: 8px; padding: 0.8rem; background: rgba(255, 255, 255, 0.04); border: 1px solid var(--glass-border); color: #e66000; border-radius: 12px; font-weight: 700; font-size: 0.85rem; margin-top: 0.5rem; transition: background 0.2s;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
<span lang="en">Download via GitHub</span><span lang="de">Über GitHub herunterladen</span>
|
||||
</a>
|
||||
<p style="text-align:center; font-size:0.8rem; opacity:0.7; margin-top: 1.2rem; color: var(--text-muted);">
|
||||
<span lang="en">The extension is required to join and sync videos.</span>
|
||||
<span lang="de">Die Erweiterung ist erforderlich, um beizutreten und Videos zu synchronisieren.</span>
|
||||
</p>
|
||||
`;
|
||||
} else {
|
||||
actions.innerHTML = `
|
||||
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc" class="primary" style="text-align:center; text-decoration:none; display:flex; align-items:center; justify-content:center; gap: 8px; padding: 1.2rem; background: var(--accent); color: white; border-radius: 12px; font-weight: 700;">
|
||||
<img src="assets/chrome.svg" width="20" style="display: block;">
|
||||
<span lang="en">GET IT ON CHROME WEBSTORE</span><span lang="de">IM CHROME WEB STORE HERUNTERLADEN</span>
|
||||
</a>
|
||||
<a href="https://github.com/shik3i/KoalaSync" style="text-align:center; text-decoration:none; display:flex; align-items:center; justify-content:center; gap: 8px; padding: 0.8rem; background: rgba(255, 255, 255, 0.04); border: 1px solid var(--glass-border); color: var(--accent); border-radius: 12px; font-weight: 700; font-size: 0.85rem; margin-top: 0.5rem; transition: background 0.2s;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
<span lang="en">Download via GitHub</span><span lang="de">Über GitHub herunterladen</span>
|
||||
</a>
|
||||
<p style="text-align:center; font-size:0.8rem; opacity:0.7; margin-top: 1.2rem; color: var(--text-muted);">
|
||||
<span lang="en">The extension is required to join and sync videos.</span>
|
||||
<span lang="de">Die Erweiterung ist erforderlich, um beizutreten und Videos zu synchronisieren.</span>
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
actions.innerHTML = `
|
||||
<div class="joining-spinner" style="text-align:center; padding: 1rem;">
|
||||
<div style="font-size: 1.2rem; margin-bottom: 0.5rem;">🚀</div>
|
||||
<div style="font-weight: 600; color: var(--accent);">Joining room automatically...</div>
|
||||
<p style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.5rem;">Your extension is taking care of it.</p>
|
||||
<div style="font-weight: 600; color: var(--accent);">
|
||||
<span lang="en">Joining room automatically...</span><span lang="de">Raum wird automatisch betreten...</span>
|
||||
</div>
|
||||
<p style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.5rem;">
|
||||
<span lang="en">Your extension is taking care of it.</span><span lang="de">Deine Erweiterung kümmert sich darum.</span>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -161,14 +192,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
if (success) {
|
||||
if (icon) icon.textContent = '✅';
|
||||
title.textContent = 'Erfolgreich!';
|
||||
const isDE = document.documentElement.classList.contains('lang-de');
|
||||
title.textContent = isDE ? 'Erfolgreich!' : 'Success!';
|
||||
|
||||
let count = 3;
|
||||
const updateCountdown = () => {
|
||||
desc.innerHTML = `Du bist dem Raum beigetreten. <br><span style="color:var(--accent); font-weight:bold;">Dieser Tab schließt sich in ${count} Sekunden...</span>`;
|
||||
const closingMsg = isDE
|
||||
? `Du bist dem Raum beigetreten. <br><span style="color:var(--accent); font-weight:bold;">Dieser Tab schließt sich in ${count} Sekunden...</span>`
|
||||
: `You joined the room. <br><span style="color:var(--accent); font-weight:bold;">This tab will close in ${count} seconds...</span>`;
|
||||
desc.innerHTML = closingMsg;
|
||||
if (count <= 0) {
|
||||
window.close();
|
||||
desc.textContent = 'Beitritt erfolgreich! Du kannst diesen Tab jetzt manuell schließen.';
|
||||
desc.textContent = isDE ? 'Beitritt erfolgreich! Du kannst diesen Tab jetzt manuell schließen.' : 'Joined successfully! You can close this tab manually.';
|
||||
} else {
|
||||
count--;
|
||||
setTimeout(updateCountdown, 1000);
|
||||
@@ -176,12 +211,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
};
|
||||
updateCountdown();
|
||||
|
||||
actions.innerHTML = '<button class="primary" onclick="window.close()" style="background:var(--success); width: 100%;">TAB JETZT SCHLIESSEN</button>';
|
||||
const closeLabel = isDE ? 'TAB JETZT SCHLIESSEN' : 'CLOSE TAB NOW';
|
||||
actions.innerHTML = `<button class="primary" onclick="window.close()" style="background:var(--success); width: 100%;">${closeLabel}</button>`;
|
||||
} else {
|
||||
if (icon) icon.textContent = '❌';
|
||||
title.textContent = 'Fehler';
|
||||
desc.textContent = `Beitritt fehlgeschlagen: ${message}`;
|
||||
actions.innerHTML = '<button class="primary" onclick="location.reload()" style="width: 100%;">ERNEUT VERSUCHEN</button>';
|
||||
const isDE = document.documentElement.classList.contains('lang-de');
|
||||
title.textContent = isDE ? 'Fehler' : 'Error';
|
||||
desc.textContent = isDE ? `Beitritt fehlgeschlagen: ${message}` : `Join failed: ${message}`;
|
||||
const retryLabel = isDE ? 'ERNEUT VERSUCHEN' : 'TRY AGAIN';
|
||||
actions.innerHTML = `<button class="primary" onclick="location.reload()" style="width: 100%;">${retryLabel}</button>`;
|
||||
}
|
||||
} else {
|
||||
const banner = document.getElementById('koala-banner');
|
||||
@@ -202,5 +240,171 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const updateDynamicVersion = async () => {
|
||||
try {
|
||||
const response = await fetch('version.json');
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
const { version, date } = data;
|
||||
if (!version || !date) return;
|
||||
|
||||
const releaseDate = new Date(date);
|
||||
const now = new Date();
|
||||
const diffMs = now - releaseDate;
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
|
||||
let relativeTimeEn = '';
|
||||
let relativeTimeDe = '';
|
||||
|
||||
if (diffDays > 0) {
|
||||
relativeTimeEn = `${diffDays} ${diffDays === 1 ? 'day' : 'days'} ago`;
|
||||
relativeTimeDe = `vor ${diffDays} ${diffDays === 1 ? 'Tag' : 'Tagen'}`;
|
||||
} else if (diffHours > 0) {
|
||||
relativeTimeEn = `${diffHours} ${diffHours === 1 ? 'hour' : 'hours'} ago`;
|
||||
relativeTimeDe = `vor ${diffHours} ${diffHours === 1 ? 'Stunde' : 'Stunden'}`;
|
||||
} else if (diffMins > 0) {
|
||||
relativeTimeEn = `${diffMins} ${diffMins === 1 ? 'minute' : 'minutes'} ago`;
|
||||
relativeTimeDe = `vor ${diffMins} ${diffMins === 1 ? 'Minute' : 'Minuten'}`;
|
||||
} else {
|
||||
relativeTimeEn = 'just now';
|
||||
relativeTimeDe = 'gerade eben';
|
||||
}
|
||||
|
||||
const badgeEn = document.querySelector('.version-text-en');
|
||||
const badgeDe = document.querySelector('.version-text-de');
|
||||
|
||||
if (badgeEn) {
|
||||
badgeEn.textContent = `v${version} OUT NOW • ${relativeTimeEn}`;
|
||||
}
|
||||
if (badgeDe) {
|
||||
badgeDe.textContent = `v${version} JETZT VERFÜGBAR • ${relativeTimeDe}`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch dynamic version info:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Extension Mockup Tab Switcher
|
||||
const mockTabs = document.querySelectorAll('.mock-tab');
|
||||
const mockScreens = document.querySelectorAll('.mock-screen');
|
||||
|
||||
mockTabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
mockTabs.forEach(t => t.classList.remove('active'));
|
||||
mockScreens.forEach(s => s.classList.remove('active'));
|
||||
|
||||
tab.classList.add('active');
|
||||
const targetId = tab.getAttribute('data-target');
|
||||
const targetScreen = document.getElementById(targetId);
|
||||
if (targetScreen) {
|
||||
targetScreen.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Terminal Tab Switcher
|
||||
const termTabBtns = document.querySelectorAll('.terminal-tab-btn');
|
||||
const termPanes = document.querySelectorAll('.terminal-pane');
|
||||
|
||||
termTabBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
termTabBtns.forEach(b => b.classList.remove('active'));
|
||||
termPanes.forEach(p => p.classList.remove('active'));
|
||||
|
||||
btn.classList.add('active');
|
||||
const targetPaneId = btn.getAttribute('data-tab');
|
||||
const targetPane = document.getElementById(targetPaneId);
|
||||
if (targetPane) {
|
||||
targetPane.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Terminal Clipboard Copy
|
||||
const copyBtn = document.querySelector('.terminal-copy-btn');
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', () => {
|
||||
const activePane = document.querySelector('.terminal-pane.active');
|
||||
if (!activePane) return;
|
||||
const codeElement = activePane.querySelector('code');
|
||||
if (!codeElement) return;
|
||||
|
||||
const textToCopy = codeElement.innerText || codeElement.textContent;
|
||||
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
const isDE = document.documentElement.classList.contains('lang-de');
|
||||
const originalHTML = copyBtn.innerHTML;
|
||||
|
||||
copyBtn.innerHTML = isDE ? '✅ Kopiert!' : '✅ Copied!';
|
||||
copyBtn.disabled = true;
|
||||
|
||||
setTimeout(() => {
|
||||
copyBtn.innerHTML = originalHTML;
|
||||
copyBtn.disabled = false;
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy text: ', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Mobile Hamburger Menu Toggle
|
||||
const hamburger = document.querySelector('.hamburger');
|
||||
const navLinks = document.querySelector('.nav-links');
|
||||
if (hamburger && navLinks) {
|
||||
hamburger.addEventListener('click', () => {
|
||||
navLinks.classList.toggle('open');
|
||||
});
|
||||
}
|
||||
|
||||
// Language Selection Umschalter
|
||||
const toggleLanguage = (e) => {
|
||||
if (e) e.preventDefault();
|
||||
const html = document.documentElement;
|
||||
const currentIsEnglish = html.classList.contains('lang-en');
|
||||
const newLang = currentIsEnglish ? 'de' : 'en';
|
||||
html.classList.remove('lang-en', 'lang-de');
|
||||
html.classList.add('lang-' + newLang);
|
||||
html.lang = newLang;
|
||||
localStorage.setItem('koala_lang', newLang);
|
||||
|
||||
// Update titles dynamically based on page
|
||||
var path = window.location.pathname;
|
||||
var isIndex = path === '/' || path.endsWith('index.html') || path.split('/').pop() === '';
|
||||
var isJoin = path.endsWith('join.html');
|
||||
|
||||
if (isIndex) {
|
||||
const titles = {
|
||||
en: 'KoalaSync | Real-time Video Synchronization for Friends',
|
||||
de: 'KoalaSync | Echtzeit-Video-Synchronisation für Freunde'
|
||||
};
|
||||
document.title = titles[newLang] || titles.en;
|
||||
} else if (isJoin) {
|
||||
const titles = {
|
||||
en: 'Join Room | KoalaSync',
|
||||
de: 'Raum beitreten | KoalaSync'
|
||||
};
|
||||
document.title = titles[newLang] || titles.en;
|
||||
}
|
||||
};
|
||||
|
||||
document.querySelectorAll('.lang-toggle').forEach(btn => {
|
||||
btn.addEventListener('click', toggleLanguage);
|
||||
});
|
||||
|
||||
// Impressum Email Obfuscation Click Reveal
|
||||
document.querySelectorAll('.email-reveal').forEach(el => {
|
||||
el.addEventListener('click', function() {
|
||||
const user = this.getAttribute('data-user');
|
||||
const domain = this.getAttribute('data-domain');
|
||||
if (user && domain) {
|
||||
this.innerHTML = `${user}@${domain}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
checkInvite();
|
||||
updateDynamicVersion();
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
|
After Width: | Height: | Size: 835 B |
|
Before Width: | Height: | Size: 670 KiB After Width: | Height: | Size: 26 KiB |
@@ -1,11 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Datenschutz | KoalaSync</title>
|
||||
<title>Datenschutz / Privacy Policy | KoalaSync</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="icon" type="image/png" href="assets/logo.png">
|
||||
<meta name="robots" content="noindex">
|
||||
<script src="lang-init.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
@@ -20,54 +22,110 @@
|
||||
<img src="assets/logo.png" alt="KoalaSync Logo">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<button class="hamburger" aria-label="Menu">☰</button>
|
||||
<div class="nav-links">
|
||||
<a href="index.html">Home</a>
|
||||
<a href="https://github.com/shik3i/KoalaSync" target="_blank">GitHub</a>
|
||||
<a href="index.html"><span lang="de">Startseite</span><span lang="en">Home</span></a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank">GitHub</a>
|
||||
<a class="lang-toggle" style="cursor: pointer;">🌐 EN/DE</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="legal-content">
|
||||
<div class="legal-card" data-reveal style="padding: 2rem;">
|
||||
<h1>Datenschutz</h1>
|
||||
<p style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
|
||||
<h1 lang="de">Datenschutz</h1>
|
||||
<h1 lang="en">Privacy Policy</h1>
|
||||
<p lang="de" style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
|
||||
Sicherheit & Privatsphäre
|
||||
</p>
|
||||
<p lang="en" style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
|
||||
Security & Privacy
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>1. Hosting & Logfiles</h2>
|
||||
<p>
|
||||
<h2>
|
||||
<span lang="de">1. Hosting & Logfiles</span>
|
||||
<span lang="en">1. Hosting & Logfiles</span>
|
||||
</h2>
|
||||
<p lang="de">
|
||||
Diese Seite wird auf einem privaten Server gehostet. Zur Gewährleistung der Stabilität werden standardmäßige Server-Logs (IP, Browser, Zeit) erhoben, aber nicht mit Personen verknüpft und nach 7 Tagen automatisch gelöscht.
|
||||
</p>
|
||||
<p lang="en">
|
||||
This site is hosted on a private server. To ensure stability, standard server logs (IP, browser, time) are collected, but not linked to individuals and are automatically deleted after 7 days.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Keine Drittanbieter & Tracking</h2>
|
||||
<p>
|
||||
<h2>
|
||||
<span lang="de">2. Keine Drittanbieter & Open Source</span>
|
||||
<span lang="en">2. No Third Parties & Open Source</span>
|
||||
</h2>
|
||||
<p lang="de">
|
||||
KoalaSync verzichtet bewusst auf Analyse-Tools, Tracking-Cookies oder Werbenetzwerke. Wir laden keine Ressourcen von Drittanbietern (wie Google Fonts) nach, um Ihre Privatsphäre maximal zu schützen.
|
||||
</p>
|
||||
<p lang="de" style="margin-top: 0.5rem;">
|
||||
Da KoalaSync vollständig Open Source ist, kann zudem jede Zeile Code auf unserem <a href="https://github.com/shik3i/KoalaSync" target="_blank" style="color: var(--accent);">GitHub-Repository</a> öffentlich eingesehen und auf Sicherheit geprüft werden.
|
||||
</p>
|
||||
<p lang="en">
|
||||
KoalaSync deliberately avoids analytics tools, tracking cookies, or advertising networks. We do not load any third-party resources (such as Google Fonts) to maximize the protection of your privacy.
|
||||
</p>
|
||||
<p lang="en" style="margin-top: 0.5rem;">
|
||||
Since KoalaSync is 100% open-source, every single line of code can also be publicly viewed and audited for security on our <a href="https://github.com/shik3i/KoalaSync" target="_blank" style="color: var(--accent);">GitHub repository</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. Relay-Server Architektur</h2>
|
||||
<p>
|
||||
<h2>
|
||||
<span lang="de">3. Relay-Server Architektur</span>
|
||||
<span lang="en">3. Relay Server Architecture</span>
|
||||
</h2>
|
||||
<p lang="de">
|
||||
Unser Relay-Server arbeitet ausschließlich im Arbeitsspeicher (RAM). Nachrichten zwischen Teilnehmern werden nicht auf Festplatten gespeichert und sind flüchtig. Sobald ein Raum geschlossen wird, werden alle zugehörigen Metadaten sofort gelöscht.
|
||||
</p>
|
||||
<p lang="en">
|
||||
Our relay server operates exclusively in memory (RAM). Messages between participants are not stored on hard drives and are volatile. As soon as a room is closed, all associated metadata is immediately deleted.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4. Brute-Force Schutz</h2>
|
||||
<p>
|
||||
<h2>
|
||||
<span lang="de">4. Browser-Erweiterung (Extension)</span>
|
||||
<span lang="en">4. Browser Extension</span>
|
||||
</h2>
|
||||
<p lang="de">
|
||||
Um die geräteübergreifende Synchronisation zu ermöglichen, erfasst die KoalaSync Browser-Erweiterung temporär Daten des aktuell aktiven Video-Tabs (z. B. Tab-Titel, Medien-Metadaten wie den Videotitel sowie den Wiedergabestatus). Diese Daten werden ausschließlich zur Synchronisation an die anderen Teilnehmer in Ihrem Raum gesendet. Es wird ausdrücklich <strong>kein allgemeiner Browserverlauf (Browsing History)</strong> ausgelesen, gespeichert oder übermittelt.
|
||||
</p>
|
||||
<p lang="en">
|
||||
To enable cross-device synchronization, the KoalaSync browser extension temporarily captures data from the currently active video tab (e.g., tab title, media metadata like the video title, and playback state). This data is exclusively sent to other participants in your room for synchronization. We explicitly <strong>do not read, store, or transmit your general browsing history</strong>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>
|
||||
<span lang="de">5. Brute-Force Schutz</span>
|
||||
<span lang="en">5. Brute-Force Protection</span>
|
||||
</h2>
|
||||
<p lang="de">
|
||||
Zur Sicherheit unserer Nutzer speichern wir fehlgeschlagene Login-Versuche (IP-Adresse und Raum-ID) für maximal 15 Minuten im RAM, um automatisierte Angriffe zu verhindern. Diese Daten werden danach rückstandslos gelöscht.
|
||||
</p>
|
||||
<p lang="en">
|
||||
For the security of our users, we store failed login attempts (IP address and room ID) for a maximum of 15 minutes in RAM to prevent automated attacks. This data is deleted without a trace afterwards.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5. Ihre Rechte</h2>
|
||||
<p>
|
||||
<h2>
|
||||
<span lang="de">6. Ihre Rechte</span>
|
||||
<span lang="en">6. Your Rights</span>
|
||||
</h2>
|
||||
<p lang="de">
|
||||
Sie haben das Recht auf Auskunft, Berichtigung oder Löschung Ihrer Daten. Da wir jedoch keine personenbezogenen Daten dauerhaft speichern, ist eine Zuordnung zu Ihrer Person in der Regel technisch nicht möglich.
|
||||
</p>
|
||||
<p>Kontakt bei Fragen: <span style="color: var(--accent); cursor: pointer; text-decoration: underline;" onclick="this.innerHTML='koalasync_datenschutz' + '@' + 'koalamail.rocks'">[E-Mail anzeigen]</span></p>
|
||||
<p lang="en">
|
||||
You have the right to information, correction, or deletion of your data. However, since we do not store any personal data permanently, linking data to your person is technically impossible in most cases.
|
||||
</p>
|
||||
<p lang="de">Kontakt bei Fragen: <span style="color: var(--accent); cursor: pointer; text-decoration: underline;" onclick="this.innerHTML='koalasync_datenschutz' + '@' + 'koalamail.rocks'">[E-Mail anzeigen]</span></p>
|
||||
<p lang="en">Contact for questions: <span style="color: var(--accent); cursor: pointer; text-decoration: underline;" onclick="this.innerHTML='koalasync_datenschutz' + '@' + 'koalamail.rocks'">[Show Email]</span></p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
@@ -75,10 +133,11 @@
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. Open source under the MIT License.</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">No data is stored on our servers. Pure RAM-based relay.</p>
|
||||
<p lang="de" style="font-size: 0.8rem; margin-top: 0.5rem;">Keine Daten werden auf unseren Servern gespeichert. Reines RAM-basiertes Relay.</p>
|
||||
<p lang="en" style="font-size: 0.8rem; margin-top: 0.5rem;">No data is stored on our servers. Pure RAM-based relay.</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; gap: 1.5rem;">
|
||||
<a href="impressum.html" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="datenschutz.html" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
|
||||
<a href="impressum.html" style="color: var(--text-muted); text-decoration: none;"><span lang="de">Impressum</span><span lang="en">Legal Notice</span></a>
|
||||
<a href="datenschutz.html" style="color: var(--text-muted); text-decoration: none;"><span lang="de">Datenschutz</span><span lang="en">Privacy Policy</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Impressum | KoalaSync</title>
|
||||
<title>Impressum / Legal Notice | KoalaSync</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="icon" type="image/png" href="assets/logo.png">
|
||||
<meta name="robots" content="noindex">
|
||||
<script src="lang-init.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
@@ -20,57 +22,95 @@
|
||||
<img src="assets/logo.png" alt="KoalaSync Logo">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<button class="hamburger" aria-label="Menu">☰</button>
|
||||
<div class="nav-links">
|
||||
<a href="index.html">Home</a>
|
||||
<a href="https://github.com/shik3i/KoalaSync" target="_blank">GitHub</a>
|
||||
<a href="index.html"><span lang="de">Startseite</span><span lang="en">Home</span></a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank">GitHub</a>
|
||||
<a class="lang-toggle" style="cursor: pointer;">🌐 EN/DE</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="legal-content">
|
||||
<div class="legal-card" data-reveal style="padding: 2rem;">
|
||||
<h1>Impressum</h1>
|
||||
<p style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
|
||||
<h1 lang="de">Impressum</h1>
|
||||
<h1 lang="en">Legal Notice</h1>
|
||||
<p lang="de" style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
|
||||
Transparenz & Identifikation
|
||||
</p>
|
||||
<p lang="en" style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
|
||||
Transparency & Identification
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>Betreiber & Kontakt</h2>
|
||||
<p>Administrator KoalaSync (Privatperson)</p>
|
||||
<p>E-Mail: <span style="color: var(--accent); cursor: pointer; text-decoration: underline;" onclick="this.innerHTML='koalasync_admin' + '@' + 'koalamail.rocks'">[E-Mail anzeigen]</span></p>
|
||||
<h2>
|
||||
<span lang="de">Betreiber & Kontakt</span>
|
||||
<span lang="en">Operator & Contact</span>
|
||||
</h2>
|
||||
<p lang="de">Administrator KoalaSync (Privatperson)</p>
|
||||
<p lang="en">Administrator KoalaSync (Private Individual)</p>
|
||||
<p lang="de">E-Mail: <span class="email-reveal" data-user="koalasync_admin" data-domain="koalamail.rocks" style="color: var(--accent); cursor: pointer; text-decoration: underline;">[E-Mail anzeigen]</span></p>
|
||||
<p lang="en">E-Mail: <span class="email-reveal" data-user="koalasync_admin" data-domain="koalamail.rocks" style="color: var(--accent); cursor: pointer; text-decoration: underline;">[Show Email]</span></p>
|
||||
</section>
|
||||
|
||||
<section style="opacity: 0.8;">
|
||||
<h2>Privatprojekt-Hinweis</h2>
|
||||
<p>
|
||||
<h2>
|
||||
<span lang="de">Privatprojekt-Hinweis</span>
|
||||
<span lang="en">Private Project Notice</span>
|
||||
</h2>
|
||||
<p lang="de">
|
||||
Diese Website ist ein rein privates Hobby-Projekt und dient keinen geschäftsmäßigen Zwecken.
|
||||
Eine Impressumspflicht nach § 5 DDG (ehemals TMG) besteht daher nicht.
|
||||
Diese Angaben erfolgen rein freiwillig zur Transparenz gegenüber der Community.
|
||||
</p>
|
||||
<p lang="en">
|
||||
This website is a purely private hobby project and does not serve any commercial purposes.
|
||||
Therefore, there is no obligation to provide a legal notice according to § 5 DDG (formerly TMG).
|
||||
This information is provided voluntarily for transparency towards the community.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Haftung für Inhalte</h2>
|
||||
<p>
|
||||
<h2>
|
||||
<span lang="de">Haftung für Inhalte</span>
|
||||
<span lang="en">Liability for Content</span>
|
||||
</h2>
|
||||
<p lang="de">
|
||||
Gemäß § 7 Abs.1 DDG sind wir für eigene Inhalte verantwortlich. Nach §§ 8 bis 10 DDG sind wir jedoch nicht verpflichtet,
|
||||
übermittelte oder gespeicherte fremde Informationen zu überwachen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Haftung für Links</h2>
|
||||
<p>
|
||||
Unser Angebot enthält Links zu externen Websites Dritter. Auf deren Inhalte haben wir keinen Einfluss und
|
||||
können daher keine Gewähr für diese fremden Inhalte übernehmen.
|
||||
<p lang="en">
|
||||
According to § 7 Abs.1 DDG we are responsible for our own content. According to §§ 8 to 10 DDG, however, we are not obligated to monitor transmitted or stored third-party information.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Urheberrecht</h2>
|
||||
<p>
|
||||
<h2>
|
||||
<span lang="de">Haftung für Links</span>
|
||||
<span lang="en">Liability for Links</span>
|
||||
</h2>
|
||||
<p lang="de">
|
||||
Unser Angebot enthält Links zu externen Websites Dritter. Auf deren Inhalte haben wir keinen Einfluss und
|
||||
können daher keine Gewähr für diese fremden Inhalte übernehmen.
|
||||
</p>
|
||||
<p lang="en">
|
||||
Our offer contains links to external third-party websites. We have no influence on their content and therefore cannot assume any liability for these external contents.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>
|
||||
<span lang="de">Urheberrecht</span>
|
||||
<span lang="en">Copyright</span>
|
||||
</h2>
|
||||
<p lang="de">
|
||||
Die durch die Seitenbetreiber erstellten Inhalte auf diesen Seiten unterliegen dem deutschen Urheberrecht.
|
||||
Vervielfältigung, Bearbeitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung.
|
||||
</p>
|
||||
<p lang="en">
|
||||
The content and works created by the site operators on these pages are subject to German copyright law.
|
||||
Duplication, processing, and any kind of exploitation outside the limits of copyright require written consent.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
@@ -78,10 +118,11 @@
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. Open source under the MIT License.</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">No data is stored on our servers. Pure RAM-based relay.</p>
|
||||
<p lang="de" style="font-size: 0.8rem; margin-top: 0.5rem;">Keine Daten werden auf unseren Servern gespeichert. Reines RAM-basiertes Relay.</p>
|
||||
<p lang="en" style="font-size: 0.8rem; margin-top: 0.5rem;">No data is stored on our servers. Pure RAM-based relay.</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; gap: 1.5rem;">
|
||||
<a href="impressum.html" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="datenschutz.html" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
|
||||
<a href="impressum.html" style="color: var(--text-muted); text-decoration: none;"><span lang="de">Impressum</span><span lang="en">Legal Notice</span></a>
|
||||
<a href="datenschutz.html" style="color: var(--text-muted); text-decoration: none;"><span lang="de">Datenschutz</span><span lang="en">Privacy Policy</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -4,14 +4,16 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KoalaSync | Real-time Video Synchronization for Friends</title>
|
||||
<meta name="description" content="Watch YouTube, Twitch, and HTML5 videos perfectly synchronized with friends. KoalaSync is a privacy-first, open-source Chrome extension.">
|
||||
<meta name="description" content="Watch YouTube, Twitch, and HTML5 videos in sync with friends. KoalaSync is a privacy-first, open-source browser extension for Chrome and Firefox.">
|
||||
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="icon" type="image/png" href="assets/logo.png">
|
||||
<meta property="og:title" content="KoalaSync | Sync your videos">
|
||||
<meta property="og:description" content="Watch together, stay in sync. Privacy-first video synchronization.">
|
||||
<meta property="og:image" content="assets/hero.png">
|
||||
<meta property="og:image" content="https://sync.koalastuff.net/assets/logo.png">
|
||||
<meta property="og:type" content="website">
|
||||
|
||||
<script src="lang-init.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -27,10 +29,18 @@
|
||||
<img src="assets/logo.png" alt="KoalaSync Logo">
|
||||
<span>KoalaSync</span>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Menu">☰</button>
|
||||
<div class="nav-links">
|
||||
<a href="#features">Features</a>
|
||||
<a href="#how-it-works">How it works</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank">GitHub</a>
|
||||
<a href="#features"><span lang="en">Features</span><span lang="de">Funktionen</span></a>
|
||||
<a href="#how-it-works"><span lang="en">How it works</span><span lang="de">So funktioniert's</span></a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
<a class="lang-toggle" style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 16px; height: 16px; display: block;"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
|
||||
EN/DE
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -38,17 +48,195 @@
|
||||
<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="version-badge" data-reveal><span lang="en" class="version-text-en">v1.5.3 OUT NOW</span><span lang="de" class="version-text-de">v1.5.3 JETZT VERFÜGBAR</span></div>
|
||||
<h1 data-reveal>
|
||||
<span lang="en">Watch Together.<br>Sync Perfectly.</span>
|
||||
<span lang="de">Gemeinsam schauen.<br>Perfekt synchron.</span>
|
||||
</h1>
|
||||
<h2 class="hero-subtitle" data-reveal>
|
||||
<span lang="en">A free, open-source watch party extension for YouTube, Twitch, Netflix, and any website with a video player. Built for reliable synchronization and data sovereignty.</span>
|
||||
<span lang="de">Eine kostenlose, quelloffene Watch-Party-Erweiterung für YouTube, Twitch, Netflix und jede Webseite mit einem Videoplayer. Entwickelt für zuverlässige Synchronisation und Datenhoheit.</span>
|
||||
</h2>
|
||||
<div class="cta-group" data-reveal>
|
||||
<a href="#" class="btn btn-primary">
|
||||
<img src="assets/chrome.svg" width="20" style="filter: brightness(100)">
|
||||
Add to Chrome
|
||||
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc" class="btn btn-primary">
|
||||
<img src="assets/chrome.svg" width="20">
|
||||
<span lang="en">Add to Chrome</span><span lang="de">Zu Chrome hinzufügen</span>
|
||||
</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" class="btn btn-secondary">
|
||||
GitHub Repo
|
||||
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/" class="btn btn-firefox">
|
||||
🦊
|
||||
<span lang="en">Add to Firefox</span><span lang="de">Zu Firefox hinzufügen</span>
|
||||
</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/releases" target="_blank" class="btn btn-secondary" style="display: inline-flex; align-items: center; gap: 8px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="20" height="20" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub Releases
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Interactive CSS Extension Mockup -->
|
||||
<div class="hero-mockup-wrapper" data-reveal>
|
||||
<div class="extension-mockup">
|
||||
<div class="mock-header">
|
||||
<img src="assets/logo.png" alt="KoalaSync">
|
||||
<span>KOALASYNC</span>
|
||||
</div>
|
||||
<div class="mock-tabs">
|
||||
<button class="mock-tab active" data-target="mock-room">Room</button>
|
||||
<button class="mock-tab" data-target="mock-sync">Sync</button>
|
||||
<button class="mock-tab" data-target="mock-settings">Settings</button>
|
||||
<button class="mock-tab" data-target="mock-dev">Dev</button>
|
||||
</div>
|
||||
<div class="mock-body">
|
||||
<!-- ROOM TAB -->
|
||||
<div id="mock-room" class="mock-screen active">
|
||||
<div class="mock-joined-room" style="display: flex; justify-content: space-between; align-items: center; border-left: 4px solid var(--accent); padding: 8px 10px; background: var(--card); border-radius: 8px; margin-bottom: 12px;">
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<span style="font-size: 0.6rem; text-transform: uppercase; color: var(--text-muted); font-weight: 700; line-height: 1;"><span lang="en">Active Room</span><span lang="de">Aktiver Raum</span></span>
|
||||
<span class="mock-room-name" style="font-weight: 700; color: var(--accent); font-size: 0.9rem; letter-spacing: 0.5px; margin-top: 2px;">brave-eagle-80</span>
|
||||
</div>
|
||||
<span class="mock-server-badge" style="font-size: 0.6rem; color: var(--text-muted);"><span lang="en">Official Server</span><span lang="de">Offizieller Server</span></span>
|
||||
</div>
|
||||
<div class="mock-card">
|
||||
<div class="mock-label"><span lang="en">Invite Link</span><span lang="de">Einladungs-Link</span></div>
|
||||
<div class="mock-invite-box">
|
||||
<input type="text" class="mock-input" value="https://sync.koalastuff.net/join.html#join:brave-eagle-80:pass" readonly>
|
||||
<button class="mock-btn" style="padding: 0.35rem 0.5rem;" onclick="navigator.clipboard.writeText('https://sync.koalastuff.net/join.html#join:brave-eagle-80:pass')"><span lang="en">Copy</span><span lang="de">Kopieren</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mock-card" style="margin-bottom: 12px;">
|
||||
<div class="mock-label"><span lang="en">Peers in Room</span><span lang="de">Teilnehmer im Raum</span></div>
|
||||
<div class="mock-peer-list">
|
||||
<div class="mock-peer-item">
|
||||
<div class="mock-peer-header">
|
||||
<span class="mock-peer-name">CoolUsername</span>
|
||||
<span class="mock-peer-meta"><span lang="en">YOU</span><span lang="de">ICH</span></span>
|
||||
</div>
|
||||
<div class="mock-peer-tab">🎬 Germany's Next Flopmodel - S21E12</div>
|
||||
<div class="mock-peer-status"><span style="color:#22c55e;">●</span> 0:55 • <span lang="en">Playing</span><span lang="de">Wiedergabe</span></div>
|
||||
</div>
|
||||
<div class="mock-peer-item">
|
||||
<div class="mock-peer-header">
|
||||
<span class="mock-peer-name">KoalaPC</span>
|
||||
<span class="mock-peer-meta"><span lang="en">Peer</span><span lang="de">Partner</span></span>
|
||||
</div>
|
||||
<div class="mock-peer-tab">🎬 Germany's Next Flopmodel - S21E12</div>
|
||||
<div class="mock-peer-status"><span style="color:#22c55e;">●</span> 0:55 • <span lang="en">Playing</span><span lang="de">Wiedergabe</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="mock-btn" style="background:#ef4444; width:100%; border: none; padding: 10px; border-radius: 8px; color: white; font-weight: 700; cursor: pointer; border: 1px solid #ef4444;"><span lang="en">Leave Room</span><span lang="de">Raum verlassen</span></button>
|
||||
</div>
|
||||
|
||||
<!-- SYNC TAB -->
|
||||
<div id="mock-sync" class="mock-screen">
|
||||
<div class="mock-form-group" style="margin-bottom: 12px;">
|
||||
<label style="display: block; font-size: 11px; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; font-weight: 700;">
|
||||
<span lang="en">Target Tab (Video Source)</span><span lang="de">Ziel-Tab (Video-Quelle)</span>
|
||||
</label>
|
||||
<select class="mock-input" style="width: 100%; box-sizing: border-box; background: var(--card); border: 1px solid #334155; color: white; padding: 8px; border-radius: 8px; font-family: inherit; font-size: 0.75rem; outline: none; cursor: default;">
|
||||
<option>🎬 Germany's Next Flopmodel - S21E12</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label style="display: block; font-size: 11px; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; font-weight: 700;">
|
||||
<span lang="en">Remote Control</span><span lang="de">Fernsteuerung</span>
|
||||
</label>
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
|
||||
<button class="mock-btn mock-btn-play" style="flex: 1; background: #22c55e; color: white; padding: 8px; border-radius: 8px; font-weight: 700; border: none; cursor: pointer;"><span lang="en">▶ Play</span><span lang="de">▶ Start</span></button>
|
||||
<button class="mock-btn mock-btn-pause" style="flex: 1; background: #ef4444; color: white; padding: 8px; border-radius: 8px; font-weight: 700; border: none; cursor: pointer;"><span lang="en">⏸ Pause</span><span lang="de">⏸ Pause</span></button>
|
||||
</div>
|
||||
<button class="mock-btn mock-btn-force" style="background: linear-gradient(135deg, #6366f1, #a855f7); width: 100%; padding: 10px; color: white; border: none; font-weight: 700; border-radius: 8px; margin-bottom: 15px; cursor: pointer;">
|
||||
<span lang="en">⚡ Force Sync Everyone</span><span lang="de">⚡ Synchronisation erzwingen</span>
|
||||
</button>
|
||||
|
||||
<label style="display: block; font-size: 11px; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; font-weight: 700;">
|
||||
<span lang="en">Last Activity Status</span><span lang="de">Letzte Aktivität</span>
|
||||
</label>
|
||||
<div class="mock-card" style="margin-bottom: 15px; min-height: 70px; background: var(--card); border: 1px solid #334155; padding: 10px; border-radius: 8px; display: flex; flex-direction: column; justify-content: center; gap: 4px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-weight: 700; color: #ef4444; font-size: 0.8rem;">PAUSE</span>
|
||||
<span style="font-size: 0.65rem; color: #22c55e; font-weight: 700;">● In Sync</span>
|
||||
</div>
|
||||
<div style="font-size: 0.7rem; color: var(--text-muted); margin-top: 2px;">
|
||||
<span lang="en">Pause triggered by CoolUsername</span><span lang="de">Pause ausgelöst durch CoolUsername</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SETTINGS TAB -->
|
||||
<div id="mock-settings" class="mock-screen">
|
||||
<div class="mock-form-group" style="margin-bottom: 12px;">
|
||||
<label style="display: block; font-size: 11px; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; font-weight: 700;">
|
||||
<span lang="en">Your Username</span><span lang="de">Dein Benutzername</span>
|
||||
</label>
|
||||
<input type="text" class="mock-input" value="CoolUsername" readonly style="width:100%; box-sizing:border-box; background: var(--card); border: 1px solid #334155; color: white; padding: 8px; border-radius: 8px;">
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 8px; border: 1px solid #334155;">
|
||||
<label style="margin: 0; font-size: 11px; text-transform: uppercase; color: var(--text-muted); font-weight: 700; cursor: pointer;" for="chk-noise">
|
||||
<span lang="en">Filter Noise Tabs</span><span lang="de">Noise-Tabs filtern</span>
|
||||
</label>
|
||||
<input type="checkbox" id="chk-noise" checked disabled style="width: auto; cursor: not-allowed;">
|
||||
</div>
|
||||
|
||||
<div 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: 0; font-size: 11px; text-transform: uppercase; color: var(--text-muted); font-weight: 700; cursor: pointer;" for="chk-binge">
|
||||
<span lang="en">Auto-Sync Next Episode</span><span lang="de">Nächste Folge auto-syncen</span>
|
||||
</label>
|
||||
<input type="checkbox" id="chk-binge" checked disabled style="width: auto; cursor: not-allowed;">
|
||||
</div>
|
||||
|
||||
<div style="font-size: 0.65rem; color: var(--text-muted); padding: 4px; line-height: 1.3;">
|
||||
<p style="margin: 2px 0;">• <span lang="en">Username helps others identify you.</span><span lang="de">Benutzername hilft anderen, dich zu erkennen.</span></p>
|
||||
<p style="margin: 2px 0;">• <span lang="en">Noise filtering hides common non-video sites.</span><span lang="de">Noise-Filterung blendet allgemeine Seiten aus.</span></p>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: auto; padding-top: 8px; border-top: 1px solid #334155;">
|
||||
<label style="display: block; font-size: 11px; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; font-weight: 700;">
|
||||
<span lang="en">Troubleshooting</span><span lang="de">Fehlerbehebung</span>
|
||||
</label>
|
||||
<button class="mock-btn" style="background:#334155; width:100%; border: none; padding: 8px; border-radius: 8px; color: white; font-weight: 600; cursor: pointer;">
|
||||
<span lang="en">Regenerate Peer ID</span><span lang="de">Peer ID regenerieren</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DEV TAB -->
|
||||
<div id="mock-dev" class="mock-screen">
|
||||
<label style="display: block; font-size: 11px; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; font-weight: 700;">
|
||||
<span lang="en">Connection Status</span><span lang="de">Verbindungsstatus</span>
|
||||
</label>
|
||||
<div style="display:flex; align-items:center; gap: 10px; background: var(--card); border: 1px solid #334155; padding: 10px; border-radius: 8px; margin-bottom: 12px;">
|
||||
<span class="status-dot status-online" style="width: 8px; height: 8px; border-radius: 50%; display: inline-block; background: #22c55e; box-shadow: 0 0 8px #22c55e;"></span>
|
||||
<span style="flex:1; font-size: 0.75rem; font-weight: 600; color: white;">
|
||||
<span lang="en">Connected</span><span lang="de">Verbunden</span>
|
||||
</span>
|
||||
<button class="mock-btn" style="width: auto; padding: 4px 8px; font-size: 0.65rem; border: none; background: #334155; color: white; border-radius: 6px; cursor: pointer;">
|
||||
<span lang="en">Copy Logs</span><span lang="de">Logs kopieren</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label style="display: block; font-size: 11px; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; font-weight: 700;">
|
||||
<span lang="en">Video Debug Info</span><span lang="de">Video-Debug-Info</span>
|
||||
</label>
|
||||
<div style="background: var(--card); border: 1px solid #334155; padding: 10px; border-radius: 8px; font-size: 0.65rem; font-family: monospace; color: var(--text-muted); line-height: 1.4; margin-bottom: 12px;">
|
||||
<div>readyState: HAVE_ENOUGH_DATA</div>
|
||||
<div>seekDelta: 0.05s</div>
|
||||
<div>buffered: 300.2s</div>
|
||||
<div>engineState: <span style="color:#22c55e; font-weight:700;">SYNCED</span></div>
|
||||
</div>
|
||||
|
||||
<label style="display: block; font-size: 11px; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; font-weight: 700;">
|
||||
<span lang="en">Logs (Last 50)</span><span lang="de">Protokoll (Letzte 50)</span>
|
||||
</label>
|
||||
<div style="max-height: 75px; overflow-y: auto; font-size: 0.6rem; font-family: monospace; background: #000; padding: 8px; border-radius: 8px; color: #ccc; line-height: 1.4;">
|
||||
<div style="color:#22c55e;">[18:37:32] EVENT: PLAY -> ACK</div>
|
||||
<div>[18:37:32] FORCE_SYNC -> 0.05s diff</div>
|
||||
<div>[18:37:38] EVENT: PAUSE -> BROADCAST</div>
|
||||
<div style="color:var(--text-muted);">[18:37:42] content_heartbeat: active</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,29 +244,68 @@
|
||||
|
||||
<section id="features">
|
||||
<div class="container">
|
||||
<h2 style="font-size: 2.5rem; text-align: center; margin-bottom: 1rem;">Why KoalaSync?</h2>
|
||||
<p style="text-align: center; color: var(--text-muted); margin-bottom: 4rem;">Built for performance, privacy, and simplicity.</p>
|
||||
<h2 style="font-size: 2.5rem; text-align: center; margin-bottom: 1rem;">
|
||||
<span lang="en">Why KoalaSync?</span><span lang="de">Warum KoalaSync?</span>
|
||||
</h2>
|
||||
<p style="text-align: center; color: var(--text-muted); margin-bottom: 4rem;">
|
||||
<span lang="en">Built for reliable sync, privacy-first design, and easy setup.</span>
|
||||
<span lang="de">Entwickelt für zuverlässigen Sync, Datenschutz und einfache Einrichtung.</span>
|
||||
</p>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-card" data-reveal>
|
||||
<div class="feature-icon">⚡</div>
|
||||
<h3>Real-time Sync</h3>
|
||||
<p>Proprietary two-phase synchronization protocol ensures sub-millisecond precision across all peers.</p>
|
||||
<h3>
|
||||
<span lang="en">Full Control / Instant Sync</span>
|
||||
<span lang="de">Volle Kontrolle & Echtzeit</span>
|
||||
</h3>
|
||||
<p lang="en">One pauses, everyone pauses. One seeks, everyone follows. Our two-phase sync protocol coordinates playback in real time across all participants.</p>
|
||||
<p lang="de">Einer pausiert, alle pausieren. Einer spult, alle folgen. Unser Zwei-Phasen-Synchronisationsprotokoll koordiniert die Wiedergabe aller Teilnehmer in Echtzeit.</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>
|
||||
<h3>
|
||||
<span lang="en">Endless Binge-Watching</span>
|
||||
<span lang="de">Grenzenloses Bingen</span>
|
||||
</h3>
|
||||
<p lang="en">Autoplay in sync. KoalaSync automatically detects episode transitions and holds playback until all peers have successfully loaded the next video.</p>
|
||||
<p lang="de">Nächste Episode startet für jeden zeitgleich. KoalaSync erkennt den Episodenwechsel und pausiert, bis jeder Teilnehmer das neue Video fertig geladen hat.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-reveal>
|
||||
<div class="feature-icon">🛡️</div>
|
||||
<h3>Privacy First</h3>
|
||||
<p>Zero data persistence. Our relay server runs entirely in RAM and collects no telemetry or logs.</p>
|
||||
<h3>
|
||||
<span lang="en">Zero Accounts / Pure Privacy</span>
|
||||
<span lang="de">Keine Accounts / Datenschutz</span>
|
||||
</h3>
|
||||
<p lang="en">No registration, no tracking, and no database persistence. The server runs entirely in RAM, collects no telemetry or persistent logs, and purges your room when you leave.</p>
|
||||
<p lang="de">Keine Registrierung, kein Tracking und keine Datenspeicherung. Der Server läuft flüchtig im RAM, speichert keine dauerhaften Logs und löscht deinen Raum nach dem Verlassen.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-reveal>
|
||||
<div class="feature-icon">⭐</div>
|
||||
<h3>Smart Match</h3>
|
||||
<p>Find the right tab instantly. KoalaSync highlights and sorts matching video tabs for you.</p>
|
||||
<div class="feature-icon">🌐</div>
|
||||
<h3>
|
||||
<span lang="en">Universal HTML5 Support</span>
|
||||
<span lang="de">Universeller HTML5-Support</span>
|
||||
</h3>
|
||||
<p lang="en">Works on YouTube, Twitch, Netflix, Jellyfin, Emby, and any standard webpage containing a HTML5 video element. Also compatible with custom self-hosted setups.</p>
|
||||
<p lang="de">Unterstützt YouTube, Twitch, Netflix, Jellyfin, Emby und jede beliebige Webseite mit einem HTML5-Video-Tag. Ideal auch für eigene Medienbibliotheken.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-reveal>
|
||||
<div class="feature-icon">🐳</div>
|
||||
<h3>
|
||||
<span lang="en">Self-Hostable & Docker-Ready</span>
|
||||
<span lang="de">Self-Hostable & Docker-Ready</span>
|
||||
</h3>
|
||||
<p lang="en">Take full ownership of your sync relay. We offer ready-to-use Docker images and lightweight Caddy configurations, enabling you to launch your private server in seconds.</p>
|
||||
<p lang="de">Übernimm die volle Kontrolle. Mit unseren fertigen Docker-Images und einfachen Caddy-Konfigurationen startest du deinen eigenen, komplett privaten Relay-Server in wenigen Sekunden.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-reveal>
|
||||
<div class="feature-icon">🔗</div>
|
||||
<h3>
|
||||
<span lang="en">Instant Invites / 1-Click Join</span>
|
||||
<span lang="de">Direkte Einladungen & 1-Klick Beitritt</span>
|
||||
</h3>
|
||||
<p lang="en">No IP addresses or passwords to exchange. Share a generated invite link with your friends to let them join your room automatically with a single click.</p>
|
||||
<p lang="de">Keine lästigen IPs oder Passwörter austauschen. Teile einfach einen Einladungslink mit deinen Freunden, um sie mit einem Klick in den Raum zu holen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,32 +314,165 @@
|
||||
|
||||
<section id="how-it-works" style="background: rgba(255,255,255,0.02)">
|
||||
<div class="container">
|
||||
<h2 style="font-size: 2.5rem; text-align: center; margin-bottom: 4rem;">Getting Started</h2>
|
||||
<h2 style="font-size: 2.5rem; text-align: center; margin-bottom: 4rem;">
|
||||
<span lang="en">Getting Started</span><span lang="de">Loslegen</span>
|
||||
</h2>
|
||||
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="step-text" data-reveal>
|
||||
<div class="step-num">01</div>
|
||||
<h3>Install Extension</h3>
|
||||
<p>Add KoalaSync to your browser from the Chrome Web Store or download the latest developer ZIP from GitHub.</p>
|
||||
<h3><span lang="en">Install Extension</span><span lang="de">Erweiterung installieren</span></h3>
|
||||
<p lang="en">Add KoalaSync to your browser from the Chrome Web Store, Firefox Add-ons, or download the latest developer ZIP from GitHub.</p>
|
||||
<p lang="de">Füge KoalaSync aus dem Chrome Web Store, den Firefox Add-ons oder über die Entwickler-ZIP von GitHub zu deinem Browser hinzu.</p>
|
||||
</div>
|
||||
<!-- Custom Step 1 Illustration -->
|
||||
<div class="step-illustration-1" data-reveal>
|
||||
<div class="illus-browser-bar">
|
||||
<div class="illus-browser-header">
|
||||
<div class="illus-dots">
|
||||
<span class="illus-dot"></span>
|
||||
<span class="illus-dot"></span>
|
||||
<span class="illus-dot"></span>
|
||||
</div>
|
||||
<div class="illus-address-bar"></div>
|
||||
<div class="illus-extension-icon"><img src="assets/logo.png" alt="KoalaSync" style="width: 12px; height: 12px; display: block; object-fit: contain;"></div>
|
||||
</div>
|
||||
<div class="illus-browser-body">
|
||||
<div class="illus-line-placeholder" style="width: 80%;"></div>
|
||||
<div class="illus-line-placeholder" style="width: 50%;"></div>
|
||||
<div class="illus-line-placeholder" style="width: 90%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background: var(--card); height: 200px; border-radius: 20px; border: 1px solid var(--glass-border);"></div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-text" data-reveal>
|
||||
<div class="step-num">02</div>
|
||||
<h3>Create a Room</h3>
|
||||
<p>Open the extension, enter a Room ID and Password, and hit Join. Your secure synchronization space is ready.</p>
|
||||
<h3><span lang="en">Create a Room</span><span lang="de">Raum erstellen</span></h3>
|
||||
<p lang="en">Open the extension, enter a Room ID and Password, and hit Join. Your secure synchronization space is ready.</p>
|
||||
<p lang="de">Öffnen Sie die Erweiterung, geben Sie eine Raum-ID und ein Passwort ein und klicken Sie auf Beitreten. Ihr sicherer Synchronisationsraum ist bereit.</p>
|
||||
</div>
|
||||
<!-- Custom Step 2 Illustration -->
|
||||
<div class="step-illustration-2" data-reveal>
|
||||
<div class="illus-form-box">
|
||||
<div class="illus-form-field">
|
||||
<div class="illus-line-placeholder" style="width: 30%; height: 6px;"></div>
|
||||
<div class="illus-form-input">
|
||||
<div class="illus-typing-text">brave-eagle-80</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="illus-form-btn">
|
||||
<span lang="en">CREATE ROOM</span><span lang="de">RAUM ERSTELLEN</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background: var(--card); height: 200px; border-radius: 20px; border: 1px solid var(--glass-border);"></div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-text" data-reveal>
|
||||
<div class="step-num">03</div>
|
||||
<h3>Share & Sync</h3>
|
||||
<p>Send the invite link to your friends. Once they join, select your video tab and enjoy perfectly synced playback.</p>
|
||||
<h3><span lang="en">Share & Sync</span><span lang="de">Teilen & Synchronisieren</span></h3>
|
||||
<p lang="en">Send the invite link to your friends. Once they join, select your video tab and enjoy synchronized playback.</p>
|
||||
<p lang="de">Senden Sie den Einladungslink an Ihre Freunde. Sobald sie beitreten, wählen Sie Ihren Video-Tab aus und genießen Sie die synchronisierte Wiedergabe.</p>
|
||||
</div>
|
||||
<!-- Custom Step 3 Illustration -->
|
||||
<div class="step-illustration-3" data-reveal>
|
||||
<div class="illus-players-container">
|
||||
<div class="illus-mini-player">
|
||||
<div class="illus-video-screen">
|
||||
<div class="illus-play-icon">▶</div>
|
||||
</div>
|
||||
<div class="illus-player-timeline">
|
||||
<div class="illus-timeline-bar">
|
||||
<div class="illus-timeline-progress"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="illus-sync-connection">
|
||||
<div class="illus-sync-line">
|
||||
<div class="illus-sync-pulse"></div>
|
||||
</div>
|
||||
<span class="illus-sync-label">IN SYNC</span>
|
||||
</div>
|
||||
<div class="illus-mini-player">
|
||||
<div class="illus-video-screen">
|
||||
<div class="illus-play-icon">▶</div>
|
||||
</div>
|
||||
<div class="illus-player-timeline">
|
||||
<div class="illus-timeline-bar">
|
||||
<div class="illus-timeline-progress"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Self Hosters Paradise Section -->
|
||||
<section id="self-hosting" style="background: rgba(15, 23, 42, 0.4); border-top: 1px solid var(--glass-border); border-bottom: 1px solid var(--glass-border);">
|
||||
<div class="container">
|
||||
<h2 style="font-size: 2.5rem; text-align: center; margin-bottom: 1rem;">
|
||||
<span lang="en">For Self-Hosters</span><span lang="de">Für Self-Hoster</span>
|
||||
</h2>
|
||||
<p style="text-align: center; color: var(--text-muted); margin-bottom: 3rem; max-width: 600px; margin-left: auto; margin-right: auto;">
|
||||
<span lang="en">Maintain full data sovereignty. Deploy your own private relay server in minutes.</span>
|
||||
<span lang="de">Behalte die volle Datenhoheit. Richte deinen eigenen privaten Relay-Server in wenigen Minuten ein.</span>
|
||||
</p>
|
||||
|
||||
<div class="terminal-container" data-reveal>
|
||||
<div class="terminal-header">
|
||||
<div class="terminal-dots">
|
||||
<span class="terminal-dot red"></span>
|
||||
<span class="terminal-dot yellow"></span>
|
||||
<span class="terminal-dot green"></span>
|
||||
</div>
|
||||
<div class="terminal-tabs">
|
||||
<button class="terminal-tab-btn active" data-tab="pane-docker">docker-compose.yml</button>
|
||||
<button class="terminal-tab-btn" data-tab="pane-caddy">Caddyfile</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="terminal-body">
|
||||
<button class="terminal-copy-btn">
|
||||
📋 <span lang="en">Copy Code</span><span lang="de">Code kopieren</span>
|
||||
</button>
|
||||
|
||||
<div id="pane-docker" class="terminal-pane active">
|
||||
<pre><code><span class="t-key">services:</span>
|
||||
<span class="t-key">koala-sync:</span>
|
||||
<span class="t-key">image:</span> <span class="t-str">ghcr.io/shik3i/koalasync:latest</span>
|
||||
<span class="t-key">container_name:</span> <span class="t-str">KoalaSync</span>
|
||||
<span class="t-key">restart:</span> <span class="t-str">always</span>
|
||||
<span class="t-comment"># Access internally by proxying to container port 3000</span>
|
||||
<span class="t-key">environment:</span>
|
||||
<span class="t-key">-</span> <span class="t-str">TZ=Europe/Berlin</span>
|
||||
<span class="t-key">-</span> <span class="t-str">PORT=3000</span>
|
||||
<span class="t-key">-</span> <span class="t-str">MIN_VERSION=1.0.0</span>
|
||||
<span class="t-key">-</span> <span class="t-str">MAX_ROOMS=100</span>
|
||||
<span class="t-key">-</span> <span class="t-str">MAX_PEERS_PER_ROOM=50</span>
|
||||
<span class="t-key">pids_limit:</span> <span class="t-val">2048</span></code></pre>
|
||||
<div style="margin-top: 1rem; font-size: 0.75rem; text-align: right; padding-right: 0.5rem;">
|
||||
<a href="https://github.com/Shik3i/KoalaSync/pkgs/container/koalasync" target="_blank" style="color: var(--accent); text-decoration: none; display: inline-flex; align-items: center; gap: 6px; font-weight: 600; transition: opacity 0.2s; opacity: 0.85;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.85'">
|
||||
📦 <span lang="en">View all image tags on GitHub Packages</span><span lang="de">Alle Image-Tags auf GitHub Packages ansehen</span> →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pane-caddy" class="terminal-pane">
|
||||
<pre><code><span class="t-key">sync.koalastuff.net</span> {
|
||||
<span class="t-comment"># Specify the web root inside the container</span>
|
||||
<span class="t-key">root</span> <span class="t-val">*</span> <span class="t-str">/var/www/html</span>
|
||||
|
||||
<span class="t-comment"># Enable static file server to deliver HTML, CSS, JS</span>
|
||||
<span class="t-key">file_server</span>
|
||||
}
|
||||
|
||||
<span class="t-key">syncserver.koalastuff.net</span> {
|
||||
<span class="t-key">reverse_proxy</span> <span class="t-val">KoalaSync:3000</span>
|
||||
}</code></pre>
|
||||
</div>
|
||||
<div style="background: var(--card); height: 200px; border-radius: 20px; border: 1px solid var(--glass-border);"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,19 +480,24 @@
|
||||
|
||||
<section style="text-align: center;">
|
||||
<div class="container">
|
||||
<h2 data-reveal>Ready to sync?</h2>
|
||||
<p data-reveal style="margin-bottom: 2rem; color: var(--text-muted);">Join thousands of users watching together.</p>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" class="btn btn-primary" data-reveal>View on GitHub</a>
|
||||
<h2 data-reveal><span lang="en">Ready to sync?</span><span lang="de">Bereit zum Synchronisieren?</span></h2>
|
||||
<p data-reveal style="margin-bottom: 2rem; color: var(--text-muted);">
|
||||
<span lang="en">Start watching together with friends.</span><span lang="de">Schau gemeinsam mit Freunden.</span>
|
||||
</p>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" class="btn btn-primary" data-reveal>
|
||||
<span lang="en">View on GitHub</span><span lang="de">Auf GitHub ansehen</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. Open source under the MIT License.</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">No data is stored on our servers. Pure RAM-based relay.</p>
|
||||
<p lang="en" style="font-size: 0.8rem; margin-top: 0.5rem;">No data is stored on our servers. Pure RAM-based relay.</p>
|
||||
<p lang="de" style="font-size: 0.8rem; margin-top: 0.5rem;">Keine Daten werden auf unseren Servern gespeichert. Reines RAM-basiertes Relay.</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; gap: 1.5rem;">
|
||||
<a href="impressum.html" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="datenschutz.html" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
|
||||
<a href="impressum.html" style="color: var(--text-muted); text-decoration: none;"><span lang="en">Legal Notice</span><span lang="de">Impressum</span></a>
|
||||
<a href="datenschutz.html" style="color: var(--text-muted); text-decoration: none;"><span lang="en">Privacy Policy</span><span lang="de">Datenschutz</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Beitreten | KoalaSync</title>
|
||||
<title>Join Room | KoalaSync</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="icon" type="image/png" href="assets/logo.png">
|
||||
<meta name="robots" content="noindex">
|
||||
<script src="lang-init.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
@@ -20,19 +22,28 @@
|
||||
<img src="assets/logo.png" alt="KoalaSync Logo">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<button class="hamburger" aria-label="Menu">☰</button>
|
||||
<div class="nav-links">
|
||||
<a href="index.html">Home</a>
|
||||
<a href="https://github.com/shik3i/KoalaSync" target="_blank">GitHub</a>
|
||||
<a href="index.html"><span lang="de">Startseite</span><span lang="en">Home</span></a>
|
||||
<a href="https://github.com/shik3i/KoalaSync" target="_blank" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
<a class="lang-toggle" style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 16px; height: 16px; display: block;"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
|
||||
EN/DE
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="legal-content join-card">
|
||||
<div class="legal-card" id="join-container" data-reveal style="padding: 2.5rem;">
|
||||
<div class="room-badge">INVITATION DETECTED</div>
|
||||
<h1 id="join-title" style="font-size: 2rem; margin-bottom: 1rem;">Ready to sync?</h1>
|
||||
<div class="room-badge"><span lang="en">INVITATION DETECTED</span><span lang="de">EINLADUNG ERKANNT</span></div>
|
||||
<h1 id="join-title" style="font-size: 2rem; margin-bottom: 1rem;"><span lang="en">Ready to sync?</span><span lang="de">Bereit zum Synchronisieren?</span></h1>
|
||||
<p id="join-desc" style="text-align: center; color: var(--text-muted); margin-bottom: 2rem; font-size: 0.9rem;">
|
||||
You've been invited to join a synchronized session.
|
||||
<span lang="en">You've been invited to join a synchronized session.</span>
|
||||
<span lang="de">Du wurdest eingeladen, einer synchronisierten Sitzung beizutreten.</span>
|
||||
</p>
|
||||
|
||||
<div id="room-info-box" style="background: rgba(255,255,255,0.03); padding: 2rem; border-radius: 20px; margin-bottom: 2rem; border: 1px solid var(--glass-border); text-align: center; position: relative; overflow: hidden;">
|
||||
@@ -41,7 +52,7 @@
|
||||
</div>
|
||||
|
||||
<div id="join-actions" style="display: flex; flex-direction: column; gap: 1rem;">
|
||||
<div style="text-align: center; color: var(--text-muted); font-size: 0.8rem;">Detecting extension...</div>
|
||||
<div style="text-align: center; color: var(--text-muted); font-size: 0.8rem;"><span lang="en">Detecting extension...</span><span lang="de">Erweiterung wird erkannt...</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -49,10 +60,11 @@
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. Open source under the MIT License.</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">No data is stored on our servers. Pure RAM-based relay.</p>
|
||||
<p lang="en" style="font-size: 0.8rem; margin-top: 0.5rem;">No data is stored on our servers. Pure RAM-based relay.</p>
|
||||
<p lang="de" style="font-size: 0.8rem; margin-top: 0.5rem;">Keine Daten werden auf unseren Servern gespeichert. Reines RAM-basiertes Relay.</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; gap: 1.5rem;">
|
||||
<a href="impressum.html" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="datenschutz.html" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
|
||||
<a href="impressum.html" style="color: var(--text-muted); text-decoration: none;"><span lang="en">Legal Notice</span><span lang="de">Impressum</span></a>
|
||||
<a href="datenschutz.html" style="color: var(--text-muted); text-decoration: none;"><span lang="en">Privacy Policy</span><span lang="de">Datenschutz</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
(function() {
|
||||
var savedLang = localStorage.getItem('koala_lang');
|
||||
var browserLang = navigator.language.startsWith('de') ? 'de' : 'en';
|
||||
var activeLang = savedLang || browserLang;
|
||||
document.documentElement.classList.add('lang-' + activeLang);
|
||||
document.documentElement.lang = activeLang;
|
||||
|
||||
// Update titles dynamically based on page
|
||||
var path = window.location.pathname;
|
||||
var isIndex = path === '/' || path.endsWith('index.html') || path.split('/').pop() === '';
|
||||
var isJoin = path.endsWith('join.html');
|
||||
|
||||
if (isIndex) {
|
||||
var titles = {
|
||||
en: 'KoalaSync | Real-time Video Synchronization for Friends',
|
||||
de: 'KoalaSync | Echtzeit-Video-Synchronisation für Freunde'
|
||||
};
|
||||
document.title = titles[activeLang] || titles.en;
|
||||
} else if (isJoin) {
|
||||
var titles = {
|
||||
en: 'Join Room | KoalaSync',
|
||||
de: 'Raum beitreten | KoalaSync'
|
||||
};
|
||||
document.title = titles[activeLang] || titles.en;
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://sync.koalastuff.net/sitemap.xml
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/impressum.html</loc>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/datenschutz.html</loc>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
@@ -156,42 +156,45 @@ nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 8rem;
|
||||
text-align: center;
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
gap: 4rem;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero-text h1 {
|
||||
font-size: 5rem;
|
||||
font-size: 4.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 1.5rem;
|
||||
background: linear-gradient(to bottom, #fff 30%, #6366f1);
|
||||
background: linear-gradient(to bottom, #fff 40%, #6366f1);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.hero-text p {
|
||||
font-size: 1.5rem;
|
||||
.hero-text p, .hero-subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 3rem;
|
||||
max-width: 700px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-bottom: 2.5rem;
|
||||
max-width: 600px;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cta-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 1rem 2rem;
|
||||
padding: 0.85rem 1.75rem;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
@@ -199,6 +202,7 @@ nav {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@@ -212,6 +216,18 @@ nav {
|
||||
box-shadow: 0 20px 25px -5px var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn-firefox {
|
||||
background: #e66000;
|
||||
color: white;
|
||||
box-shadow: 0 10px 15px -3px rgba(230, 96, 0, 0.3);
|
||||
}
|
||||
|
||||
.btn-firefox:hover {
|
||||
background: #ff7300;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 20px 25px -5px rgba(230, 96, 0, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
@@ -227,9 +243,9 @@ nav {
|
||||
display: inline-block;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: var(--accent);
|
||||
padding: 0.5rem 1.25rem;
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: 99px;
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
@@ -237,15 +253,339 @@ nav {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* --- Interactive CSS Extension Mockup --- */
|
||||
.hero-mockup-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
.extension-mockup {
|
||||
width: 320px;
|
||||
height: 490px;
|
||||
background: #0f172a;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4), 0 0 20px rgba(99, 102, 241, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mock-header {
|
||||
background: #1e293b;
|
||||
padding: 0.85rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.mock-header img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.mock-header span {
|
||||
font-weight: 800;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mock-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
background: #1e293b;
|
||||
padding: 0.25rem;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.mock-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.4rem 0;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mock-tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mock-tab.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mock-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.95rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
font-size: 0.8rem;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.mock-body::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
}
|
||||
|
||||
.mock-screen {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mock-screen.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Common Mockup Components */
|
||||
.mock-card {
|
||||
background: #1e293b;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.mock-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.mock-input {
|
||||
background: #0f172a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
padding: 0.45rem;
|
||||
font-size: 0.75rem;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mock-btn {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mock-btn:hover {
|
||||
background: #4f46e5;
|
||||
}
|
||||
|
||||
/* Room Tab Details */
|
||||
.mock-joined-room {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
|
||||
.mock-room-name {
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.mock-server-badge {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
color: var(--accent);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mock-invite-box {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.mock-invite-box input {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mock-peer-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mock-peer-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.03);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
|
||||
.mock-peer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.mock-peer-name {
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mock-peer-meta {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mock-peer-tab {
|
||||
font-size: 0.7rem;
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mock-peer-status {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Sync Tab Details */
|
||||
.mock-target-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
border: 1px solid rgba(234, 179, 8, 0.2);
|
||||
color: #facc15;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.mock-ctrl-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mock-btn-play { background: #22c55e; }
|
||||
.mock-btn-play:hover { background: #16a34a; }
|
||||
.mock-btn-pause { background: #ef4444; }
|
||||
.mock-btn-pause:hover { background: #dc2626; }
|
||||
.mock-btn-force { background: var(--accent); grid-column: span 2; }
|
||||
|
||||
.mock-status-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mock-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mock-log-item {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
border-left: 2px solid var(--accent);
|
||||
padding-left: 0.4rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
/* Settings Tab Details */
|
||||
.mock-settings-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.mock-settings-row input[type="checkbox"] {
|
||||
margin-top: 0.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mock-settings-row label {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mock-settings-row p {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
/* Dev Tab Details */
|
||||
.mock-diagnostic-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.mock-diag-box {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.mock-diag-box .diag-val {
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* --- Features --- */
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
@@ -255,17 +595,33 @@ nav {
|
||||
padding: 2.5rem;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--glass-border);
|
||||
transition: transform 0.3s;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.feature-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
border-radius: 24px;
|
||||
padding: 1px;
|
||||
background: linear-gradient(to bottom right, rgba(99, 102, 241, 0.25), transparent);
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-10px);
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 15px 30px rgba(99, 102, 241, 0.15);
|
||||
border-color: rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--accent-glow);
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -273,28 +629,32 @@ nav {
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.5rem;
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* --- How it works --- */
|
||||
.steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rem;
|
||||
gap: 6rem;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4rem;
|
||||
grid-template-columns: 1fr 1.1fr;
|
||||
gap: 6rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -303,11 +663,412 @@ nav {
|
||||
}
|
||||
|
||||
.step-num {
|
||||
font-size: 5rem;
|
||||
font-size: 4rem;
|
||||
font-weight: 800;
|
||||
color: var(--accent-glow);
|
||||
color: rgba(99, 102, 241, 0.15);
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.step-text h3 {
|
||||
font-size: 1.85rem;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.step-text p {
|
||||
color: var(--text-muted);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Custom CSS Mockups for step illustrations */
|
||||
.step-illustration-1, .step-illustration-2, .step-illustration-3 {
|
||||
background: #1e293b;
|
||||
height: 240px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--glass-border);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 15px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Step 1 Illustration: Browser & Extension icon trigger */
|
||||
.illus-browser-bar {
|
||||
width: 85%;
|
||||
background: #0f172a;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.illus-browser-header {
|
||||
background: #1e293b;
|
||||
padding: 0.5rem 0.75rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.illus-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.illus-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.illus-address-bar {
|
||||
background: #0f172a;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
padding: 2px 10px;
|
||||
border-radius: 4px;
|
||||
width: 60%;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.illus-extension-icon {
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: pulse-border 2s infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes pulse-border {
|
||||
0% { box-shadow: 0 0 0 rgba(99, 102, 241, 0); border-color: rgba(99, 102, 241, 0.4); }
|
||||
100% { box-shadow: 0 0 8px rgba(99, 102, 241, 0.6); border-color: var(--accent); }
|
||||
}
|
||||
|
||||
.illus-browser-body {
|
||||
padding: 1.5rem;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.illus-line-placeholder {
|
||||
height: 8px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Step 2 Illustration: Typing in Form Fields */
|
||||
.illus-form-box {
|
||||
width: 70%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.illus-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.illus-form-input {
|
||||
background: #0f172a;
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
padding: 0.45rem;
|
||||
border-radius: 6px;
|
||||
height: 28px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.illus-typing-text {
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.illus-typing-text::after {
|
||||
content: '|';
|
||||
animation: blink-cursor 0.8s infinite;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@keyframes blink-cursor {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.illus-form-btn {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 10px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
/* Step 3 Illustration: Multi-player Sync simulation */
|
||||
.illus-players-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.illus-mini-player {
|
||||
flex: 1;
|
||||
background: #0f172a;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
border-radius: 10px;
|
||||
height: 120px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.illus-video-screen {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(circle, rgba(99,102,241,0.15) 0%, transparent 80%);
|
||||
}
|
||||
|
||||
.illus-play-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: rgba(99, 102, 241, 0.9);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
box-shadow: 0 0 12px rgba(99, 102, 241, 0.5);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.illus-player-timeline {
|
||||
height: 20px;
|
||||
background: #1e293b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.5rem;
|
||||
border-top: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.illus-timeline-bar {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
position: relative;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.illus-timeline-progress {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 60%;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
animation: timeline-grow 4s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes timeline-grow {
|
||||
0% { width: 0%; }
|
||||
100% { width: 100%; }
|
||||
}
|
||||
|
||||
.illus-sync-connection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
color: var(--accent);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.illus-sync-line {
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background: linear-gradient(to right, var(--accent), var(--success));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.illus-sync-pulse {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 8px #fff;
|
||||
animation: slide-pulse 2s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes slide-pulse {
|
||||
0% { left: 0%; }
|
||||
100% { left: 100%; }
|
||||
}
|
||||
|
||||
.illus-sync-label {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(34, 197, 94, 0.25);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- Self Hosters Terminal --- */
|
||||
.terminal-container {
|
||||
max-width: 850px;
|
||||
margin: 3rem auto 0 auto;
|
||||
background: #0f172a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 45px rgba(0,0,0,0.5);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
background: #1e293b;
|
||||
padding: 0.85rem 1.25rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
.terminal-dots {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.terminal-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.terminal-dot.red { background: #ef4444; }
|
||||
.terminal-dot.yellow { background: #f59e0b; }
|
||||
.terminal-dot.green { background: #10b981; }
|
||||
|
||||
.terminal-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.terminal-tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.terminal-tab-btn:hover {
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.terminal-tab-btn.active {
|
||||
color: var(--accent);
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.terminal-body {
|
||||
position: relative;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.terminal-pane {
|
||||
display: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.terminal-pane.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.terminal-pane pre {
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.terminal-pane code {
|
||||
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Syntax Highlighting */
|
||||
.t-comment { color: #64748b; font-style: italic; }
|
||||
.t-key { color: #f472b6; }
|
||||
.t-val { color: #38bdf8; }
|
||||
.t-str { color: #34d399; }
|
||||
.t-num { color: #fbbf24; }
|
||||
|
||||
.terminal-copy-btn {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-muted);
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.terminal-copy-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.terminal-copy-btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* --- Footer --- */
|
||||
@@ -330,6 +1091,7 @@ footer {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
|
||||
/* --- Legal Pages --- */
|
||||
.legal-content {
|
||||
max-width: 800px;
|
||||
@@ -398,11 +1160,60 @@ footer {
|
||||
}
|
||||
.cta-group {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.nav-links {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
flex-direction: column;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 1rem 2rem;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
.nav-links.open {
|
||||
display: flex;
|
||||
}
|
||||
.hamburger {
|
||||
display: flex;
|
||||
}
|
||||
.legal-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Language Toggle --- */
|
||||
html.lang-en [lang="de"],
|
||||
html.lang-de [lang="en"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.lang-toggle {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.lang-toggle:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* --- Hamburger Menu --- */
|
||||
.hamburger {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": "1.7.3",
|
||||
"date": "2026-05-25T10:52:55Z"
|
||||
}
|
||||