Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98b4fc5fb4 | |||
| 762d6425be | |||
| 1fba2fb69c | |||
| eca259281a | |||
| afd28be2e6 | |||
| cc0265c836 | |||
| 6ebff9ab4c | |||
| 35e779c1ff | |||
| 09f0e04891 | |||
| 9eff53ba46 | |||
| bde2f7ea55 | |||
| 901861269a | |||
| ba91e2744c | |||
| b7a44024ab | |||
| 3c49bfe54c | |||
| d2ea7c7423 | |||
| db11812bd6 | |||
| 6db8fdbf75 | |||
| d23c37f87f | |||
| acd428d4f7 | |||
| c0a6f0adc2 | |||
| 42b73bb97f | |||
| dc36bfdded | |||
| 06db850387 | |||
| 0de92b5b61 | |||
| 8ff8e7beb6 | |||
| 8317099072 | |||
| 04c63dcf68 | |||
| fd23ccc23a | |||
| 4d5caeda9e | |||
| f1f41e5cac | |||
| 473eacda22 | |||
| 42029f86bf | |||
| 4c4a2638d7 | |||
| 5b57970c4c | |||
| 61492f953b | |||
| da6a1cc643 | |||
| 62fdffa5ee | |||
| fb13978c9d | |||
| 82c09a5328 | |||
| 2fbeafeb3f | |||
| 80f8c821cb | |||
| 7d3965a9fd | |||
| 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 |
@@ -59,6 +59,7 @@ jobs:
|
||||
- name: Inject version into source files
|
||||
run: |
|
||||
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
|
||||
@@ -73,8 +74,22 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -68,7 +68,7 @@ The following features are critical and must not be removed or fundamentally alt
|
||||
- **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.
|
||||
@@ -94,14 +94,30 @@ The following features are critical and must not be removed or fundamentally alt
|
||||
|
||||
## 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.
|
||||
1. Commit all code changes and push to `main`.
|
||||
2. 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.
|
||||
3. 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.
|
||||
4. Verify the release builds on GitHub Actions.
|
||||
> - **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.
|
||||
- **🚫 WHEN NOT TO TAG**: Do NOT create a release tag for changes that do NOT affect the shipped extension or server artifacts. Website text changes, documentation updates (`.md` files), and landing page content do NOT require a version tag. Tags trigger the full CI pipeline (Docker build, extension packaging, GitHub Release) — running this for a typo fix wastes CI resources and creates meaningless releases. Only tag when extension code (`extension/`), server code (`server/`), or shared protocol constants (`shared/`) have changed.
|
||||
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`.
|
||||
|
||||
@@ -7,8 +7,17 @@ sync.koalastuff.net {
|
||||
file_server
|
||||
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
|
||||
|
||||
@@ -15,8 +15,7 @@ KoalaSync does not use a database. All active session data exists only in the se
|
||||
|:----------|:------------------|:---------------------|
|
||||
| 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 |
|
||||
| Failed auth lockout records | 15 minutes | Automatic expiry |
|
||||
| Auth failure records | 1 hour | Periodic cleanup |
|
||||
| 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 |
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
# KoalaSync
|
||||
<h1 align="center"><img src="extension/icons/icon128.png" width="32" valign="middle"> KoalaSync</h1>
|
||||
|
||||
<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>
|
||||
<img src="https://img.shields.io/badge/Browser-Chrome%20|%20Firefox-blueviolet" alt="Cross Browser">
|
||||
<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>
|
||||
|
||||
KoalaSync is a premium, 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 **Data Sovereignty** and **Extreme Performance**.
|
||||
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
|
||||
|
||||
### 🌟 Why KoalaSync?
|
||||
|
||||
* **🛡️ Security-First**: Volatile RAM-based relay with built-in brute-force protection and zero-persistence architecture.
|
||||
* **📡 Direct Logic**: Custom wire protocol implementation for frame-perfect synchronization.
|
||||
* **📡 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.
|
||||
|
||||
@@ -24,12 +25,33 @@ KoalaSync is a premium, lightweight Browser Extension and Relay Server for synch
|
||||
- **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 wire protocol implementation for maximum performance.
|
||||
- **Efficient Relay**: Minimal overhead WebSocket message forwarding.
|
||||
- **Seamless Invitations**: Smart links that automatically configure server and room credentials for your friends.
|
||||
|
||||
---
|
||||
|
||||
### 📂 Repository Structure
|
||||
### 🚀 Quick Start
|
||||
|
||||
#### For Users (Installation & Usage)
|
||||
The easiest and safest way to install KoalaSync is directly through the official browser stores:
|
||||
|
||||
<p>
|
||||
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white&style=for-the-badge" alt="Chrome Extension"></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&style=for-the-badge" alt="Firefox Add-on"></a>
|
||||
</p>
|
||||
|
||||
*(For manual offline installation: Download the latest `.zip` from the [Releases](https://github.com/Shik3i/KoalaSync/releases) page and load it as an "Unpacked Extension" in Developer Mode).*
|
||||
|
||||
**How to use:**
|
||||
1. **Create a Room:** Click the Koala icon in your browser and hit `+ Create New Room`.
|
||||
2. **Invite Friends:** Share the auto-copied invite link. Once they click it, they automatically join.
|
||||
3. **Pick a Video:** Navigate to the Sync Tab, select the tab playing your video, and grab some popcorn! 🍿
|
||||
|
||||
---
|
||||
|
||||
### 🛠️ For Developers & Self-Hosters
|
||||
|
||||
#### 📂 Repository Structure
|
||||
|
||||
- `extension/`: Browser Extension (Chrome & Firefox).
|
||||
- `server/`: Node.js + Socket.IO Relay Server (Containerized).
|
||||
@@ -38,16 +60,7 @@ KoalaSync is a premium, lightweight Browser Extension and Relay Server for synch
|
||||
- `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)
|
||||
#### Building from Source
|
||||
To build the extension from source and synchronize protocol constants:
|
||||
```bash
|
||||
npm install
|
||||
|
||||
@@ -11,18 +11,19 @@ This document describes the communication flows and internal logic of the KoalaS
|
||||
- **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**: 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.
|
||||
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.
|
||||
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 and call `video.play()`, `video.pause()`, or `video.currentTime = targetTime`.
|
||||
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 frame-perfect and buffered before resuming:
|
||||
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`.
|
||||
@@ -44,6 +45,7 @@ To maintain a clean room state and eliminate "Ghost Peers":
|
||||
- **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.
|
||||
@@ -54,8 +56,13 @@ KoalaSync uses a megaphone routing approach to minimize server logic:
|
||||
- **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` 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.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ This guide walks through the complete user flow of KoalaSync, from creating a ro
|
||||
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), `serverUrl`, `filterNoise` preference. All stored via `chrome.storage.sync` and `chrome.storage.local`.
|
||||
> **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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -109,7 +109,7 @@ Both users now need to select which browser tab contains the video to sync:
|
||||
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`, `timeupdate`, and `volumechange`. It uses an `expectedEvents` Set to distinguish between user actions and programmatic actions (loop prevention).
|
||||
> **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).
|
||||
|
||||
---
|
||||
|
||||
@@ -143,7 +143,7 @@ If videos drift out of sync, either user can click **"Force Sync"**:
|
||||
|
||||
### 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 frame-perfect sync.
|
||||
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.
|
||||
|
||||
@@ -155,7 +155,7 @@ While in a room, two heartbeats keep the session alive:
|
||||
|
||||
| Heartbeat | Interval | Source | Purpose |
|
||||
|:----------|:---------|:-------|:--------|
|
||||
| **Background** | 30 seconds | `background.js` | Signals "I'm still connected" even without a video |
|
||||
| **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").
|
||||
|
||||
@@ -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,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -20,7 +20,7 @@ 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 maximum performance and privacy.
|
||||
- **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. **Prepare Extension**: From the repository root, run:
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
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';
|
||||
import { generateUsername } from './shared/names.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 +16,12 @@ 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
|
||||
let localSeq = 0; // Monotonically increasing command sequence for this peer
|
||||
const lastSeqBySender = {}; // senderId → last received seq (stale command guard)
|
||||
|
||||
function _persistLastSeq() {
|
||||
if (storageInitialized) chrome.storage.session.set({ lastSeqBySender });
|
||||
}
|
||||
|
||||
// --- Boot Sequence Lock ---
|
||||
let restorationTask = null;
|
||||
@@ -26,12 +29,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'
|
||||
'episodeLobby', 'localSeq', 'lastSeqBySender'
|
||||
], (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
|
||||
@@ -78,6 +91,9 @@ function ensureState() {
|
||||
}
|
||||
}
|
||||
|
||||
if (data.localSeq !== undefined && !isNaN(data.localSeq)) localSeq = data.localSeq;
|
||||
if (data.lastSeqBySender && typeof data.lastSeqBySender === 'object') Object.assign(lastSeqBySender, data.lastSeqBySender);
|
||||
|
||||
storageInitialized = true;
|
||||
|
||||
// Process any early logs/history that weren't captured in the spread
|
||||
@@ -92,7 +108,7 @@ function ensureState() {
|
||||
pendingHistory = [];
|
||||
}
|
||||
|
||||
resolve();
|
||||
done();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -103,8 +119,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;
|
||||
@@ -115,6 +135,26 @@ let forceSyncTimeout = null;
|
||||
let episodeLobby = null; // { expectedTitle, initiatorPeerId, readyPeers: [], createdAt }
|
||||
let episodeLobbyTimeout = null;
|
||||
|
||||
// --- Episode Title Extraction (synced with content.js) ---
|
||||
function extractEpisodeId(title) {
|
||||
if (!title || typeof title !== 'string') return null;
|
||||
const se = title.match(/S(?:eason\s*)?(\d+)[\s\-\.]*E(?:pisode\s*)?(\d+)/i);
|
||||
if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`;
|
||||
const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i);
|
||||
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function sameEpisode(titleA, titleB) {
|
||||
if (!titleA && !titleB) return true; // Both unknown → assume same (backward compat)
|
||||
if (!titleA || !titleB) return false; // One unknown, one known → different
|
||||
const idA = extractEpisodeId(titleA);
|
||||
const idB = extractEpisodeId(titleB);
|
||||
if (idA && idB) return idA === idB; // Both have parseable IDs → compare IDs
|
||||
if (idA || idB) return false; // One has ID, other doesn't → different
|
||||
return titleA === titleB; // Neither has ID → exact string match
|
||||
}
|
||||
|
||||
// --- Storage Utils ---
|
||||
|
||||
/**
|
||||
@@ -137,6 +177,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 +208,33 @@ 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) {
|
||||
username = generateUsername();
|
||||
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 +296,7 @@ async function connect() {
|
||||
return;
|
||||
}
|
||||
|
||||
broadcastConnectionStatus('connecting');
|
||||
broadcastConnectionStatus('reconnecting');
|
||||
const isCustomServer = settings.serverUrl && settings.useCustomServer;
|
||||
finalUrl = isCustomServer ? settings.serverUrl : OFFICIAL_SERVER_URL;
|
||||
|
||||
@@ -239,7 +319,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 +337,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 +380,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 +395,6 @@ async function connect() {
|
||||
isConnecting = false;
|
||||
isNamespaceJoined = false;
|
||||
|
||||
// Clear Force Sync state
|
||||
isForceSyncInitiator = false;
|
||||
forceSyncAcks.clear();
|
||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||
@@ -319,20 +403,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 +437,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 +458,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 +560,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
|
||||
@@ -484,14 +604,37 @@ function handleServerEvent(event, data) {
|
||||
case EVENTS.PAUSE:
|
||||
case EVENTS.SEEK:
|
||||
case EVENTS.FORCE_SYNC_PREPARE:
|
||||
if (data.senderId && typeof data.seq === 'number') {
|
||||
const lastSeq = lastSeqBySender[data.senderId];
|
||||
if (lastSeq !== undefined && data.seq <= lastSeq) {
|
||||
addLog(`Ignored stale ${event} from ${data.senderId} (seq ${data.seq} <= ${lastSeq})`, 'warn');
|
||||
break;
|
||||
}
|
||||
lastSeqBySender[data.senderId] = data.seq;
|
||||
_persistLastSeq();
|
||||
}
|
||||
if (data.senderId) {
|
||||
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;
|
||||
case EVENTS.FORCE_SYNC_ACK:
|
||||
if (data.senderId && typeof data.seq === 'number') {
|
||||
const lastSeq = lastSeqBySender[data.senderId];
|
||||
if (lastSeq !== undefined && data.seq <= lastSeq) break;
|
||||
lastSeqBySender[data.senderId] = data.seq;
|
||||
_persistLastSeq();
|
||||
}
|
||||
if (isForceSyncInitiator) {
|
||||
forceSyncAcks.add(data.senderId);
|
||||
chrome.storage.session.set({ forceSyncAcks: Array.from(forceSyncAcks) });
|
||||
@@ -505,24 +648,52 @@ 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 && typeof data.seq === 'number') {
|
||||
const lastSeq = lastSeqBySender[data.senderId];
|
||||
if (lastSeq !== undefined && data.seq <= lastSeq) break;
|
||||
lastSeqBySender[data.senderId] = data.seq;
|
||||
_persistLastSeq();
|
||||
}
|
||||
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 +701,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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -552,30 +729,36 @@ function handleServerEvent(event, data) {
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
|
||||
// Episode Lobby: Handle peer departure
|
||||
if (episodeLobby) {
|
||||
checkEpisodeLobbyPeerDeparture();
|
||||
}
|
||||
|
||||
if (isForceSyncInitiator) {
|
||||
const peerCount = currentRoom.peers ? currentRoom.peers.length : 1;
|
||||
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 < 300;
|
||||
|
||||
if (!ignoreStatus) {
|
||||
peer.playbackState = data.playbackState !== undefined ? data.playbackState : peer.playbackState;
|
||||
peer.currentTime = data.currentTime !== undefined ? data.currentTime : peer.currentTime;
|
||||
if (data.playbackState !== undefined || data.currentTime !== undefined) {
|
||||
peer.lastHeartbeat = Date.now();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Migration: replace string peer with normalized object
|
||||
const idx = currentRoom.peers.indexOf(peer);
|
||||
@@ -591,7 +774,7 @@ function handleServerEvent(event, data) {
|
||||
if (data.senderId && data.expectedTitle) {
|
||||
addLog(`Episode lobby from ${data.senderId}: "${data.expectedTitle}"`, 'info');
|
||||
// If we already have a lobby for this same title, treat as dedup
|
||||
if (episodeLobby && episodeLobby.expectedTitle === data.expectedTitle) {
|
||||
if (episodeLobby && sameEpisode(episodeLobby.expectedTitle, data.expectedTitle)) {
|
||||
break; // Already tracking this lobby
|
||||
}
|
||||
// Cancel any existing lobby before starting a new one
|
||||
@@ -600,7 +783,7 @@ function handleServerEvent(event, data) {
|
||||
episodeLobby = {
|
||||
expectedTitle: data.expectedTitle,
|
||||
initiatorPeerId: data.senderId,
|
||||
readyPeers: [],
|
||||
readyPeers: [data.senderId], // Initiator is already ready
|
||||
createdAt: Date.now()
|
||||
};
|
||||
persistEpisodeLobby();
|
||||
@@ -647,8 +830,27 @@ 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);
|
||||
|
||||
localSeq++;
|
||||
chrome.storage.session.set({ localSeq });
|
||||
|
||||
emit(EVENTS.FORCE_SYNC_EXECUTE, { actionTimestamp: executionTimestamp, seq: localSeq });
|
||||
routeToContent(EVENTS.FORCE_SYNC_EXECUTE, { actionTimestamp: executionTimestamp, seq: localSeq });
|
||||
addLog('Force Sync Executed', 'success');
|
||||
}
|
||||
|
||||
@@ -699,10 +901,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() + 8500;
|
||||
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: [],
|
||||
@@ -710,15 +915,17 @@ function executeEpisodeLobby() {
|
||||
});
|
||||
|
||||
const syncPayload = { targetTime: 0.0 };
|
||||
emit(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, peerId });
|
||||
routeToContent(EVENTS.FORCE_SYNC_PREPARE, syncPayload);
|
||||
localSeq++;
|
||||
chrome.storage.session.set({ localSeq });
|
||||
emit(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, peerId, actionTimestamp: timestamp, seq: localSeq });
|
||||
routeToContent(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, actionTimestamp: timestamp, seq: localSeq });
|
||||
|
||||
forceSyncTimeout = setTimeout(() => {
|
||||
if (isForceSyncInitiator) {
|
||||
addLog('Force Sync (Episode): Timeout waiting for ACKs, executing anyway...', 'warn');
|
||||
executeForceSync();
|
||||
}
|
||||
}, 8500);
|
||||
}, FORCE_SYNC_TIMEOUT);
|
||||
}
|
||||
|
||||
function checkEpisodeLobbyCompletion() {
|
||||
@@ -731,6 +938,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
|
||||
@@ -765,23 +973,33 @@ 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;
|
||||
|
||||
_routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, 0);
|
||||
}
|
||||
|
||||
function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries) {
|
||||
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 (retries >= 3) {
|
||||
addLog(`Content Script not responding in tab ${tabId} after ${retries} retries`, 'warn');
|
||||
currentTabId = null;
|
||||
updateBadgeStatus();
|
||||
return;
|
||||
}
|
||||
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 => {
|
||||
setTimeout(() => _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries + 1), 500);
|
||||
}).catch(_err => {
|
||||
addLog(`Auto-reinject failed for tab ${tabId}`, 'warn');
|
||||
});
|
||||
} else {
|
||||
@@ -793,13 +1011,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();
|
||||
@@ -813,7 +1033,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) => {
|
||||
@@ -827,9 +1070,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,
|
||||
@@ -847,12 +1094,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,
|
||||
@@ -860,7 +1113,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 });
|
||||
@@ -894,9 +1148,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;
|
||||
@@ -907,6 +1159,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();
|
||||
@@ -948,13 +1201,24 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
} else if (message.type === 'CONTENT_EVENT') {
|
||||
const processEvent = () => {
|
||||
const timestamp = Date.now();
|
||||
localSeq++;
|
||||
chrome.storage.session.set({ localSeq });
|
||||
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;
|
||||
message.payload.seq = localSeq;
|
||||
|
||||
// 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() + 8500;
|
||||
const deadline = Date.now() + FORCE_SYNC_TIMEOUT;
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: true,
|
||||
forceSyncAcks: [],
|
||||
@@ -964,14 +1228,25 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
|
||||
routeToContent(EVENTS.FORCE_SYNC_PREPARE, message.payload);
|
||||
|
||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||
forceSyncTimeout = setTimeout(() => {
|
||||
if (isForceSyncInitiator) {
|
||||
addLog('Force Sync: Timeout waiting for ACKs, executing anyway...', 'warn');
|
||||
executeForceSync();
|
||||
}
|
||||
}, 8500);
|
||||
}, FORCE_SYNC_TIMEOUT);
|
||||
}
|
||||
addToHistory(message.action, 'You');
|
||||
|
||||
const isNonEssentialEvent = message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK;
|
||||
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
|
||||
const hasOtherPeers = otherCount > 0;
|
||||
|
||||
if (isNonEssentialEvent && !hasOtherPeers) {
|
||||
sendResponse({ status: 'ok_solo' });
|
||||
return;
|
||||
}
|
||||
|
||||
emit(message.action, { ...message.payload, peerId });
|
||||
sendResponse({ status: 'ok' });
|
||||
};
|
||||
@@ -997,20 +1272,31 @@ 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();
|
||||
}
|
||||
} else {
|
||||
emit(EVENTS.FORCE_SYNC_ACK, { peerId });
|
||||
localSeq++;
|
||||
chrome.storage.session.set({ localSeq });
|
||||
emit(EVENTS.FORCE_SYNC_ACK, { peerId, seq: localSeq });
|
||||
}
|
||||
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
|
||||
});
|
||||
}
|
||||
@@ -1033,21 +1319,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;
|
||||
@@ -1086,14 +1376,14 @@ 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;
|
||||
}
|
||||
|
||||
// If lobby already exists for this title, just mark self ready
|
||||
if (episodeLobby && episodeLobby.expectedTitle === newTitle) {
|
||||
if (episodeLobby && sameEpisode(episodeLobby.expectedTitle, newTitle)) {
|
||||
if (!episodeLobby.readyPeers.includes(peerId)) {
|
||||
episodeLobby.readyPeers.push(peerId);
|
||||
persistEpisodeLobby();
|
||||
@@ -1140,7 +1430,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
sendResponse({ status: 'lobby_created' });
|
||||
} else if (message.type === 'EPISODE_READY_LOCAL') {
|
||||
// Content script confirmed it loaded the lobby episode
|
||||
if (episodeLobby && message.payload && message.payload.title === episodeLobby.expectedTitle) {
|
||||
if (episodeLobby && message.payload && sameEpisode(message.payload.title, episodeLobby.expectedTitle)) {
|
||||
if (!episodeLobby.readyPeers.includes(peerId)) {
|
||||
episodeLobby.readyPeers.push(peerId);
|
||||
persistEpisodeLobby();
|
||||
@@ -1167,16 +1457,46 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
// Tab removal listener
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
if (tabId === currentTabId) {
|
||||
const wasInRoom = !!currentRoom;
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
chrome.storage.session.set({ currentTabId: null, currentTabTitle: null });
|
||||
updateBadgeStatus();
|
||||
addLog('Target tab closed.', 'warn');
|
||||
|
||||
if (wasInRoom) {
|
||||
const roomAtClose = currentRoom;
|
||||
getSettings().then(settings => {
|
||||
if (currentRoom !== roomAtClose) return;
|
||||
|
||||
emit(EVENTS.PEER_STATUS, {
|
||||
peerId,
|
||||
playbackState: 'paused',
|
||||
currentTime: null,
|
||||
mediaTitle: null,
|
||||
username: settings.username,
|
||||
tabTitle: null
|
||||
});
|
||||
|
||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||
const me = currentRoom.peers.find(p => (p.peerId || p) === peerId);
|
||||
if (me && typeof me === 'object') {
|
||||
me.playbackState = 'paused';
|
||||
me.currentTime = null;
|
||||
me.mediaTitle = null;
|
||||
me.tabTitle = null;
|
||||
me.lastHeartbeat = Date.now();
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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 },
|
||||
|
||||
@@ -22,12 +22,15 @@ window.addEventListener('KOALASYNC_JOIN_REQUEST', (e) => {
|
||||
// 3. Listen for Status Updates from the Extension and relay to Website
|
||||
chrome.runtime.onMessage.addListener((msg) => {
|
||||
if (msg.type === 'JOIN_STATUS') {
|
||||
const event = new CustomEvent('KOALASYNC_STATUS', {
|
||||
detail: {
|
||||
success: msg.success,
|
||||
message: msg.message
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
const detail = { success: msg.success, message: msg.message };
|
||||
// Firefox MV3 content scripts run in an isolated world. When dispatching
|
||||
// a CustomEvent with a detail object, Firefox wraps it in an XrayWrapper
|
||||
// that the page's JavaScript cannot destructure (Permission denied).
|
||||
// cloneInto() exposes the object to the page's context correctly.
|
||||
// Chrome doesn't have this issue — cloneInto() is undefined there.
|
||||
const safeDetail = typeof cloneInto === 'function'
|
||||
? cloneInto(detail, document.defaultView)
|
||||
: detail;
|
||||
window.dispatchEvent(new CustomEvent('KOALASYNC_STATUS', { detail: safeDetail }));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
if (window.koalaSyncInjected && chrome.runtime.id) {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// Context invalidated, proceed with re-injection
|
||||
}
|
||||
window.koalaSyncInjected = true;
|
||||
@@ -29,8 +29,25 @@
|
||||
};
|
||||
// --- SHARED_EVENTS_INJECT_END ---
|
||||
|
||||
let expectedEvents = new Set();
|
||||
let expectedTimeouts = {};
|
||||
// Suppresses native event reporting after a programmatic action.
|
||||
// Each entry is a per-type timer (key = 'playing'|'paused'|'seek').
|
||||
// While a timer exists, matching native events are consumed and not relayed.
|
||||
// Timers self-clean after 300ms if the native event never fires.
|
||||
let _suppressTimers = {};
|
||||
|
||||
function _setSuppress(state) {
|
||||
if (_suppressTimers[state]) clearTimeout(_suppressTimers[state]);
|
||||
_suppressTimers[state] = setTimeout(() => {
|
||||
delete _suppressTimers[state];
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function _clearSuppress(state) {
|
||||
if (_suppressTimers[state]) {
|
||||
clearTimeout(_suppressTimers[state]);
|
||||
delete _suppressTimers[state];
|
||||
}
|
||||
}
|
||||
|
||||
// --- Seek Relay Filtering ---
|
||||
// Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks
|
||||
@@ -42,26 +59,35 @@
|
||||
// --- 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;
|
||||
let _autoSyncEnabled = true; // Cached setting, updated via storage.onChanged
|
||||
|
||||
function expectEvent(state) {
|
||||
expectedEvents.add(state);
|
||||
if (expectedTimeouts[state]) clearTimeout(expectedTimeouts[state]);
|
||||
const timeout = state === 'seek' ? 10000 : 1500;
|
||||
expectedTimeouts[state] = setTimeout(() => {
|
||||
expectedEvents.delete(state);
|
||||
}, timeout);
|
||||
}
|
||||
// Cache the autoSyncNextEpisode setting
|
||||
chrome.storage.sync.get(['autoSyncNextEpisode'], (data) => {
|
||||
_autoSyncEnabled = data.autoSyncNextEpisode !== false; // default: enabled
|
||||
});
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area === 'sync' && changes.autoSyncNextEpisode) {
|
||||
_autoSyncEnabled = changes.autoSyncNextEpisode.newValue !== false;
|
||||
}
|
||||
});
|
||||
|
||||
function reportLog(message, level = 'info') {
|
||||
chrome.runtime.sendMessage({ type: 'LOG', message, level }).catch(() => {});
|
||||
}
|
||||
|
||||
// --- 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 ---
|
||||
@@ -71,6 +97,43 @@
|
||||
: null;
|
||||
}
|
||||
|
||||
// Extract a canonical episode identifier from a title string.
|
||||
// Handles: S01E01, S1E1, S01 - E01, Season 1 Episode 1, "Folge 5", "Episode 5", "Ep. 5", "#5"
|
||||
// Returns null if no episode pattern found.
|
||||
function extractEpisodeId(title) {
|
||||
if (!title || typeof title !== 'string') return null;
|
||||
// S01E01 patterns (with optional spaces, dashes, dots between S and E)
|
||||
const se = title.match(/S(?:eason\s*)?(\d+)[\s\-\.]*E(?:pisode\s*)?(\d+)/i);
|
||||
if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`;
|
||||
// "Episode X", "Folge X", "Ep. X", "#X"
|
||||
const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i);
|
||||
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Returns true if two titles likely refer to the same episode.
|
||||
// Strict: both must have IDs and match, OR neither has IDs and exact match.
|
||||
function sameEpisode(titleA, titleB) {
|
||||
if (!titleA && !titleB) return true; // Both unknown → assume same (backward compat)
|
||||
if (!titleA || !titleB) return false; // One unknown, one known → different
|
||||
const idA = extractEpisodeId(titleA);
|
||||
const idB = extractEpisodeId(titleB);
|
||||
if (idA && idB) return idA === idB; // Both have parseable IDs → compare IDs
|
||||
if (idA || idB) return false; // One has ID, other doesn't → different
|
||||
return titleA === titleB; // Neither has ID → exact string match
|
||||
}
|
||||
|
||||
// Returns true only when we are CERTAIN the episodes differ.
|
||||
// Permissive: only blocks if BOTH titles have parseable IDs AND they differ.
|
||||
// Films, music, unparseable titles always pass through.
|
||||
function isDifferentEpisode(titleA, titleB) {
|
||||
if (!titleA || !titleB) return false; // Unknown → allow
|
||||
const idA = extractEpisodeId(titleA);
|
||||
const idB = extractEpisodeId(titleB);
|
||||
if (!idA || !idB) return false; // At least one unparseable → allow
|
||||
return idA !== idB; // Both parseable → only block if different
|
||||
}
|
||||
|
||||
function checkEpisodeTransition() {
|
||||
const currentTitle = getMediaTitle();
|
||||
const video = findVideo();
|
||||
@@ -78,7 +141,7 @@
|
||||
// Only trigger if: we had a previous title, the title changed,
|
||||
// a video exists, and we're near the start of new content.
|
||||
if (lastKnownMediaTitle && currentTitle
|
||||
&& currentTitle !== lastKnownMediaTitle
|
||||
&& !sameEpisode(currentTitle, lastKnownMediaTitle)
|
||||
&& video
|
||||
&& video.currentTime < 5
|
||||
&& video.readyState >= 1) {
|
||||
@@ -111,11 +174,11 @@
|
||||
const video = findVideo();
|
||||
const currentTitle = getMediaTitle();
|
||||
|
||||
if (video && currentTitle && currentTitle === expectedTitle
|
||||
if (video && currentTitle && sameEpisode(currentTitle, expectedTitle)
|
||||
&& video.currentTime < 5 && video.readyState >= 1) {
|
||||
// Match! Pause at start and report ready.
|
||||
if (!video.paused) {
|
||||
expectEvent('paused');
|
||||
_setSuppress('paused');
|
||||
video.pause();
|
||||
}
|
||||
stopLobbyPoll();
|
||||
@@ -131,7 +194,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.
|
||||
@@ -151,7 +214,7 @@
|
||||
|
||||
|
||||
function stopLobbyPoll() {
|
||||
pendingLobbyTitle = null;
|
||||
_pendingLobbyTitle = null;
|
||||
if (lobbyPollTimer) {
|
||||
clearInterval(lobbyPollTimer);
|
||||
lobbyPollTimer = null;
|
||||
@@ -169,7 +232,7 @@
|
||||
reportLog(`Media Action Error: Invalid seek payload - ${JSON.stringify(data)}`, 'error');
|
||||
return;
|
||||
}
|
||||
data.targetTime = target;
|
||||
data = { ...data, targetTime: target };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -182,11 +245,11 @@
|
||||
if (ytButton) {
|
||||
const isCurrentlyPlaying = !video.paused;
|
||||
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
|
||||
expectEvent(action === EVENTS.PLAY ? 'playing' : 'paused');
|
||||
_setSuppress(action === EVENTS.PLAY ? 'playing' : 'paused');
|
||||
ytButton.click();
|
||||
}
|
||||
if (action === EVENTS.SEEK) {
|
||||
expectEvent('seek');
|
||||
_setSuppress('seek');
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
return;
|
||||
@@ -198,11 +261,11 @@
|
||||
if (twitchButton) {
|
||||
const isCurrentlyPlaying = !video.paused;
|
||||
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
|
||||
expectEvent(action === EVENTS.PLAY ? 'playing' : 'paused');
|
||||
_setSuppress(action === EVENTS.PLAY ? 'playing' : 'paused');
|
||||
twitchButton.click();
|
||||
}
|
||||
if (action === EVENTS.SEEK) {
|
||||
expectEvent('seek');
|
||||
_setSuppress('seek');
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
return;
|
||||
@@ -211,19 +274,19 @@
|
||||
|
||||
// Fallback for native HTML5
|
||||
if (action === EVENTS.PLAY) {
|
||||
expectEvent('playing');
|
||||
_setSuppress('playing');
|
||||
video.play().catch((e) => {
|
||||
reportLog(`Playback prevented: ${e.message}`, 'warn');
|
||||
expectedEvents.delete('playing');
|
||||
_clearSuppress('playing');
|
||||
});
|
||||
} else if (action === EVENTS.PAUSE) {
|
||||
expectEvent('paused');
|
||||
_setSuppress('paused');
|
||||
video.pause();
|
||||
} else if (action === EVENTS.SEEK) {
|
||||
expectEvent('seek');
|
||||
_setSuppress('seek');
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e) {
|
||||
reportLog(`Media Action Error: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
@@ -259,22 +322,44 @@
|
||||
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;
|
||||
|
||||
// Guard: Don't execute sync commands if peers are on different episodes.
|
||||
// Only active when autoSyncNextEpisode setting is enabled (default: on).
|
||||
// Only blocks when BOTH sides have parseable S01E01-style IDs that differ.
|
||||
// Films and unparseable titles always pass through.
|
||||
const syncActions = [EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK,
|
||||
EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE];
|
||||
if (_autoSyncEnabled && syncActions.includes(action)) {
|
||||
const senderTitle = payload?.mediaTitle;
|
||||
const myTitle = getMediaTitle();
|
||||
if (isDifferentEpisode(senderTitle, myTitle)) {
|
||||
reportLog(`Episode mismatch: sender="${senderTitle || '?'}" vs mine="${myTitle || '?'}" — skipping ${action}. Disable "Auto-Sync next Episode" in settings if this causes issues.`, 'warn');
|
||||
if (action !== EVENTS.FORCE_SYNC_PREPARE && action !== EVENTS.FORCE_SYNC_EXECUTE) {
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -283,18 +368,28 @@
|
||||
reportLog(`Media Action Error: Invalid force sync payload - ${JSON.stringify(payload)}`, 'error');
|
||||
return;
|
||||
}
|
||||
expectEvent('paused');
|
||||
expectEvent('seek');
|
||||
_setSuppress('paused');
|
||||
_setSuppress('seek');
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +415,7 @@
|
||||
if (message.type === 'PAUSE_FOR_LOBBY') {
|
||||
const video = findVideo();
|
||||
if (video && !video.paused) {
|
||||
expectEvent('paused');
|
||||
_setSuppress('paused');
|
||||
video.pause();
|
||||
}
|
||||
// Start lobby poll now that we know the feature is enabled
|
||||
@@ -384,10 +479,14 @@
|
||||
|
||||
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
|
||||
if (_suppressTimers[eventState]) {
|
||||
_clearSuppress(eventState);
|
||||
return;
|
||||
}
|
||||
|
||||
// Suppress only SEEK during visibility grace period (tab re-focus ghost jump).
|
||||
// Play/Pause pass through — user may want to immediately pause after tabbing back.
|
||||
if (Date.now() < visibilityGraceUntil && action === EVENTS.SEEK) return;
|
||||
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'CONTENT_EVENT',
|
||||
@@ -399,8 +498,43 @@
|
||||
timestamp: Date.now()
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger proactive heartbeat to push stabilized state
|
||||
scheduleProactiveHeartbeat();
|
||||
}
|
||||
|
||||
// --- Tab Visibility Handling ---
|
||||
// Browsers (especially Firefox) aggressively throttle background tabs.
|
||||
// When the user returns to a video tab, the video element may have lost
|
||||
// time-sync and fires spurious seek events as it recovers (jumping back).
|
||||
// We suppress only SEEK for a short grace period after tab re-focus.
|
||||
// Play/Pause are NOT suppressed — the user may legitimately want to
|
||||
// pause immediately after switching back.
|
||||
let pageVisible = !document.hidden;
|
||||
let visibilityGraceUntil = 0;
|
||||
const VISIBILITY_GRACE_MS = 1000;
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
pageVisible = false;
|
||||
} else if (!pageVisible) {
|
||||
pageVisible = true;
|
||||
visibilityGraceUntil = Date.now() + VISIBILITY_GRACE_MS;
|
||||
reportLog(`Tab re-focused — suppressing seeks for ${VISIBILITY_GRACE_MS / 1000}s to prevent ghost relay`, 'warn');
|
||||
}
|
||||
});
|
||||
|
||||
// Reset on page hide/show (bfcache, tab discard)
|
||||
window.addEventListener('pagehide', () => { pageVisible = false; });
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
// event.persisted is true ONLY when restored from bfcache, not on initial load
|
||||
if (event.persisted && !pageVisible) {
|
||||
pageVisible = true;
|
||||
visibilityGraceUntil = Date.now() + VISIBILITY_GRACE_MS;
|
||||
reportLog(`Page restored from cache — suppressing seeks for ${VISIBILITY_GRACE_MS / 1000}s`, 'warn');
|
||||
}
|
||||
});
|
||||
|
||||
const handlePlay = () => reportEvent(EVENTS.PLAY);
|
||||
const handlePause = () => reportEvent(EVENTS.PAUSE);
|
||||
|
||||
@@ -412,24 +546,26 @@
|
||||
const current = video.currentTime;
|
||||
if (!Number.isFinite(current)) return;
|
||||
|
||||
// Step 1: Check expectedEvents (programmatic seek suppression)
|
||||
if (expectedEvents.has('seek')) {
|
||||
expectedEvents.delete('seek');
|
||||
lastReportedSeekTime = current; // Update baseline so next user seek is relative to here
|
||||
// No log — this is routine programmatic behavior (Force Sync, lobby, peer command)
|
||||
// Step 1: Check _suppressTimers (programmatic seek from remote peer)
|
||||
if (_suppressTimers['seek']) {
|
||||
_clearSuppress('seek');
|
||||
lastReportedSeekTime = current;
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Suppress during visibility grace period (tab re-focus ghost events)
|
||||
if (Date.now() < visibilityGraceUntil) return;
|
||||
|
||||
const delta = lastReportedSeekTime !== null ? Math.abs(current - lastReportedSeekTime) : null;
|
||||
const deltaStr = delta !== null ? `Δ${delta.toFixed(2)}s` : 'Δ?';
|
||||
|
||||
// Step 2: Delta check — skip micro-seeks (buffering, chapter markers, etc.)
|
||||
// Step 3: Delta check — skip micro-seeks (buffering, chapter markers, etc.)
|
||||
if (lastReportedSeekTime !== null && delta < MIN_SEEK_DELTA) {
|
||||
reportLog(`[Seek] Filtered (${deltaStr} < ${MIN_SEEK_DELTA}s threshold) @ ${current.toFixed(2)}s — not relayed`, 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Debounce rapid consecutive seeks (e.g. scrubbing)
|
||||
// Step 4: Debounce rapid consecutive seeks (e.g. scrubbing)
|
||||
// — wait 800ms for the user to settle before relaying
|
||||
if (seekDebounceTimer) clearTimeout(seekDebounceTimer);
|
||||
seekDebounceTimer = setTimeout(() => {
|
||||
@@ -446,7 +582,7 @@
|
||||
};
|
||||
|
||||
|
||||
let lastVideoSrc = null;
|
||||
let lastVideoSrc = undefined;
|
||||
|
||||
// Episode detection handler for loadeddata event
|
||||
const handleLoadedData = () => {
|
||||
@@ -456,19 +592,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();
|
||||
}
|
||||
@@ -482,13 +621,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();
|
||||
@@ -504,45 +649,70 @@
|
||||
observerTimeout = setTimeout(checkVideo, 1000 - (now - lastMutate));
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
|
||||
// --- SHARED_HEARTBEAT_INJECT_START ---
|
||||
const HEARTBEAT_INTERVAL_VAL = 15000;
|
||||
// --- SHARED_HEARTBEAT_INJECT_END ---
|
||||
|
||||
// Heartbeat
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_VAL);
|
||||
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.3.1",
|
||||
"description": "Watch party extension to synchronize video playback on YouTube, Twitch, Netflix, and HTML5 sites in real-time with friends.",
|
||||
"version": "1.9.0",
|
||||
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
"tabs",
|
||||
@@ -17,21 +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"
|
||||
}
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["https://sync.koalastuff.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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,49 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 16px 0;
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
text-align: center;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
h1 img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.popup-version {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s, color 0.2s;
|
||||
}
|
||||
.popup-version:hover {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
.popup-version svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
@@ -189,16 +225,110 @@
|
||||
}
|
||||
.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); }
|
||||
}
|
||||
|
||||
@keyframes fadeSlideIn {
|
||||
from { opacity: 0; transform: translateX(5px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
.tab-active-animate {
|
||||
animation: fadeSlideIn 0.15s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Toggle Switch CSS */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
}
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: #334155;
|
||||
transition: .3s;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: #94a3b8;
|
||||
transition: .3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
input:checked + .slider {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(16px);
|
||||
background-color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>KoalaSync</h1>
|
||||
<div id="toast-container"></div>
|
||||
<div class="header-row">
|
||||
<h1><img src="icons/icon128.png" alt="KoalaSync Logo">KoalaSync</h1>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" class="popup-version">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
<span id="popupVersion">v0.0.0</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="tab-room">Room</button>
|
||||
<button class="tab-btn" data-tab="tab-sync">Sync</button>
|
||||
<button class="tab-btn" data-tab="tab-settings">Settings</button>
|
||||
<button class="tab-btn" data-tab="tab-dev">Dev</button>
|
||||
<button class="tab-btn active" data-tab="tab-room" title="Room settings and connection">Room</button>
|
||||
<button class="tab-btn" data-tab="tab-sync" title="Video sync controls and remote actions">Sync</button>
|
||||
<button class="tab-btn" data-tab="tab-settings" title="Extension preferences">Settings</button>
|
||||
<button class="tab-btn" data-tab="tab-dev" title="Advanced Diagnostics & Logs">Status</button>
|
||||
</div>
|
||||
|
||||
<!-- Room Tab -->
|
||||
@@ -206,47 +336,52 @@
|
||||
|
||||
<!-- JOIN SECTION: Visible when not in a room -->
|
||||
<div id="section-join">
|
||||
<button id="createRoomBtn" class="primary" style="margin-bottom: 16px; padding: 14px; font-size: 15px; background: linear-gradient(135deg, #6366f1, #a855f7); box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);">+ Create New Room</button>
|
||||
<details style="margin-bottom: 12px; background: var(--card); border: 1px solid #334155; border-radius: 8px; padding: 8px;">
|
||||
<summary style="font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; cursor: pointer; outline: none;">Manual Connect / Advanced</summary>
|
||||
<div style="margin-top: 12px;">
|
||||
<div class="form-group">
|
||||
<label>Server</label>
|
||||
<label title="Select which KoalaSync server to use">Server</label>
|
||||
<div style="display:flex; gap:4px; margin-bottom:8px;">
|
||||
<button id="serverOfficial" class="tab-btn active" style="flex:1; padding:6px; font-size:11px;">Official</button>
|
||||
<button id="serverCustom" class="tab-btn" style="flex:1; padding:6px; font-size:11px;">Custom</button>
|
||||
<button id="serverOfficial" class="tab-btn active" style="flex:1; padding:6px; font-size:11px;" title="Use the official reliable server">Official</button>
|
||||
<button id="serverCustom" class="tab-btn" style="flex:1; padding:6px; font-size:11px;" title="Connect to your own self-hosted server">Custom</button>
|
||||
</div>
|
||||
<input type="text" id="serverUrl" placeholder="wss://your-server:3000" style="display:none;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Room ID</label>
|
||||
<input type="text" id="roomId" placeholder="Leave empty to create">
|
||||
<label title="The unique identifier for your sync room">Room ID</label>
|
||||
<input type="text" id="roomId" placeholder="Enter Room ID" title="The unique ID of the room you want to join">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password (Optional)</label>
|
||||
<input type="password" id="password" placeholder="Room password">
|
||||
<label title="Optional password to restrict room access">Password (Optional)</label>
|
||||
<input type="password" id="password" placeholder="Room Password (optional)" title="Password for the room (leave empty if none)">
|
||||
</div>
|
||||
<div id="roomError" style="display:none; color:var(--error); font-size:11px; margin-bottom:8px; text-align:center;"></div>
|
||||
<button id="joinBtn" class="primary">Join Room</button>
|
||||
<button id="createRoomBtn" class="secondary" style="border: 1px solid var(--accent); color: var(--accent);">Create Random Room</button>
|
||||
<button id="joinBtn" class="primary" title="Connect to the room">Join Room</button>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1.5rem; margin-bottom: 8px;">
|
||||
<label style="margin:0;">Public Rooms</label>
|
||||
<button id="refreshRooms" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;">REFRESH</button>
|
||||
<label style="margin:0;" title="List of publicly available rooms on this server">Public Rooms</label>
|
||||
<button id="refreshRooms" class="secondary" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;" title="Refresh the list of public rooms">REFRESH</button>
|
||||
</div>
|
||||
<div id="publicRooms" class="info-card" style="max-height: 120px; overflow-y: auto; padding: 4px;">
|
||||
<div style="text-align:center; color: var(--text-muted); font-size: 11px; padding: 10px;">Refreshing...</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- ACTIVE SECTION: Visible when in a room -->
|
||||
<div id="section-active" style="display:none;">
|
||||
<div class="info-card" style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; border-left: 4px solid var(--accent);">
|
||||
<div>
|
||||
<label style="margin-bottom: 0;">Active Room</label>
|
||||
<label style="margin-bottom: 0;" title="The room you are currently connected to">Active Room</label>
|
||||
<div id="activeRoomId" style="font-weight: 700; color: var(--accent); font-size: 16px; letter-spacing: 1px;">NONE</div>
|
||||
</div>
|
||||
<div id="activeServer" style="font-size: 10px; color: var(--text-muted); text-align: right; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">Official Server</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card" style="margin-bottom: 20px;">
|
||||
<label>Invite Link</label>
|
||||
<label title="Share this link with friends so they can join">Invite Link</label>
|
||||
<div class="invite-box">
|
||||
<input type="text" id="inviteLink" readonly>
|
||||
<button id="copyInvite" class="secondary">📋</button>
|
||||
@@ -254,7 +389,7 @@
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label>Peers in Room</label>
|
||||
<label title="Other users currently connected to this room">Peers in Room</label>
|
||||
<div id="peerList" class="info-card">
|
||||
<div style="text-align:center; color: var(--text-muted); font-size: 12px;">No peers connected</div>
|
||||
</div>
|
||||
@@ -266,25 +401,36 @@
|
||||
|
||||
<!-- Sync Tab -->
|
||||
<div id="tab-sync" class="tab-content">
|
||||
<!-- SYNC ACTIVE: Visible when in a room -->
|
||||
<div id="sync-active">
|
||||
<div class="form-group">
|
||||
<label>Target Tab (Video Source)</label>
|
||||
<label title="Choose the browser tab containing the video to sync">Select Video</label>
|
||||
<select id="targetTab">
|
||||
<option value="">-- Select a Tab --</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label>Remote Control</label>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
|
||||
<label style="margin: 0;">Remote Control</label>
|
||||
<button id="syncTabCopyInvite" title="Copy Invite Link" style="background:transparent; border: 1px solid #334155; border-radius: 6px; padding: 4px 8px; font-size: 11px; cursor:pointer; opacity:0.8; transition: opacity 0.2s; color: var(--text-muted); display: flex; align-items: center; gap: 4px; white-space: nowrap;">📋 Invite Link</button>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
|
||||
<button id="playBtn" class="primary" style="flex:1; background: var(--success);">▶ Play</button>
|
||||
<button id="pauseBtn" class="primary" style="flex:1; background: var(--error);">⏸ Pause</button>
|
||||
<button id="playBtn" class="primary" style="flex:1; background: var(--success);" title="Send a Play command to everyone">▶ Play</button>
|
||||
<button id="pauseBtn" class="primary" style="flex:1; background: var(--error);" title="Send a Pause command to everyone">⏸ 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;" title="Force all users to sync up">⚡ SYNC</button>
|
||||
<select id="forceSyncMode" style="flex: 1; 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>
|
||||
<label title="Shows the most recent play, pause, or seek command">Last Activity Status</label>
|
||||
<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 -->
|
||||
@@ -298,63 +444,90 @@
|
||||
</div>
|
||||
|
||||
<div id="peerListSync" class="info-card" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- SYNC INACTIVE: Visible when not in a room -->
|
||||
<div id="sync-inactive" style="display:none; text-align: center; padding: 20px 10px;">
|
||||
<div style="font-size: 32px; margin-bottom: 12px;">🔒</div>
|
||||
<h3 style="margin: 0 0 8px 0; color: var(--accent); font-size: 15px;">Connect to a room first</h3>
|
||||
<p style="color: var(--text-muted); font-size: 12px; margin-bottom: 20px;">You need to join a room via an invite link or create a new one to sync videos.</p>
|
||||
<button id="syncTabCreateRoomBtn" class="primary" style="padding: 12px; font-size: 14px; background: linear-gradient(135deg, #6366f1, #a855f7); box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);" title="Create a new random room and join it">Create New Room</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div id="tab-settings" class="tab-content">
|
||||
<div class="form-group">
|
||||
<label>Your Username</label>
|
||||
<input type="text" id="username" placeholder="Anonymous Koala" maxlength="20">
|
||||
<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;" title="Username helps others identify you.">Your Username</label>
|
||||
<input type="text" id="username" placeholder="Anonymous Koala" maxlength="20" style="width: 150px;">
|
||||
</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;">Filter Noise Tabs</label>
|
||||
<input type="checkbox" id="filterNoise" style="width: auto;" checked>
|
||||
<label style="margin-bottom: 0; cursor: help;" title="Filters out non-video tabs and unrelated domains to keep the list clean">Hide Clutter Tabs</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="filterNoise" checked>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display: flex; align-items: center; justify-content: space-between; background: var(--card); padding: 10px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #334155;">
|
||||
<label style="margin-bottom: 0;">Auto-Sync Next Episode</label>
|
||||
<input type="checkbox" id="autoSyncNextEpisode" style="width: auto;">
|
||||
<label style="margin-bottom: 0; cursor: help;" title="Pauses automatically and waits for all peers when an episode changes, then sync-starts together.">Auto-Sync Next Episode</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="autoSyncNextEpisode">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</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; cursor: help;" title="Automatically copies the invite link to your clipboard when creating a new room.">Auto-Copy Invite Link</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="autoCopyInvite" checked>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</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; cursor: help;" title="Shows native system notifications when someone joins/leaves or plays/pauses.">Browser Notifications</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="browserNotifications">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="font-size: 11px; color: var(--text-muted); padding: 8px;">
|
||||
<p>• Username helps others identify you.</p>
|
||||
<p>• Noise filtering uses a blacklist to hide common non-video sites (e.g. Search, Social Media) from the Target Tab selector.</p>
|
||||
<p>• Auto-Sync will pause and wait for all peers when an episode changes, then sync-start together.</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div style="margin-top: 15px; padding: 8px; border-top: 1px solid var(--card);">
|
||||
<label>Troubleshooting</label>
|
||||
<button id="regenId" class="secondary" style="width: 100%; font-size: 11px;">Regenerate Peer ID</button>
|
||||
<label title="Tools for fixing connection issues">Troubleshooting</label>
|
||||
<button id="regenId" class="secondary" style="width: 100%; font-size: 11px;" title="Regenerate your internal ID and reconnect">Regenerate Peer ID</button>
|
||||
<p style="font-size: 9px; color: var(--text-muted); margin-top: 5px; text-align: center;">Use this if you see "Duplicate Identity" errors.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dev Tab -->
|
||||
<div id="tab-dev" class="tab-content">
|
||||
<label>Connection Status</label>
|
||||
<label title="Current WebSocket connection state">Connection Status</label>
|
||||
<div id="connStatus" class="info-card" style="display:flex; align-items:center; gap: 10px;">
|
||||
<span id="connDot" class="status-dot status-offline"></span>
|
||||
<span id="connText" style="flex:1;">Disconnected</span>
|
||||
<button id="retryBtn" class="secondary" style="display:none; width: auto; padding: 4px 8px; font-size: 10px; margin: 0;">RETRY</button>
|
||||
<button id="copyLogs" class="btn secondary" style="width: auto; padding: 4px 10px; font-size: 11px;">Copy Logs</button>
|
||||
<button id="retryBtn" class="secondary" style="display:none; width: auto; padding: 4px 8px; font-size: 10px; margin: 0;" title="Attempt to reconnect to the server">RETRY</button>
|
||||
<button id="copyLogs" class="btn secondary" style="width: auto; padding: 4px 10px; font-size: 11px;" title="Copy logs to clipboard for sharing">Copy Logs</button>
|
||||
</div>
|
||||
|
||||
<label>Video Debug Info</label>
|
||||
<label title="Technical details about the currently selected video element">Video Debug Info</label>
|
||||
<div id="videoDebug" class="info-card" style="font-size: 10px; font-family: monospace; color: var(--text-muted); max-height: 250px; overflow-y: auto; line-height: 1.4;">
|
||||
No tab selected or video detected.
|
||||
</div>
|
||||
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 8px;">
|
||||
<label>Full Action History</label>
|
||||
<label title="Chronological log of all sync commands in the room">Full Action History</label>
|
||||
</div>
|
||||
<div id="historyList" class="info-card" style="max-height: 120px; overflow-y: auto; font-size: 10px; margin-bottom: 15px;">
|
||||
<div style="text-align:center; color: var(--text-muted); font-size: 11px;">No activity yet</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 8px;">
|
||||
<label>Logs (Last 50)</label>
|
||||
<button id="clearLogs" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;">CLEAR</button>
|
||||
<label title="Technical connection logs for debugging">Logs (Last 50)</label>
|
||||
<button id="clearLogs" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;" title="Clear log output">CLEAR</button>
|
||||
</div>
|
||||
<div id="logList"></div>
|
||||
|
||||
@@ -365,5 +538,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.5); backdrop-filter: blur(2px); z-index:1000; align-items:flex-end; justify-content:center; padding-bottom: 20px;">
|
||||
<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;" title="Skip the tutorial">Skip</button>
|
||||
<button id="onboarding-next" class="primary" style="width:auto; padding:8px 16px;" title="Go to next step">Next</button>
|
||||
</div>
|
||||
<div id="onboarding-dots" style="margin-top:12px; display:flex; gap:6px; justify-content:center;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,17 +1,230 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "1.3.1",
|
||||
"version": "1.8.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "koalasync",
|
||||
"version": "1.3.1",
|
||||
"version": "1.8.5",
|
||||
"devDependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"eslint": "^10.4.0",
|
||||
"fs-extra": "^11.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eslint-visitor-keys": "^3.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
|
||||
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/regexpp": {
|
||||
"version": "4.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
|
||||
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array": {
|
||||
"version": "0.23.5",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
|
||||
"integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/object-schema": "^3.0.5",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^10.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-helpers": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
|
||||
"integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/core": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
|
||||
"integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/object-schema": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
|
||||
"integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz",
|
||||
"integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.2.1",
|
||||
"levn": "^0.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@humanfs/types": "^0.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/node": {
|
||||
"version": "0.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
|
||||
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@humanfs/core": "^0.19.2",
|
||||
"@humanfs/types": "^0.15.0",
|
||||
"@humanwhocodes/retry": "^0.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/types": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
|
||||
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/module-importer": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
|
||||
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.22"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/retry": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
|
||||
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -41,6 +254,27 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/esrecurse": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
@@ -54,6 +288,46 @@
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-jsx": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
|
||||
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
@@ -396,6 +670,31 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
@@ -410,6 +709,210 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz",
|
||||
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@eslint/config-array": "^0.23.5",
|
||||
"@eslint/config-helpers": "^0.6.0",
|
||||
"@eslint/core": "^1.2.1",
|
||||
"@eslint/plugin-kit": "^0.7.1",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
"@types/estree": "^1.0.6",
|
||||
"ajv": "^6.14.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"debug": "^4.3.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"eslint-scope": "^9.1.2",
|
||||
"eslint-visitor-keys": "^5.0.1",
|
||||
"espree": "^11.2.0",
|
||||
"esquery": "^1.7.0",
|
||||
"esutils": "^2.0.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"file-entry-cache": "^8.0.0",
|
||||
"find-up": "^5.0.0",
|
||||
"glob-parent": "^6.0.2",
|
||||
"ignore": "^5.2.0",
|
||||
"imurmurhash": "^0.1.4",
|
||||
"is-glob": "^4.0.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"minimatch": "^10.2.4",
|
||||
"natural-compare": "^1.4.0",
|
||||
"optionator": "^0.9.3"
|
||||
},
|
||||
"bin": {
|
||||
"eslint": "bin/eslint.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://eslint.org/donate"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"jiti": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"jiti": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-scope": {
|
||||
"version": "9.1.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
|
||||
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@types/esrecurse": "^4.3.1",
|
||||
"@types/estree": "^1.0.8",
|
||||
"esrecurse": "^4.3.0",
|
||||
"estraverse": "^5.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-visitor-keys": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
|
||||
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"acorn": "^8.16.0",
|
||||
"acorn-jsx": "^5.3.2",
|
||||
"eslint-visitor-keys": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/esquery": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"estraverse": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/esrecurse": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
|
||||
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"estraverse": "^5.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/estraverse": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
|
||||
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esutils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
@@ -440,6 +943,13 @@
|
||||
"bare-events": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-fifo": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
||||
@@ -447,6 +957,71 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-levenshtein": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
|
||||
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"flat-cache": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^6.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/flat-cache": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"flatted": "^3.2.9",
|
||||
"keyv": "^4.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
@@ -501,6 +1076,19 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -529,6 +1117,26 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/imurmurhash": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
@@ -536,6 +1144,16 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
@@ -546,6 +1164,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-stream": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
@@ -589,6 +1220,27 @@
|
||||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-buffer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stable-stringify-without-jsonify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||
@@ -602,6 +1254,16 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lazystream": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
|
||||
@@ -648,6 +1310,36 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prelude-ls": "^1.2.1",
|
||||
"type-check": "~0.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
@@ -688,6 +1380,20 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/natural-compare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -698,6 +1404,56 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deep-is": "^0.1.3",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
"levn": "^0.4.1",
|
||||
"prelude-ls": "^1.2.1",
|
||||
"type-check": "^0.4.0",
|
||||
"word-wrap": "^1.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"yocto-queue": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
|
||||
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
@@ -705,6 +1461,16 @@
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -732,6 +1498,16 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process": {
|
||||
"version": "0.11.10",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
@@ -749,6 +1525,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||
@@ -1005,6 +1791,19 @@
|
||||
"b4a": "^1.6.4"
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prelude-ls": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
@@ -1015,6 +1814,16 @@
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -1038,6 +1847,16 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
@@ -1136,6 +1955,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zip-stream": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz",
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "1.3.1",
|
||||
"version": "1.9.0",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:extension": "node scripts/build-extension.js"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ if (!fs.existsSync(extSharedDir)) {
|
||||
fs.mkdirSync(extSharedDir, { recursive: true });
|
||||
}
|
||||
|
||||
const sharedFiles = ['constants.js', 'blacklist.js', 'README.md'];
|
||||
const sharedFiles = ['constants.js', 'blacklist.js', 'names.js', 'README.md'];
|
||||
for (const file of sharedFiles) {
|
||||
const src = path.join(masterSharedDir, file);
|
||||
const dest = path.join(extSharedDir, file);
|
||||
@@ -31,7 +31,7 @@ for (const file of sharedFiles) {
|
||||
}
|
||||
fs.copyFileSync(src, dest);
|
||||
}
|
||||
console.log('✓ constants.js, blacklist.js, and README.md synced to extension/shared/');
|
||||
console.log('✓ constants.js, blacklist.js, names.js, and README.md synced to extension/shared/');
|
||||
|
||||
// Read the base manifest
|
||||
const baseManifest = JSON.parse(fs.readFileSync(baseManifestPath, 'utf8'));
|
||||
@@ -161,7 +161,10 @@ async function run() {
|
||||
};
|
||||
manifest.browser_specific_settings = {
|
||||
gecko: {
|
||||
id: "koalasync@koalastuff.net"
|
||||
id: "koalasync@koalastuff.net",
|
||||
data_collection_permissions: {
|
||||
required: ["none"]
|
||||
}
|
||||
}
|
||||
};
|
||||
return manifest;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,45 @@ 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://sync.koalastuff.net"],
|
||||
origin: (origin, callback) => {
|
||||
if (!origin || origin === 'https://sync.koalastuff.net' || origin.startsWith('chrome-extension://') || origin.startsWith('moz-extension://')) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
log('CORS', `Rejected origin: ${origin}`);
|
||||
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 +64,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();
|
||||
@@ -66,6 +94,10 @@ function checkAuthRate(ip, roomId) {
|
||||
}
|
||||
|
||||
function recordAuthFailure(ip, roomId) {
|
||||
if (failedAuthAttempts.size > 50000) {
|
||||
failedAuthAttempts.clear();
|
||||
log('SECURITY', 'Cleared failedAuthAttempts map to prevent memory leak');
|
||||
}
|
||||
const key = `${ip}:${roomId}`;
|
||||
const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 };
|
||||
record.count++;
|
||||
@@ -73,17 +105,18 @@ 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 and event counts to prevent memory leak
|
||||
setInterval(() => {
|
||||
@@ -94,10 +127,15 @@ setInterval(() => {
|
||||
}
|
||||
}
|
||||
for (const [socketId, entry] of eventCounts.entries()) {
|
||||
if (now > entry.resetTime) {
|
||||
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) {
|
||||
@@ -118,6 +156,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.
|
||||
@@ -125,12 +172,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;
|
||||
|
||||
@@ -146,7 +189,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);
|
||||
}
|
||||
|
||||
@@ -164,7 +208,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)) {
|
||||
@@ -185,7 +232,14 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
|
||||
if (clientVersion) {
|
||||
const [cMaj, cMin, cPatch] = clientVersion.split('.').map(Number);
|
||||
if (typeof clientVersion !== 'string') {
|
||||
log('AUTH', `Invalid version type from ${clientIp}`);
|
||||
socket.emit(EVENTS.ERROR, { message: 'Invalid version format' });
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
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}`);
|
||||
@@ -225,7 +279,7 @@ io.on('connection', (socket) => {
|
||||
|
||||
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;
|
||||
@@ -233,64 +287,102 @@ 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)) {
|
||||
log('AUTH', `Auth rate limit blocked ${ip} from room ${roomId.substring(0, 3)}***`);
|
||||
socket.emit(EVENTS.ERROR, { message: "Too many failed attempts. Try again later." });
|
||||
return;
|
||||
}
|
||||
|
||||
let room = rooms.get(roomId);
|
||||
let createdByMe = false;
|
||||
|
||||
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) {
|
||||
// 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) {
|
||||
log('ROOM', `Server at capacity: ${rooms.size}/${MAX_ROOMS} rooms — rejecting join`);
|
||||
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)}***`);
|
||||
} else {
|
||||
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);
|
||||
createdByMe = true;
|
||||
log('ROOM', `Created room: ${roomId.substring(0, 3)}***`);
|
||||
} finally {
|
||||
roomCreationLocks.delete(roomId);
|
||||
resolveLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!room) {
|
||||
socket.emit(EVENTS.ERROR, { message: "Join error" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!createdByMe) {
|
||||
if (room.passwordHash) {
|
||||
if (!password || !(await bcrypt.compare(password, room.passwordHash))) {
|
||||
recordAuthFailure(ip, roomId);
|
||||
log('AUTH', `Invalid password from ${ip} for room ${roomId.substring(0, 3)}***`);
|
||||
socket.emit(EVENTS.ERROR, { message: "Invalid password" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (room.peers.size >= MAX_PEERS_PER_ROOM) {
|
||||
log('ROOM', `Room full (${room.peers.size}/${MAX_PEERS_PER_ROOM}): ${roomId.substring(0, 3)}***`);
|
||||
socket.emit(EVENTS.ERROR, { message: "Room full" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -314,7 +406,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" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -328,18 +422,19 @@ 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();
|
||||
|
||||
// --- S-2 & S-3: Sanitize ALL relay fields (strings, numbers, booleans) ---
|
||||
@@ -364,6 +459,7 @@ io.on('connection', (socket) => {
|
||||
// --- S-3: Construct clean relay payload — never forward raw client data ---
|
||||
const relayPayload = {
|
||||
senderId: mapping.peerId,
|
||||
seq: clampNum(data.seq, 0, Number.MAX_SAFE_INTEGER),
|
||||
currentTime: clampNum(data.currentTime, 0, 86400),
|
||||
targetTime: clampNum(data.targetTime, 0, 86400),
|
||||
playbackState: validState(data.playbackState),
|
||||
@@ -381,12 +477,20 @@ io.on('connection', (socket) => {
|
||||
// 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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
socket.on(EVENTS.GET_ROOMS, () => {
|
||||
if (!checkEventRate(socket.id)) {
|
||||
log('SECURITY', `Event rate limit exceeded for socket (GET_ROOMS): ${socket.id}`);
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
const list = Array.from(rooms.entries()).map(([id, r]) => ({
|
||||
id,
|
||||
peerCount: r.peers.size,
|
||||
@@ -403,25 +507,30 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(EVENTS.EVENT_ACK, (data) => {
|
||||
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;
|
||||
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);
|
||||
@@ -441,7 +550,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 = [];
|
||||
@@ -453,14 +566,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)`);
|
||||
@@ -491,3 +605,13 @@ 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.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
log('ERROR', `Unhandled rejection: ${reason}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -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,6 +109,15 @@ export const BLACKLIST_DOMAINS = [
|
||||
'myanimelist.net',
|
||||
|
||||
// Development & Utilities
|
||||
'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',
|
||||
@@ -85,6 +125,47 @@ export const BLACKLIST_DOMAINS = [
|
||||
'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',
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = "1.0.0";
|
||||
export const APP_VERSION = "1.3.1";
|
||||
export const APP_VERSION = "1.9.0";
|
||||
|
||||
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
|
||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* KoalaSync Shared Name Generation & Emoji Mapping
|
||||
*
|
||||
* ⚠️ WARNING: This is the SINGLE SOURCE OF TRUTH.
|
||||
* If you edit this file, you MUST run: node scripts/build-extension.js
|
||||
* to propagate changes to the extension.
|
||||
*
|
||||
* The emoji map covers every animal/creature that has a Unicode emoji.
|
||||
* Entries are sorted by key length (longest first) at lookup time to
|
||||
* prevent substring false-matches (e.g. "caterpillar" must be checked
|
||||
* before "cat").
|
||||
*
|
||||
* If you add a new animal noun to USERNAME_NOUNS, ensure it has a
|
||||
* corresponding entry in ANIMAL_EMOJI_MAP (or a substring that already
|
||||
* maps to a suitable emoji).
|
||||
*/
|
||||
|
||||
export const USERNAME_ADJECTIVES = [
|
||||
'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'
|
||||
];
|
||||
|
||||
export const USERNAME_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',
|
||||
'Cat', 'Dog', 'Deer', 'Bat', 'Gorilla', 'Monkey', 'Rabbit',
|
||||
'Horse', 'Unicorn', 'Zebra', 'Leopard', 'Cheetah', 'Puma',
|
||||
'Ram', 'Goat', 'Bull', 'Donkey', 'Moose',
|
||||
'Elephant', 'Giraffe', 'Hippo', 'Sloth', 'Kangaroo',
|
||||
'Raccoon', 'Hamster', 'Hedgehog', 'Skunk', 'Beaver', 'Bison',
|
||||
'Camel', 'Llama', 'Hyena', 'Coyote',
|
||||
'Mouse', 'Pig', 'Boar', 'Polar', 'Orangutan', 'Mammoth',
|
||||
'Crow', 'Duck', 'Swan', 'Penguin', 'Parrot', 'Peacock',
|
||||
'Dove', 'Dodo', 'Turkey', 'Flamingo', 'Chicken', 'Rooster', 'Goose',
|
||||
'Dolphin', 'Whale', 'Crab', 'Lobster', 'Octopus', 'Squid',
|
||||
'Jellyfish', 'Turtle',
|
||||
'Crocodile', 'Lizard', 'Snake', 'Frog', 'Toad', 'Gecko',
|
||||
'Bee', 'Ant', 'Spider', 'Scorpion', 'Butterfly', 'Ladybug',
|
||||
'Beetle', 'Snail', 'Dragonfly', 'Caterpillar',
|
||||
'Alien', 'Robot', 'Mermaid', 'Ghoul', 'Sprite', 'Cyborg',
|
||||
'Dinosaur', 'Reaper', 'Wraith', 'Sphinx',
|
||||
];
|
||||
|
||||
export const ANIMAL_EMOJI_MAP = {
|
||||
'hippopotamus': '🦛',
|
||||
'rhinoceros': '🦏',
|
||||
'caterpillar': '🐛',
|
||||
'chimpanzee': '🐵',
|
||||
'orangutan': '🦧',
|
||||
'blackbird': '🐦⬛',
|
||||
'bumblebee': '🐝',
|
||||
'cockatoo': '🦜',
|
||||
'cockroach': '🪳',
|
||||
'dragonfly': '🐉',
|
||||
'grasshopper': '🦗',
|
||||
'hedgehog': '🦔',
|
||||
'jellyfish': '🪼',
|
||||
'kangaroo': '🦘',
|
||||
'ladybird': '🐞',
|
||||
'ladybug': '🐞',
|
||||
'porcupine': '🦔',
|
||||
'scorpion': '🦂',
|
||||
'tarantula': '🕷️',
|
||||
'alligator': '🐊',
|
||||
'anaconda': '🐍',
|
||||
'antelope': '🦌',
|
||||
'blowfish': '🐡',
|
||||
'butterfly': '🦋',
|
||||
'chameleon': '🦎',
|
||||
'chipmunk': '🐿️',
|
||||
'crocodile': '🐊',
|
||||
'dinosaur': '🦖',
|
||||
'elephant': '🐘',
|
||||
'flamingo': '🦩',
|
||||
'giraffe': '🦒',
|
||||
'hamster': '🐹',
|
||||
'leopard': '🐆',
|
||||
'lobster': '🦞',
|
||||
'mermaid': '🧜♀️',
|
||||
'mongoose': '🦦',
|
||||
'mosquito': '🦟',
|
||||
'pangolin': '🦔',
|
||||
'peacock': '🦚',
|
||||
'penguin': '🐧',
|
||||
'phoenix': '🐦🔥',
|
||||
'raccoon': '🦝',
|
||||
'seahorse': '🐴',
|
||||
'sealion': '🦭',
|
||||
'unicorn': '🦄',
|
||||
'vampire': '🦇',
|
||||
'warthog': '🐗',
|
||||
'wolverine': '🦡',
|
||||
'mammoth': '🦣',
|
||||
'meerkat': '🦦',
|
||||
'octopus': '🐙',
|
||||
'opposum': '🐭',
|
||||
'ostrich': '🐦',
|
||||
'panther': '🐆',
|
||||
'pelican': '🦩',
|
||||
'rooster': '🐓',
|
||||
'serpent': '🐍',
|
||||
'specter': '👻',
|
||||
'spectre': '👻',
|
||||
'sparrow': '🐦',
|
||||
'spider': '🕷️',
|
||||
'sphinx': '🦁',
|
||||
'squirrel': '🐿️',
|
||||
'stingray': '🦈',
|
||||
'termite': '🐜',
|
||||
'tortoise': '🐢',
|
||||
'turkey': '🦃',
|
||||
'walrus': '🦭',
|
||||
'wombat': '🦡',
|
||||
'woodpecker': '🐦',
|
||||
'alien': '👾',
|
||||
'badger': '🦡',
|
||||
'beaver': '🦫',
|
||||
'beetle': '🪲',
|
||||
'beluga': '🐋',
|
||||
'bison': '🦬',
|
||||
'bobcat': '🐱',
|
||||
'buffalo': '🦬',
|
||||
'bunny': '🐰',
|
||||
'camel': '🐪',
|
||||
'cheetah': '🐆',
|
||||
'chicken': '🐔',
|
||||
'cobra': '🐍',
|
||||
'condor': '🦅',
|
||||
'cougar': '🐆',
|
||||
'coyote': '🐺',
|
||||
'crane': '🦩',
|
||||
'cricket': '🦗',
|
||||
'crow': '🐦⬛',
|
||||
'cyborg': '🤖',
|
||||
'dolphin': '🐬',
|
||||
'donkey': '🫏',
|
||||
'dragon': '🐉',
|
||||
'drake': '🐉',
|
||||
'eagle': '🦅',
|
||||
'falcon': '🦅',
|
||||
'ferret': '🦦',
|
||||
'gazelle': '🦌',
|
||||
'gecko': '🦎',
|
||||
'gerbil': '🐹',
|
||||
'ghost': '👻',
|
||||
'ghoul': '👻',
|
||||
'goose': '🪿',
|
||||
'gopher': '🐹',
|
||||
'gorilla': '🦍',
|
||||
'grizzly': '🐻',
|
||||
'heron': '🦩',
|
||||
'hippo': '🦛',
|
||||
'hornet': '🐝',
|
||||
'hyena': '🐺',
|
||||
'iguana': '🦎',
|
||||
'jackal': '🐺',
|
||||
'jaguar': '🐆',
|
||||
'kitten': '🐱',
|
||||
'koala': '🐨',
|
||||
'lemur': '🐒',
|
||||
'lizard': '🦎',
|
||||
'llama': '🦙',
|
||||
'locust': '🦗',
|
||||
'lynx': '🐱',
|
||||
'macaw': '🦜',
|
||||
'mantis': '🦗',
|
||||
'mink': '🦦',
|
||||
'monkey': '🐵',
|
||||
'moose': '🦌',
|
||||
'mouse': '🐭',
|
||||
'orca': '🐋',
|
||||
'otter': '🦦',
|
||||
'oyster': '🦪',
|
||||
'panda': '🐼',
|
||||
'parrot': '🦜',
|
||||
'pigeon': '🕊️',
|
||||
'polar': '🐻❄️',
|
||||
'poodle': '🐩',
|
||||
'puffin': '🐧',
|
||||
'puma': '🐆',
|
||||
'rabbit': '🐰',
|
||||
'raptor': '🦖',
|
||||
'raven': '🐦⬛',
|
||||
'reaper': '👻',
|
||||
'rhino': '🦏',
|
||||
'robin': '🐦',
|
||||
'robot': '🤖',
|
||||
'salmon': '🐟',
|
||||
'shrimp': '🦐',
|
||||
'skunk': '🦨',
|
||||
'sloth': '🦥',
|
||||
'snail': '🐌',
|
||||
'snake': '🐍',
|
||||
'sprite': '🧚',
|
||||
'squid': '🦑',
|
||||
'swan': '🦢',
|
||||
'tapir': '🐗',
|
||||
'tiger': '🐯',
|
||||
'toad': '🐸',
|
||||
'trout': '🐟',
|
||||
'tuna': '🐟',
|
||||
'turtle': '🐢',
|
||||
'viper': '🐍',
|
||||
'vulture': '🦅',
|
||||
'weasel': '🦦',
|
||||
'whale': '🐋',
|
||||
'wolf': '🐺',
|
||||
'wraith': '👻',
|
||||
'zebra': '🦓',
|
||||
'ape': '🦍',
|
||||
'ant': '🐜',
|
||||
'bat': '🦇',
|
||||
'bee': '🐝',
|
||||
'bug': '🐛',
|
||||
'cat': '🐱',
|
||||
'cow': '🐮',
|
||||
'crab': '🦀',
|
||||
'dog': '🐶',
|
||||
'duck': '🦆',
|
||||
'elk': '🦌',
|
||||
'fly': '🪰',
|
||||
'fox': '🦊',
|
||||
'frog': '🐸',
|
||||
'goat': '🐐',
|
||||
'hawk': '🦅',
|
||||
'hen': '🐔',
|
||||
'hog': '🐷',
|
||||
'lion': '🦁',
|
||||
'mole': '🐭',
|
||||
'moth': '🦋',
|
||||
'mule': '🫏',
|
||||
'owl': '🦉',
|
||||
'pig': '🐷',
|
||||
'ram': '🐏',
|
||||
'rat': '🐀',
|
||||
'seal': '🦭',
|
||||
'shark': '🦈',
|
||||
'wasp': '🐝',
|
||||
'yak': '🐂',
|
||||
'doe': '🦌',
|
||||
'ewe': '🐑',
|
||||
'buck': '🦌',
|
||||
'ox': '🐂',
|
||||
'bull': '🐂',
|
||||
'dodo': '🦤',
|
||||
'boar': '🐗',
|
||||
'bear': '🐻',
|
||||
'deer': '🦌',
|
||||
'dove': '🕊️',
|
||||
'fish': '🐟',
|
||||
'hare': '🐰',
|
||||
'horse': '🐴',
|
||||
'lamb': '🐑',
|
||||
'mare': '🐴',
|
||||
'pony': '🐴',
|
||||
'pup': '🐶',
|
||||
'croc': '🐊',
|
||||
'gnat': '🦟',
|
||||
'gnu': '🦬',
|
||||
};
|
||||
|
||||
export function getAvatarForName(username) {
|
||||
if (!username) return '\u{1F464}';
|
||||
const lower = username.toLowerCase();
|
||||
const sorted = Object.entries(ANIMAL_EMOJI_MAP).sort((a, b) => b[0].length - a[0].length);
|
||||
for (const [key, emoji] of sorted) {
|
||||
if (lower.includes(key)) return emoji;
|
||||
}
|
||||
return '\u{1F464}';
|
||||
}
|
||||
|
||||
export function generateUsername() {
|
||||
const adj = USERNAME_ADJECTIVES[Math.floor(Math.random() * USERNAME_ADJECTIVES.length)];
|
||||
const noun = USERNAME_NOUNS[Math.floor(Math.random() * USERNAME_NOUNS.length)];
|
||||
return `${adj}${noun}`;
|
||||
}
|
||||
@@ -34,8 +34,17 @@ sync.koalastuff.net {
|
||||
file_server
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -164,7 +195,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const isDE = document.documentElement.classList.contains('lang-de');
|
||||
title.textContent = isDE ? 'Erfolgreich!' : 'Success!';
|
||||
|
||||
let count = 3;
|
||||
let count = 2;
|
||||
const updateCountdown = () => {
|
||||
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>`
|
||||
@@ -195,8 +226,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (banner) {
|
||||
if (success) {
|
||||
banner.style.background = 'var(--success)';
|
||||
banner.innerHTML = '<div class="container">✅ Joined! This tab will close in 3s...</div>';
|
||||
setTimeout(() => window.close(), 3000);
|
||||
banner.innerHTML = '<div class="container">✅ Joined! This tab will close in 2s...</div>';
|
||||
setTimeout(() => window.close(), 2000);
|
||||
} else {
|
||||
banner.style.background = 'var(--error)';
|
||||
banner.innerHTML = '';
|
||||
@@ -209,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 |
@@ -7,26 +7,7 @@
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="icon" type="image/png" href="assets/logo.png">
|
||||
<meta name="robots" content="noindex">
|
||||
<script>
|
||||
(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;
|
||||
})();
|
||||
|
||||
function toggleLanguage(e) {
|
||||
if(e) e.preventDefault();
|
||||
var html = document.documentElement;
|
||||
var currentIsEnglish = html.classList.contains('lang-en');
|
||||
var newLang = currentIsEnglish ? 'de' : 'en';
|
||||
html.classList.remove('lang-en', 'lang-de');
|
||||
html.classList.add('lang-' + newLang);
|
||||
html.lang = newLang;
|
||||
localStorage.setItem('koala_lang', newLang);
|
||||
}
|
||||
</script>
|
||||
<script src="lang-init.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
@@ -41,11 +22,11 @@
|
||||
<img src="assets/logo.png" alt="KoalaSync Logo">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<button class="hamburger" onclick="document.querySelector('.nav-links').classList.toggle('open')" aria-label="Menu">☰</button>
|
||||
<button class="hamburger" aria-label="Menu">☰</button>
|
||||
<div class="nav-links">
|
||||
<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" onclick="toggleLanguage(event)">🌍 EN/DE</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>
|
||||
|
||||
@@ -7,26 +7,7 @@
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="icon" type="image/png" href="assets/logo.png">
|
||||
<meta name="robots" content="noindex">
|
||||
<script>
|
||||
(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;
|
||||
})();
|
||||
|
||||
function toggleLanguage(e) {
|
||||
if(e) e.preventDefault();
|
||||
var html = document.documentElement;
|
||||
var currentIsEnglish = html.classList.contains('lang-en');
|
||||
var newLang = currentIsEnglish ? 'de' : 'en';
|
||||
html.classList.remove('lang-en', 'lang-de');
|
||||
html.classList.add('lang-' + newLang);
|
||||
html.lang = newLang;
|
||||
localStorage.setItem('koala_lang', newLang);
|
||||
}
|
||||
</script>
|
||||
<script src="lang-init.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
@@ -41,11 +22,11 @@
|
||||
<img src="assets/logo.png" alt="KoalaSync Logo">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<button class="hamburger" onclick="document.querySelector('.nav-links').classList.toggle('open')" aria-label="Menu">☰</button>
|
||||
<button class="hamburger" aria-label="Menu">☰</button>
|
||||
<div class="nav-links">
|
||||
<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" onclick="toggleLanguage(event)">🌍 EN/DE</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>
|
||||
@@ -68,8 +49,8 @@
|
||||
</h2>
|
||||
<p lang="de">Administrator KoalaSync (Privatperson)</p>
|
||||
<p lang="en">Administrator KoalaSync (Private Individual)</p>
|
||||
<p lang="de">E-Mail: <span style="color: var(--accent); cursor: pointer; text-decoration: underline;" onclick="this.innerHTML='koalasync_admin' + '@' + 'koalamail.rocks'">[E-Mail anzeigen]</span></p>
|
||||
<p lang="en">E-Mail: <span style="color: var(--accent); cursor: pointer; text-decoration: underline;" onclick="this.innerHTML='koalasync_admin' + '@' + 'koalamail.rocks'">[Show Email]</span></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;">
|
||||
|
||||
@@ -3,40 +3,20 @@
|
||||
<head>
|
||||
<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.">
|
||||
<title>KoalaSync | Sync Netflix, Emby, Jellyfin & Any Video with Friends – Browser Extension</title>
|
||||
<meta name="description" content="Watch Netflix, Emby, Jellyfin, YouTube, Twitch and any HTML5 video in perfect sync with friends. KoalaSync is a privacy-first, open-source browser extension for Chrome and Firefox. Works on almost any site with a video element.">
|
||||
|
||||
<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.">
|
||||
<link rel="canonical" href="https://sync.koalastuff.net/">
|
||||
<meta name="robots" content="index, follow">
|
||||
<meta property="og:title" content="KoalaSync | Sync Netflix, Emby, Jellyfin & Any Video with Friends">
|
||||
<meta property="og:description" content="Watch Netflix, Emby, Jellyfin, YouTube, Twitch and any HTML5 video in perfect sync. Privacy-first, open-source browser extension for Chrome & Firefox.">
|
||||
<meta property="og:image" content="https://sync.koalastuff.net/assets/logo.png">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://sync.koalastuff.net/">
|
||||
|
||||
<script>
|
||||
(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;
|
||||
var titles = { en: 'KoalaSync | Real-time Video Synchronization for Friends', de: 'KoalaSync | Echtzeit-Video-Synchronisation für Freunde' };
|
||||
document.title = titles[activeLang] || titles.en;
|
||||
})();
|
||||
|
||||
function toggleLanguage(e) {
|
||||
if(e) e.preventDefault();
|
||||
var html = document.documentElement;
|
||||
var currentIsEnglish = html.classList.contains('lang-en');
|
||||
var newLang = currentIsEnglish ? 'de' : 'en';
|
||||
html.classList.remove('lang-en', 'lang-de');
|
||||
html.classList.add('lang-' + newLang);
|
||||
html.lang = newLang;
|
||||
localStorage.setItem('koala_lang', newLang);
|
||||
var titles = { en: 'KoalaSync | Real-time Video Synchronization for Friends', de: 'KoalaSync | Echtzeit-Video-Synchronisation für Freunde' };
|
||||
document.title = titles[newLang] || titles.en;
|
||||
}
|
||||
</script>
|
||||
<script src="lang-init.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -52,12 +32,18 @@
|
||||
<img src="assets/logo.png" alt="KoalaSync Logo">
|
||||
<span>KoalaSync</span>
|
||||
</div>
|
||||
<button class="hamburger" onclick="document.querySelector('.nav-links').classList.toggle('open')" aria-label="Menu">☰</button>
|
||||
<button class="hamburger" aria-label="Menu">☰</button>
|
||||
<div class="nav-links">
|
||||
<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">GitHub</a>
|
||||
<a class="lang-toggle" onclick="toggleLanguage(event)">🌍 EN/DE</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>
|
||||
@@ -65,29 +51,197 @@
|
||||
<header class="hero">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-text">
|
||||
<div class="version-badge" data-reveal><span lang="en">v1.3.1 OUT NOW</span><span lang="de">v1.3.1 JETZT VERFÜGBAR</span></div>
|
||||
<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">The ultimate free watch party extension for YouTube, Twitch, and local MP4s.</span>
|
||||
<span lang="de">Die ultimative, kostenlose Watch-Party-Erweiterung für YouTube, Twitch und lokale MP4s.</span>
|
||||
<span lang="en">Your remote movie night without lags. No registration, no data collection. Just share a link and watch together.</span>
|
||||
<span lang="de">Dein Kino-Abend auf Distanz. Keine Lags, keine Anmeldung. Einfach Link teilen und zusammen schauen.</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)">
|
||||
<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="#" class="btn btn-primary" style="background: #e66000;">
|
||||
<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" class="btn btn-secondary">
|
||||
GitHub Repo
|
||||
<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;"><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">Select Video</span><span lang="de">Video auswählen</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">⚡ Sync All Viewers</span><span lang="de">⚡ Alle synchronisieren</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">Show Video Tabs Only</span><span lang="de">Nur Video-Tabs anzeigen</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>
|
||||
</header>
|
||||
|
||||
@@ -97,33 +251,64 @@
|
||||
<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 performance, privacy, and simplicity.</span><span lang="de">Entwickelt für Leistung, Privatsphäre und Einfachheit.</span>
|
||||
<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><span lang="en">Real-time Sync</span><span lang="de">Echtzeit-Synchronisation</span></h3>
|
||||
<p lang="en">Proprietary two-phase synchronization protocol ensures sub-millisecond precision across all peers.</p>
|
||||
<p lang="de">Das proprietäre Zwei-Phasen-Synchronisationsprotokoll gewährleistet Sub-Millisekunden-Präzision für alle Teilnehmer.</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><span lang="en">Episode Auto-Sync</span><span lang="de">Automatische Episoden-Synchronisation</span></h3>
|
||||
<p lang="en">New in v1.3.1: Perfectly sync series binges. All peers wait until everyone has loaded the next episode.</p>
|
||||
<p lang="de">Neu in v1.3.1: Perfekt synchronisiertes Serien-Bingeing. Alle warten, bis jeder die nächste Episode geladen hat.</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><span lang="en">Privacy First</span><span lang="de">Privatsphäre zuerst</span></h3>
|
||||
<p lang="en">Zero data persistence. Our relay server runs entirely in RAM and collects no telemetry or logs.</p>
|
||||
<p lang="de">Keine Datenspeicherung. Unser Relay-Server läuft komplett im RAM und sammelt weder Telemetrie noch 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, no bullshit. Just share a link and watch. The server runs entirely in RAM and purges your room when you leave.</p>
|
||||
<p lang="de">Kein Login. Keine Daten. Einfach Link teilen und schauen. Der Server läuft flüchtig im RAM und löscht deinen Raum komplett nach dem Verlassen.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-reveal>
|
||||
<div class="feature-icon">⭐</div>
|
||||
<h3><span lang="en">Smart Match</span><span lang="de">Intelligente Zuordnung</span></h3>
|
||||
<p lang="en">Find the right tab instantly. KoalaSync highlights and sorts matching video tabs for you.</p>
|
||||
<p lang="de">Finden Sie sofort den richtigen Tab. KoalaSync markiert und sortiert passende Video-Tabs für Sie.</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">Our official servers are free and fast, but you <strong>can</strong> take full ownership if you want to. Launch your own private relay server in seconds via Docker.</p>
|
||||
<p lang="de">Unsere Server sind kostenlos und schnell, aber du <strong>kannst</strong> auch die volle Kontrolle übernehmen, wenn du willst. Starte dein eigenes privates Relay in Sekunden via Docker.</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>
|
||||
|
||||
@@ -141,10 +326,28 @@
|
||||
<div class="step-text" data-reveal>
|
||||
<div class="step-num">01</div>
|
||||
<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 or download the latest developer ZIP from GitHub.</p>
|
||||
<p lang="de">Fügen Sie KoalaSync aus dem Chrome Web Store zu Ihrem Browser hinzu oder laden Sie die neueste Entwickler-ZIP von GitHub herunter.</p>
|
||||
<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>
|
||||
@@ -153,16 +356,126 @@
|
||||
<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>
|
||||
<div style="background: var(--card); height: 200px; border-radius: 20px; border: 1px solid var(--glass-border);"></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>
|
||||
<div class="step">
|
||||
<div class="step-text" data-reveal>
|
||||
<div class="step-num">03</div>
|
||||
<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 perfectly synced 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 perfekt synchronisierte Wiedergabe.</p>
|
||||
<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;">
|
||||
📦 <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>
|
||||
@@ -172,7 +485,7 @@
|
||||
<div class="container">
|
||||
<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">Join thousands of users watching together.</span><span lang="de">Schließen Sie sich Tausenden von Nutzern an, die bereits zusammen schauen.</span>
|
||||
<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>
|
||||
|
||||
@@ -7,30 +7,7 @@
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="icon" type="image/png" href="assets/logo.png">
|
||||
<meta name="robots" content="noindex">
|
||||
<script>
|
||||
(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;
|
||||
var titles = { en: 'Join Room | KoalaSync', de: 'Raum beitreten | KoalaSync' };
|
||||
document.title = titles[activeLang] || titles.en;
|
||||
})();
|
||||
|
||||
function toggleLanguage(e) {
|
||||
if(e) e.preventDefault();
|
||||
var html = document.documentElement;
|
||||
var currentIsEnglish = html.classList.contains('lang-en');
|
||||
var newLang = currentIsEnglish ? 'de' : 'en';
|
||||
html.classList.remove('lang-en', 'lang-de');
|
||||
html.classList.add('lang-' + newLang);
|
||||
html.lang = newLang;
|
||||
localStorage.setItem('koala_lang', newLang);
|
||||
var titles = { en: 'Join Room | KoalaSync', de: 'Raum beitreten | KoalaSync' };
|
||||
document.title = titles[newLang] || titles.en;
|
||||
}
|
||||
</script>
|
||||
<script src="lang-init.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
@@ -45,11 +22,17 @@
|
||||
<img src="assets/logo.png" alt="KoalaSync Logo">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<button class="hamburger" onclick="document.querySelector('.nav-links').classList.toggle('open')" aria-label="Menu">☰</button>
|
||||
<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 class="lang-toggle" onclick="toggleLanguage(event)">🌍 EN/DE</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>
|
||||
@@ -77,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;
|
||||
}
|
||||
})();
|
||||
@@ -1,4 +1,6 @@
|
||||
# KoalaSync Website — Allow all crawlers, full indexing
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# Sitemap for search engines
|
||||
Sitemap: https://sync.koalastuff.net/sitemap.xml
|
||||
|
||||
@@ -156,43 +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, .hero-subtitle {
|
||||
font-size: 1.5rem;
|
||||
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;
|
||||
@@ -200,6 +202,7 @@ nav {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@@ -213,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);
|
||||
@@ -228,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);
|
||||
@@ -238,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;
|
||||
}
|
||||
@@ -256,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;
|
||||
@@ -274,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;
|
||||
}
|
||||
|
||||
@@ -304,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 --- */
|
||||
@@ -331,6 +1091,7 @@ footer {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
|
||||
/* --- Legal Pages --- */
|
||||
.legal-content {
|
||||
max-width: 800px;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": "1.8.10",
|
||||
"date": "2026-05-26T15:42:24Z"
|
||||
}
|
||||