mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+17
-2
@@ -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,16 +94,31 @@ 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. **MANDATORY SYNTAX CHECK**: Before staging, committing, or pushing any changes, you **MUST** run a syntax validation check using `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.
|
||||
> - **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`.
|
||||
2. Run the build script (`node scripts/build-extension.js`).
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
+1
-2
@@ -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 @@
|
||||
# <img src="extension/icons/icon128.png" width="32" valign="middle"> 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
|
||||
|
||||
+14
-7
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
+1
-1
@@ -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:
|
||||
|
||||
+204
-111
@@ -1,13 +1,10 @@
|
||||
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, APP_VERSION, EPISODE_LOBBY_TIMEOUT } from './shared/constants.js';
|
||||
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, APP_VERSION, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT } from './shared/constants.js';
|
||||
|
||||
// --- State Management ---
|
||||
let socket = null;
|
||||
let reconnectDelay = 1000;
|
||||
const MAX_RECONNECT_DELAY = 30000;
|
||||
let isConnecting = false;
|
||||
let peerId = null; // initialized via getPeerId()
|
||||
let currentRoom = null;
|
||||
let lastPeersJson = null;
|
||||
let currentTabId = null;
|
||||
let currentTabTitle = null; // New: for Smart Matching
|
||||
let logs = [];
|
||||
@@ -18,7 +15,6 @@ let pendingHistory = [];
|
||||
let eventQueue = [];
|
||||
let isNamespaceJoined = false;
|
||||
let lastActionState = { action: null, senderId: null, timestamp: 0, acks: [] };
|
||||
let currentCommandSenderId = null; // Track who sent the last command we are executing
|
||||
|
||||
// --- Boot Sequence Lock ---
|
||||
let restorationTask = null;
|
||||
@@ -26,12 +22,22 @@ let restorationTask = null;
|
||||
function ensureState() {
|
||||
if (!restorationTask) {
|
||||
restorationTask = new Promise(resolve => {
|
||||
let resolved = false;
|
||||
const done = () => { if (!resolved) { resolved = true; resolve(); } };
|
||||
|
||||
const storageTimeout = setTimeout(() => {
|
||||
addLog('Storage restoration timed out, continuing with defaults', 'warn');
|
||||
storageInitialized = true;
|
||||
done();
|
||||
}, 10000);
|
||||
|
||||
chrome.storage.session.get([
|
||||
'logs', 'history', 'currentRoom', 'lastActionState',
|
||||
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
|
||||
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'currentTabId', 'currentTabTitle',
|
||||
'episodeLobby'
|
||||
], (data) => {
|
||||
clearTimeout(storageTimeout);
|
||||
if (data.currentTabId !== undefined) currentTabId = data.currentTabId;
|
||||
if (data.currentTabTitle !== undefined) currentTabTitle = data.currentTabTitle;
|
||||
// Merge data from storage with any early-arriving state
|
||||
@@ -92,7 +98,7 @@ function ensureState() {
|
||||
pendingHistory = [];
|
||||
}
|
||||
|
||||
resolve();
|
||||
done();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -103,8 +109,12 @@ function ensureState() {
|
||||
ensureState();
|
||||
|
||||
let reconnectTimer = null;
|
||||
let reconnectStartTime = null; // New: track when reconnection started
|
||||
let reconnectFailed = false; // New: true if we hit the 5-min cap
|
||||
let reconnectStartTime = null;
|
||||
let reconnectFailed = false;
|
||||
let reconnectAttempts = 0;
|
||||
const MAX_RECONNECT_ATTEMPTS = 20;
|
||||
const _RECONNECT_BASE_DELAY = 500;
|
||||
const _RECONNECT_MAX_DELAY = 5000;
|
||||
|
||||
// Force Sync Coordination
|
||||
let isForceSyncInitiator = false;
|
||||
@@ -143,7 +153,7 @@ function createPeerData(raw) {
|
||||
*/
|
||||
function updateLocalPeerState(targetPeerId, updates) {
|
||||
if (!currentRoom || !Array.isArray(currentRoom.peers)) return;
|
||||
const peer = currentRoom.peers.find(p => (p.peerId || p) === targetPeerId);
|
||||
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) {
|
||||
@@ -168,15 +178,35 @@ async function getPeerId() {
|
||||
}
|
||||
|
||||
async function getSettings() {
|
||||
return new Promise(resolve => {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username'], (data) => {
|
||||
resolve({
|
||||
serverUrl: data.serverUrl || '',
|
||||
useCustomServer: data.useCustomServer || false,
|
||||
roomId: data.roomId || '',
|
||||
password: data.password || '',
|
||||
username: data.username || ''
|
||||
});
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
}
|
||||
let username = data.username;
|
||||
if (!username) {
|
||||
const adjs = ['Happy', 'Cool', 'Fast', 'Smart', 'Brave', 'Calm', 'Sneaky', 'Lazy', 'Wild', 'Chill', 'Lucky', 'Epic', 'Swift', 'Bold', 'Mighty', 'Cosmic', 'Neon', 'Shadow', 'Crystal', 'Thunder', 'Silent', 'Golden', 'Fierce', 'Noble', 'Mystic', 'Frozen', 'Blazing', 'Sapphire', 'Iron', 'Crimson'];
|
||||
const nouns = ['Koala', 'Panda', 'Tiger', 'Eagle', 'Fox', 'Bear', 'Wolf', 'Lion', 'Hawk', 'Seal', 'Owl', 'Shark', 'Dragon', 'Phoenix', 'Falcon', 'Panther', 'Raven', 'Cobra', 'Lynx', 'Jaguar', 'Orca', 'Mantis', 'Viper', 'Condor', 'Badger', 'Otter', 'Rhino', 'Crane', 'Mongoose', 'Specter'];
|
||||
username = `${adjs[Math.floor(Math.random() * adjs.length)]}${nouns[Math.floor(Math.random() * nouns.length)]}`;
|
||||
chrome.storage.sync.set({ username }, () => {
|
||||
resolve({
|
||||
serverUrl: data.serverUrl || '',
|
||||
useCustomServer: data.useCustomServer || false,
|
||||
roomId: data.roomId || '',
|
||||
password: data.password || '',
|
||||
username: username
|
||||
});
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
serverUrl: data.serverUrl || '',
|
||||
useCustomServer: data.useCustomServer || false,
|
||||
roomId: data.roomId || '',
|
||||
password: data.password || '',
|
||||
username: username
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -238,7 +268,7 @@ async function connect() {
|
||||
return;
|
||||
}
|
||||
|
||||
broadcastConnectionStatus('connecting');
|
||||
broadcastConnectionStatus('reconnecting');
|
||||
const isCustomServer = settings.serverUrl && settings.useCustomServer;
|
||||
finalUrl = isCustomServer ? settings.serverUrl : OFFICIAL_SERVER_URL;
|
||||
|
||||
@@ -261,7 +291,7 @@ async function connect() {
|
||||
throw new Error(`[URL Error] ${e.message}`);
|
||||
}
|
||||
|
||||
addLog(`Connecting to ${isCustomServer ? finalUrl : 'Official Server'}...`, 'info');
|
||||
addLog(`Connecting to ${isCustomServer ? finalUrl : 'Official Server'}... (attempt ${reconnectAttempts + 1})`, 'info');
|
||||
|
||||
// --- Phase 4: WebSocket Init ---
|
||||
try {
|
||||
@@ -279,10 +309,11 @@ async function connect() {
|
||||
|
||||
// --- Phase 5: Event Listeners ---
|
||||
socket.onopen = () => {
|
||||
reconnectDelay = 1000;
|
||||
reconnectAttempts = 0;
|
||||
addLog('WebSocket Connection Opened', 'success');
|
||||
reconnectStartTime = null;
|
||||
reconnectFailed = false;
|
||||
chrome.storage.session.set({ reconnectFailed: false });
|
||||
isNamespaceJoined = false;
|
||||
socket.send('40');
|
||||
};
|
||||
@@ -321,8 +352,12 @@ async function connect() {
|
||||
} else if (msg.startsWith('42')) {
|
||||
try {
|
||||
const payload = JSON.parse(msg.substring(2));
|
||||
handleServerEvent(payload[0], payload[1]);
|
||||
} catch (e) {
|
||||
try {
|
||||
handleServerEvent(payload[0], payload[1]);
|
||||
} catch (handlerErr) {
|
||||
addLog(`Handler error for ${payload[0]}: ${handlerErr.message}`, 'error');
|
||||
}
|
||||
} catch (_e) {
|
||||
addLog(`Failed to parse message: ${msg}`, 'error');
|
||||
}
|
||||
}
|
||||
@@ -332,7 +367,6 @@ async function connect() {
|
||||
isConnecting = false;
|
||||
isNamespaceJoined = false;
|
||||
|
||||
// Clear Force Sync state
|
||||
isForceSyncInitiator = false;
|
||||
forceSyncAcks.clear();
|
||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||
@@ -341,20 +375,22 @@ async function connect() {
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null
|
||||
});
|
||||
|
||||
|
||||
if (currentRoom) {
|
||||
currentRoom.peers = [];
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
}
|
||||
broadcastConnectionStatus('disconnected');
|
||||
addLog(`Disconnected. Retrying in ${reconnectDelay / 1000}s...`, 'warn');
|
||||
addLog('Disconnected. Scheduling reconnect...', 'warn');
|
||||
socket = null;
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
socket.onerror = (err) => {
|
||||
socket.onerror = () => {
|
||||
broadcastConnectionStatus('disconnected');
|
||||
addLog(`WebSocket Error: ${err.message || 'Handshake failed or server unreachable'}`, 'error');
|
||||
socket.close();
|
||||
addLog('WebSocket Error: Connection failed', 'error');
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
@@ -373,11 +409,15 @@ function broadcastConnectionStatus(status) {
|
||||
|
||||
function updateBadgeStatus() {
|
||||
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
|
||||
const status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : 'disconnected');
|
||||
const isReconnecting = !isConnected && reconnectAttempts > 0 && !reconnectFailed;
|
||||
const status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
|
||||
|
||||
if (reconnectFailed) {
|
||||
chrome.action.setBadgeText({ text: 'ERR' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#ef4444' });
|
||||
} else if (status === 'reconnecting') {
|
||||
chrome.action.setBadgeText({ text: '...' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#f59e0b' });
|
||||
} else if (status === 'connecting') {
|
||||
chrome.action.setBadgeText({ text: '...' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#fbbf24' });
|
||||
@@ -390,48 +430,73 @@ function updateBadgeStatus() {
|
||||
}
|
||||
|
||||
function showNotification(senderName, action) {
|
||||
const label = action === 'play' ? 'started playback' :
|
||||
action === 'pause' ? 'paused playback' :
|
||||
action === 'seek' ? 'seeked the video' :
|
||||
action === 'force_sync_execute' ? 'synchronized everyone' : action;
|
||||
|
||||
// Find username in current room if available
|
||||
let displayName = senderName || 'A peer';
|
||||
if (currentRoom && currentRoom.peers) {
|
||||
const peer = currentRoom.peers.find(p => (p.peerId || p) === senderName);
|
||||
if (peer && peer.username) displayName = peer.username;
|
||||
}
|
||||
chrome.storage.sync.get(['browserNotifications'], (settings) => {
|
||||
if (!settings.browserNotifications) return;
|
||||
|
||||
chrome.notifications.create(`sync_${Date.now()}`, {
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon128.png',
|
||||
title: 'KoalaSync',
|
||||
message: `${displayName} ${label}.`,
|
||||
priority: 1
|
||||
const label = action === 'play' ? 'started playback' :
|
||||
action === 'pause' ? 'paused playback' :
|
||||
action === 'seek' ? 'seeked the video' :
|
||||
action === 'force_sync_prepare' ? 'started force sync' :
|
||||
action === 'force_sync_execute' ? 'synchronized everyone' : action;
|
||||
|
||||
let displayName = senderName || 'A peer';
|
||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||
const peer = currentRoom.peers.find(p => (p.peerId || p) === senderName);
|
||||
if (peer && peer.username) displayName = peer.username;
|
||||
}
|
||||
|
||||
chrome.notifications.create(`sync_${Date.now()}`, {
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon128.png',
|
||||
title: 'KoalaSync',
|
||||
message: `${displayName} ${label}.`,
|
||||
priority: 1
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer || reconnectFailed) return;
|
||||
|
||||
if (reconnectTimer) return;
|
||||
|
||||
if (reconnectFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reconnectStartTime) reconnectStartTime = Date.now();
|
||||
|
||||
// Check 5 minute cap (300,000ms)
|
||||
if (Date.now() - reconnectStartTime > 300000) {
|
||||
reconnectFailed = true;
|
||||
chrome.storage.session.set({ reconnectFailed: true });
|
||||
addLog('Reconnection failed after 5 minutes. Please try again manually.', 'error');
|
||||
addLog('Reconnection failed after 5 minutes.', 'error');
|
||||
broadcastConnectionStatus('reconnect_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
reconnectAttempts++;
|
||||
|
||||
// Cap at max attempts to prevent infinite loops
|
||||
if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnectFailed = true;
|
||||
chrome.storage.session.set({ reconnectFailed: true });
|
||||
addLog('Reconnection failed after max attempts.', 'error');
|
||||
broadcastConnectionStatus('reconnect_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Aggressive reconnect: 500ms base, cap at 5s, no exponential growth beyond that
|
||||
const delay = Math.min(_RECONNECT_BASE_DELAY * Math.pow(1.5, reconnectAttempts - 1), _RECONNECT_MAX_DELAY);
|
||||
|
||||
addLog(`Reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`, 'warn');
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// Slow reconnect logic is now handled in the keepAlive alarm
|
||||
|
||||
function emit(event, data) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
|
||||
const msg = `42${JSON.stringify([event, data])}`;
|
||||
@@ -467,8 +532,13 @@ function handleServerEvent(event, data) {
|
||||
switch (event) {
|
||||
case EVENTS.ROOM_DATA:
|
||||
currentRoom = data;
|
||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||
currentRoom.peers = currentRoom.peers.map(p => typeof p === 'object' ? createPeerData(p) : { peerId: p, username: null, tabTitle: null, mediaTitle: null, playbackState: null, currentTime: null, volume: null, muted: null, lastHeartbeat: Date.now() });
|
||||
} else if (currentRoom) {
|
||||
currentRoom.peers = [];
|
||||
}
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
addLog(`Joined Room: ${data.roomId}`, 'success');
|
||||
addLog(`Joined Room: ${data?.roomId || 'unknown'}`, 'success');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: data.peers }).catch(() => {});
|
||||
|
||||
// Inform Website Bridge & Popup
|
||||
@@ -544,28 +614,27 @@ function handleServerEvent(event, data) {
|
||||
}
|
||||
|
||||
// Check if all peers responded
|
||||
const peerCount = currentRoom ? currentRoom.peers.length : 1;
|
||||
const peerCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1;
|
||||
if (forceSyncAcks.size >= peerCount) {
|
||||
executeForceSync();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case EVENTS.FORCE_SYNC_EXECUTE:
|
||||
if (data.senderId) {
|
||||
if (data?.senderId) {
|
||||
addToHistory(event, data.senderId);
|
||||
showNotification(data.senderId, event);
|
||||
|
||||
// Force Sync Execute Remote Reactive Update
|
||||
updateLocalPeerState(data.senderId, {
|
||||
playbackState: 'playing'
|
||||
});
|
||||
// (The sender's state is updated below with everyone else)
|
||||
}
|
||||
|
||||
// Reset reactive update locks for all peers so the next playing heartbeat is accepted immediately
|
||||
// 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.lastReactiveUpdate = 0;
|
||||
peer.playbackState = 'playing';
|
||||
peer.lastReactiveUpdate = Date.now();
|
||||
}
|
||||
});
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
@@ -575,7 +644,7 @@ function handleServerEvent(event, data) {
|
||||
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 = [];
|
||||
@@ -611,20 +680,18 @@ 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;
|
||||
@@ -633,10 +700,8 @@ function handleServerEvent(event, data) {
|
||||
peer.volume = data.volume !== undefined ? data.volume : peer.volume;
|
||||
peer.muted = data.muted !== undefined ? data.muted : peer.muted;
|
||||
|
||||
// Race condition guard: ignore heartbeat playbackState/currentTime
|
||||
// if we applied a reactive user action in the last 1.5 seconds.
|
||||
const timeSinceReactive = peer.lastReactiveUpdate ? (Date.now() - peer.lastReactiveUpdate) : Infinity;
|
||||
const ignoreStatus = timeSinceReactive < 1500;
|
||||
const ignoreStatus = timeSinceReactive < 1000;
|
||||
|
||||
if (!ignoreStatus) {
|
||||
peer.playbackState = data.playbackState !== undefined ? data.playbackState : peer.playbackState;
|
||||
@@ -715,19 +780,23 @@ function executeForceSync() {
|
||||
forceSyncDeadline: null
|
||||
});
|
||||
|
||||
// Reset reactive update locks for all peers so the next playing heartbeat is accepted immediately
|
||||
// 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.lastReactiveUpdate = 0;
|
||||
peer.playbackState = 'playing';
|
||||
peer.lastReactiveUpdate = Date.now();
|
||||
}
|
||||
});
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
}
|
||||
|
||||
emit(EVENTS.FORCE_SYNC_EXECUTE, {});
|
||||
routeToContent(EVENTS.FORCE_SYNC_EXECUTE, {});
|
||||
const executionTimestamp = Date.now();
|
||||
updateLastAction(EVENTS.FORCE_SYNC_EXECUTE, 'You', executionTimestamp);
|
||||
|
||||
emit(EVENTS.FORCE_SYNC_EXECUTE, { actionTimestamp: executionTimestamp });
|
||||
routeToContent(EVENTS.FORCE_SYNC_EXECUTE, { actionTimestamp: executionTimestamp });
|
||||
addLog('Force Sync Executed', 'success');
|
||||
}
|
||||
|
||||
@@ -778,10 +847,13 @@ function executeEpisodeLobby() {
|
||||
clearEpisodeLobbyState();
|
||||
addLog(`Episode lobby complete: Starting "${title}" via Force Sync`, 'success');
|
||||
|
||||
// Trigger a standard Force Sync at targetTime 0.0
|
||||
isForceSyncInitiator = true;
|
||||
forceSyncAcks.clear();
|
||||
const deadline = Date.now() + 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: [],
|
||||
@@ -789,15 +861,15 @@ function executeEpisodeLobby() {
|
||||
});
|
||||
|
||||
const syncPayload = { targetTime: 0.0 };
|
||||
emit(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, peerId });
|
||||
routeToContent(EVENTS.FORCE_SYNC_PREPARE, syncPayload);
|
||||
emit(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, peerId, actionTimestamp: timestamp });
|
||||
routeToContent(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, actionTimestamp: timestamp });
|
||||
|
||||
forceSyncTimeout = setTimeout(() => {
|
||||
if (isForceSyncInitiator) {
|
||||
addLog('Force Sync (Episode): Timeout waiting for ACKs, executing anyway...', 'warn');
|
||||
executeForceSync();
|
||||
}
|
||||
}, 8500);
|
||||
}, FORCE_SYNC_TIMEOUT);
|
||||
}
|
||||
|
||||
function checkEpisodeLobbyCompletion() {
|
||||
@@ -810,6 +882,7 @@ function checkEpisodeLobbyCompletion() {
|
||||
|
||||
function checkEpisodeLobbyPeerDeparture() {
|
||||
if (!episodeLobby || !currentRoom) return;
|
||||
if (!Array.isArray(currentRoom.peers)) return;
|
||||
const remainingPeerIds = currentRoom.peers.map(p => typeof p === 'object' ? p.peerId : p);
|
||||
|
||||
// If only we remain, cancel the lobby
|
||||
@@ -844,23 +917,23 @@ async function routeToContent(action, payload) {
|
||||
const tabId = parseInt(currentTabId);
|
||||
if (isNaN(tabId)) return;
|
||||
|
||||
currentCommandSenderId = payload.senderId || null;
|
||||
const actionTimestamp = payload.actionTimestamp || Date.now();
|
||||
const actionTimestamp = payload?.actionTimestamp || Date.now();
|
||||
const commandSenderId = payload?.senderId || null;
|
||||
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
type: 'SERVER_COMMAND',
|
||||
action,
|
||||
payload,
|
||||
actionTimestamp
|
||||
actionTimestamp,
|
||||
commandSenderId
|
||||
}).catch(err => {
|
||||
// Auto-Reinject if content script is missing or extension was reloaded
|
||||
if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['content.js']
|
||||
}).then(() => {
|
||||
setTimeout(() => routeToContent(action, payload), 500);
|
||||
}).catch(err => {
|
||||
}).catch(_err => {
|
||||
addLog(`Auto-reinject failed for tab ${tabId}`, 'warn');
|
||||
});
|
||||
} else {
|
||||
@@ -872,13 +945,15 @@ async function routeToContent(action, payload) {
|
||||
}
|
||||
|
||||
// --- Keep-Alive Mechanism ---
|
||||
chrome.alarms.create('keepAlive', { periodInMinutes: 1 });
|
||||
chrome.alarms.create('keepAlive', { periodInMinutes: 0.5 });
|
||||
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
await ensureState();
|
||||
if (alarm.name === 'keepAlive') {
|
||||
chrome.storage.session.get('keepAlive', () => {});
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
connect();
|
||||
if (!reconnectFailed) {
|
||||
connect();
|
||||
}
|
||||
} else if (currentRoom) {
|
||||
// Heartbeat Logic: Always include identity metadata
|
||||
const settings = await getSettings();
|
||||
@@ -896,11 +971,7 @@ 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) {
|
||||
try {
|
||||
socket.send(`42${JSON.stringify([EVENTS.LEAVE_ROOM, { peerId }])}`);
|
||||
} catch (e) {
|
||||
addLog('Failed to send leave room packet during transition', 'error');
|
||||
}
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
}
|
||||
currentRoom = null;
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom: null });
|
||||
@@ -915,6 +986,9 @@ function leaveOldRoomIfSwitching(newRoomId) {
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null
|
||||
});
|
||||
|
||||
// Cancel any active episode lobby
|
||||
clearEpisodeLobbyState();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -930,12 +1004,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
if (message.type === 'CONNECT') {
|
||||
reconnectFailed = false;
|
||||
reconnectStartTime = null;
|
||||
reconnectAttempts = 0;
|
||||
chrome.storage.session.set({ reconnectFailed: false });
|
||||
const settings = await getSettings();
|
||||
if (settings.roomId) {
|
||||
leaveOldRoomIfSwitching(settings.roomId);
|
||||
}
|
||||
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
|
||||
// Already connected, but maybe room changed or we need to refresh room state
|
||||
if (settings.roomId) {
|
||||
emit(EVENTS.JOIN_ROOM, {
|
||||
roomId: settings.roomId,
|
||||
@@ -953,12 +1028,18 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
} else if (message.type === 'RETRY_CONNECT') {
|
||||
reconnectFailed = false;
|
||||
reconnectStartTime = null;
|
||||
reconnectDelay = 1000;
|
||||
reconnectAttempts = 0;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
chrome.storage.session.set({ reconnectFailed: false });
|
||||
connect();
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'GET_STATUS') {
|
||||
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
|
||||
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : 'disconnected');
|
||||
const isReconnecting = !isConnected && reconnectAttempts > 0 && !reconnectFailed;
|
||||
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
|
||||
if (reconnectFailed) status = 'reconnect_failed';
|
||||
sendResponse({
|
||||
status,
|
||||
@@ -966,7 +1047,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
peers: currentRoom ? currentRoom.peers : [],
|
||||
lastActionState,
|
||||
targetTabId: currentTabId,
|
||||
episodeLobby: episodeLobby
|
||||
episodeLobby: episodeLobby,
|
||||
reconnectAttempts
|
||||
});
|
||||
} else if (message.type === 'LEAVE_ROOM') {
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
@@ -1000,9 +1082,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
} else if (message.type === 'GET_HISTORY') {
|
||||
sendResponse(history);
|
||||
} else if (message.type === 'GET_ROOM_LIST') {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(`42${JSON.stringify([EVENTS.GET_ROOMS])}`);
|
||||
}
|
||||
emit(EVENTS.GET_ROOMS, {});
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'WEB_JOIN_REQUEST') {
|
||||
const { roomId, password, useCustomServer, serverUrl } = message;
|
||||
@@ -1056,7 +1136,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
const processEvent = () => {
|
||||
const timestamp = Date.now();
|
||||
updateLastAction(message.action, 'You', timestamp);
|
||||
lastActionState.targetTime = message.payload.targetTime !== undefined ? message.payload.targetTime : message.payload.currentTime;
|
||||
lastActionState.targetTime = message.payload?.targetTime !== undefined ? message.payload.targetTime : message.payload?.currentTime;
|
||||
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
||||
message.payload.actionTimestamp = timestamp;
|
||||
|
||||
@@ -1069,7 +1149,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
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: [],
|
||||
@@ -1084,9 +1164,18 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
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 hasOtherPeers = currentRoom && Array.isArray(currentRoom.peers) && currentRoom.peers.length > 0;
|
||||
|
||||
if (isNonEssentialEvent && !hasOtherPeers) {
|
||||
sendResponse({ status: 'ok_solo' });
|
||||
return;
|
||||
}
|
||||
|
||||
emit(message.action, { ...message.payload, peerId });
|
||||
sendResponse({ status: 'ok' });
|
||||
};
|
||||
@@ -1121,7 +1210,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
});
|
||||
}
|
||||
|
||||
const peerCount = currentRoom ? currentRoom.peers.length : 1;
|
||||
const peerCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1;
|
||||
if (forceSyncAcks.size >= peerCount) {
|
||||
executeForceSync();
|
||||
}
|
||||
@@ -1130,11 +1219,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'CMD_ACK') {
|
||||
// Content script successfully ran a command. Send ACK back to the initiator.
|
||||
if (currentCommandSenderId && currentCommandSenderId !== peerId) {
|
||||
const commandSenderId = message.commandSenderId;
|
||||
if (commandSenderId && commandSenderId !== peerId) {
|
||||
emit(EVENTS.EVENT_ACK, {
|
||||
senderId: peerId,
|
||||
targetId: currentCommandSenderId,
|
||||
targetId: commandSenderId,
|
||||
actionTimestamp: message.actionTimestamp
|
||||
});
|
||||
}
|
||||
@@ -1157,21 +1246,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;
|
||||
@@ -1210,7 +1303,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
|
||||
// Check setting
|
||||
const epSettings = await chrome.storage.sync.get(['autoSyncNextEpisode']);
|
||||
if (!epSettings.autoSyncNextEpisode) {
|
||||
if (epSettings.autoSyncNextEpisode === false) {
|
||||
addLog(`Episode change detected ("${newTitle}") but Auto-Sync is disabled.`, 'info');
|
||||
sendResponse({ status: 'disabled' });
|
||||
return;
|
||||
@@ -1300,7 +1393,7 @@ chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
});
|
||||
|
||||
// Re-inject on full page refresh
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, _tab) => {
|
||||
if (currentTabId && tabId === parseInt(currentTabId) && changeInfo.status === 'complete') {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
|
||||
+10
-7
@@ -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 }));
|
||||
}
|
||||
});
|
||||
|
||||
+109
-37
@@ -9,7 +9,7 @@
|
||||
if (window.koalaSyncInjected && chrome.runtime.id) {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// Context invalidated, proceed with re-injection
|
||||
}
|
||||
window.koalaSyncInjected = true;
|
||||
@@ -42,15 +42,19 @@
|
||||
// --- Episode Auto-Sync State ---
|
||||
let lastKnownMediaTitle = null;
|
||||
let episodeTransitionDebounce = null;
|
||||
let pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby)
|
||||
let _pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby)
|
||||
let lobbyPollTimer = null;
|
||||
|
||||
function expectEvent(state) {
|
||||
expectedEvents.add(state);
|
||||
if (expectedTimeouts[state]) clearTimeout(expectedTimeouts[state]);
|
||||
if (expectedTimeouts[state]) {
|
||||
clearTimeout(expectedTimeouts[state]);
|
||||
delete expectedTimeouts[state];
|
||||
}
|
||||
const timeout = state === 'seek' ? 10000 : 1500;
|
||||
expectedTimeouts[state] = setTimeout(() => {
|
||||
expectedEvents.delete(state);
|
||||
delete expectedTimeouts[state];
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
@@ -59,9 +63,16 @@
|
||||
}
|
||||
|
||||
// --- Helper: find the best video element on the page ---
|
||||
function findVideo() {
|
||||
const videos = document.querySelectorAll('video');
|
||||
return videos.length > 0 ? videos[0] : null;
|
||||
function findVideo(root = document) {
|
||||
const video = root.querySelector('video');
|
||||
if (video) return video;
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
if (el.shadowRoot) {
|
||||
const found = findVideo(el.shadowRoot);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Episode Auto-Sync: Detection ---
|
||||
@@ -131,7 +142,7 @@
|
||||
|
||||
function startLobbyPoll(expectedTitle) {
|
||||
stopLobbyPoll();
|
||||
pendingLobbyTitle = expectedTitle;
|
||||
_pendingLobbyTitle = expectedTitle;
|
||||
|
||||
// NOTE: Do NOT pause here. Three callers reach this function:
|
||||
// 1. PAUSE_FOR_LOBBY (initiator): already paused by that handler before calling us.
|
||||
@@ -151,7 +162,7 @@
|
||||
|
||||
|
||||
function stopLobbyPoll() {
|
||||
pendingLobbyTitle = null;
|
||||
_pendingLobbyTitle = null;
|
||||
if (lobbyPollTimer) {
|
||||
clearInterval(lobbyPollTimer);
|
||||
lobbyPollTimer = null;
|
||||
@@ -169,7 +180,7 @@
|
||||
reportLog(`Media Action Error: Invalid seek payload - ${JSON.stringify(data)}`, 'error');
|
||||
return;
|
||||
}
|
||||
data.targetTime = target;
|
||||
data = { ...data, targetTime: target };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -223,7 +234,7 @@
|
||||
expectEvent('seek');
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e) {
|
||||
reportLog(`Media Action Error: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
@@ -259,7 +270,7 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -269,15 +280,15 @@
|
||||
|
||||
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;
|
||||
@@ -292,16 +303,18 @@
|
||||
video.pause();
|
||||
video.currentTime = payload.targetTime;
|
||||
pollSeekReady(payload.targetTime).then((ready) => {
|
||||
chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' }).catch(() => {});
|
||||
if (ready) {
|
||||
chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' });
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -397,9 +410,17 @@
|
||||
const eventState = action === EVENTS.PLAY ? 'playing' : (action === EVENTS.PAUSE ? 'paused' : (action === EVENTS.SEEK ? 'seek' : null));
|
||||
|
||||
if (eventState && expectedEvents.has(eventState)) {
|
||||
expectedEvents.delete(eventState); // Consume the match
|
||||
return; // Ignore event caused by our programmatic action
|
||||
expectedEvents.delete(eventState);
|
||||
if (expectedTimeouts[eventState]) {
|
||||
clearTimeout(expectedTimeouts[eventState]);
|
||||
delete expectedTimeouts[eventState];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 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',
|
||||
@@ -416,6 +437,38 @@
|
||||
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);
|
||||
|
||||
@@ -427,24 +480,30 @@
|
||||
const current = video.currentTime;
|
||||
if (!Number.isFinite(current)) return;
|
||||
|
||||
// Step 1: Check expectedEvents (programmatic seek suppression)
|
||||
// Step 1: Check expectedEvents (programmatic seek from remote peer — ALWAYS process)
|
||||
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)
|
||||
if (expectedTimeouts['seek']) {
|
||||
clearTimeout(expectedTimeouts['seek']);
|
||||
delete expectedTimeouts['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(() => {
|
||||
@@ -461,7 +520,7 @@
|
||||
};
|
||||
|
||||
|
||||
let lastVideoSrc = null;
|
||||
let lastVideoSrc = undefined;
|
||||
|
||||
// Episode detection handler for loadeddata event
|
||||
const handleLoadedData = () => {
|
||||
@@ -471,19 +530,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();
|
||||
}
|
||||
@@ -497,13 +559,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();
|
||||
@@ -519,7 +587,7 @@
|
||||
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;
|
||||
@@ -577,8 +645,12 @@
|
||||
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);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "KoalaSync",
|
||||
"version": "1.5.1",
|
||||
"description": "Watch party extension to synchronize video playback on YouTube, Twitch, Netflix, and HTML5 sites in real-time with friends.",
|
||||
"version": "1.8.7",
|
||||
"description": "Watch party extension to synchronize video playback on YouTube, Twitch, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
"tabs",
|
||||
|
||||
+190
-47
@@ -199,16 +199,102 @@
|
||||
}
|
||||
.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>
|
||||
<div id="toast-container"></div>
|
||||
<h1><img src="icons/icon128.png" alt="KoalaSync Logo">KoalaSync</h1>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="tab-room">Room</button>
|
||||
<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 -->
|
||||
@@ -216,47 +302,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>
|
||||
@@ -264,7 +355,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>
|
||||
@@ -276,25 +367,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 -->
|
||||
@@ -308,63 +410,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>
|
||||
|
||||
@@ -375,5 +504,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>
|
||||
|
||||
+689
-172
File diff suppressed because it is too large
Load Diff
Generated
+834
-3
@@ -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",
|
||||
@@ -153,7 +427,6 @@
|
||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
@@ -397,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",
|
||||
@@ -411,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",
|
||||
@@ -441,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",
|
||||
@@ -448,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",
|
||||
@@ -502,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",
|
||||
@@ -530,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",
|
||||
@@ -537,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",
|
||||
@@ -547,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",
|
||||
@@ -590,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",
|
||||
@@ -603,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",
|
||||
@@ -649,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",
|
||||
@@ -689,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",
|
||||
@@ -699,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",
|
||||
@@ -706,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",
|
||||
@@ -733,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",
|
||||
@@ -750,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",
|
||||
@@ -1006,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",
|
||||
@@ -1016,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",
|
||||
@@ -1039,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",
|
||||
@@ -1137,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",
|
||||
|
||||
+5
-2
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "1.5.1",
|
||||
"version": "1.8.8",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
|
||||
+193
-75
@@ -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,97 @@ 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 (!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 +401,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 +417,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) ---
|
||||
@@ -381,12 +471,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 +501,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 +544,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 +560,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 +599,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',
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = "1.0.0";
|
||||
export const APP_VERSION = "1.3.1";
|
||||
export const APP_VERSION = "1.8.6";
|
||||
|
||||
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
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
+160
-10
@@ -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 = '';
|
||||
@@ -255,6 +286,125 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 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 |
@@ -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>
|
||||
|
||||
+6
-25
@@ -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;">
|
||||
|
||||
+374
-61
@@ -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" class="version-text-en">v1.3.1 OUT NOW</span><span lang="de" class="version-text-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;" onclick="navigator.clipboard.writeText('https://sync.koalastuff.net/join.html#join:brave-eagle-80:pass')"><span lang="en">Copy</span><span lang="de">Kopieren</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mock-card" style="margin-bottom: 12px;">
|
||||
<div class="mock-label"><span lang="en">Peers in Room</span><span lang="de">Teilnehmer im Raum</span></div>
|
||||
<div class="mock-peer-list">
|
||||
<div class="mock-peer-item">
|
||||
<div class="mock-peer-header">
|
||||
<span class="mock-peer-name">CoolUsername</span>
|
||||
<span class="mock-peer-meta"><span lang="en">YOU</span><span lang="de">ICH</span></span>
|
||||
</div>
|
||||
<div class="mock-peer-tab">🎬 Germany's Next Flopmodel - S21E12</div>
|
||||
<div class="mock-peer-status"><span style="color:#22c55e;">●</span> 0:55 • <span lang="en">Playing</span><span lang="de">Wiedergabe</span></div>
|
||||
</div>
|
||||
<div class="mock-peer-item">
|
||||
<div class="mock-peer-header">
|
||||
<span class="mock-peer-name">KoalaPC</span>
|
||||
<span class="mock-peer-meta"><span lang="en">Peer</span><span lang="de">Partner</span></span>
|
||||
</div>
|
||||
<div class="mock-peer-tab">🎬 Germany's Next Flopmodel - S21E12</div>
|
||||
<div class="mock-peer-status"><span style="color:#22c55e;">●</span> 0:55 • <span lang="en">Playing</span><span lang="de">Wiedergabe</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="mock-btn" style="background:#ef4444; width:100%; border: none; padding: 10px; border-radius: 8px; color: white; font-weight: 700; cursor: pointer; border: 1px solid #ef4444;"><span lang="en">Leave Room</span><span lang="de">Raum verlassen</span></button>
|
||||
</div>
|
||||
|
||||
<!-- SYNC TAB -->
|
||||
<div id="mock-sync" class="mock-screen">
|
||||
<div class="mock-form-group" style="margin-bottom: 12px;">
|
||||
<label style="display: block; font-size: 11px; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; font-weight: 700;">
|
||||
<span lang="en">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;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.85'">
|
||||
📦 <span lang="en">View all image tags on GitHub Packages</span><span lang="de">Alle Image-Tags auf GitHub Packages ansehen</span> →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pane-caddy" class="terminal-pane">
|
||||
<pre><code><span class="t-key">sync.koalastuff.net</span> {
|
||||
<span class="t-comment"># Specify the web root inside the container</span>
|
||||
<span class="t-key">root</span> <span class="t-val">*</span> <span class="t-str">/var/www/html</span>
|
||||
|
||||
<span class="t-comment"># Enable static file server to deliver HTML, CSS, JS</span>
|
||||
<span class="t-key">file_server</span>
|
||||
}
|
||||
|
||||
<span class="t-key">syncserver.koalastuff.net</span> {
|
||||
<span class="t-key">reverse_proxy</span> <span class="t-val">KoalaSync:3000</span>
|
||||
}</code></pre>
|
||||
</div>
|
||||
<div style="background: var(--card); height: 200px; border-radius: 20px; border: 1px solid var(--glass-border);"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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>
|
||||
|
||||
+15
-31
@@ -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
|
||||
|
||||
+788
-27
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "1.5.1",
|
||||
"date": "2026-05-18T17:44:37Z"
|
||||
"version": "1.8.7",
|
||||
"date": "2026-05-26T00:06:38Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user