Compare commits
269 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81c50eff16 | |||
| bf0fb5741f | |||
| 6d2355f404 | |||
| 732d585273 | |||
| 666808f876 | |||
| 3ec9812265 | |||
| 64f3c5eefd | |||
| 3fe074b308 | |||
| a06cca8b9d | |||
| c4fc3ab53b | |||
| 3f3fea58ed | |||
| 014169f84e | |||
| 9c61abee03 | |||
| 2f11c60307 | |||
| 27301f8746 | |||
| 030b839b12 | |||
| 5c805bcafa | |||
| eb83d9148e | |||
| 290360387f | |||
| c56e404c13 | |||
| e519ea2302 | |||
| 5a75bae8d0 | |||
| fc3f4f38ff | |||
| f69da7542e | |||
| aba05060b9 | |||
| 0b986e6e13 | |||
| b23ce8ee7d | |||
| 3195bd0089 | |||
| 1c1778d265 | |||
| 0262d9c1ad | |||
| f23c329709 | |||
| 78a165d368 | |||
| 63b5ef0ffd | |||
| a26b07cb5c | |||
| a445759dd7 | |||
| 1e883d8ee4 | |||
| be574e0e25 | |||
| ebf3178e32 | |||
| ba96cf2765 | |||
| d026ed891a | |||
| d6665fe0f5 | |||
| 79025f4d18 | |||
| a1f921407c | |||
| f2669ed769 | |||
| 7a6ec8087e | |||
| 8cc622bda0 | |||
| 2b5da0dbb7 | |||
| 1a7ff6b3a7 | |||
| b1b858d771 | |||
| 608f742f83 | |||
| b2ff24e155 | |||
| 9285e1041e | |||
| 3ac110f8f5 | |||
| cc8c58fac5 | |||
| 53768980d0 | |||
| 1b04db009a | |||
| a2720da5f8 | |||
| d2c380d6fc | |||
| 1a36b138c0 | |||
| 47ead27344 | |||
| 72180ba817 | |||
| 9eab699e2a | |||
| c95e72b713 | |||
| 14be38fe11 | |||
| 1bb2123da2 | |||
| e29c6666b6 | |||
| b08e8ba06b | |||
| 30a4057c99 | |||
| fd47eb82b9 | |||
| 3fcafbd081 | |||
| d430501b82 | |||
| d738352c90 | |||
| dbf1b3e81b | |||
| 5a3f1c7019 | |||
| 57b0dd1632 | |||
| b6f7c1ccdb | |||
| 4bada9533a | |||
| df32385fa6 | |||
| ce9778245d | |||
| 5d6c0cd1fb | |||
| bbba50f643 | |||
| 56293209f8 | |||
| c55691a535 | |||
| ce3b17f55f | |||
| 5e8e9f61c2 | |||
| 59eb1a6092 | |||
| 00c9ff8cfc | |||
| 748ccaa835 | |||
| 25b23d083d | |||
| 74042c3c78 | |||
| fb1451022b | |||
| 54ffbfa4bd | |||
| 0db0b5eea2 | |||
| d58041bd4e | |||
| 1f1b99a1fe | |||
| c608d2d9e9 | |||
| 06203eb5cb | |||
| ed599a96ab | |||
| 5d1707a7d7 | |||
| f4343ca6b1 | |||
| 3644a7b8ac | |||
| c621c851af | |||
| 1113fb8bb5 | |||
| c852826091 | |||
| cfef79ec1d | |||
| 8fbada8b03 | |||
| 2261a8133a | |||
| d5241b19e8 | |||
| f7096edd30 | |||
| e2b76b05a1 | |||
| 93acd0b44c | |||
| ce7b5c47f2 | |||
| 92fb8e2d00 | |||
| 43cde9bbef | |||
| 56955027f9 | |||
| 5ef059a94f | |||
| 98b4fc5fb4 | |||
| 762d6425be | |||
| 1fba2fb69c | |||
| eca259281a | |||
| afd28be2e6 | |||
| cc0265c836 | |||
| 6ebff9ab4c | |||
| 35e779c1ff | |||
| 09f0e04891 | |||
| 9eff53ba46 | |||
| bde2f7ea55 | |||
| 901861269a | |||
| ba91e2744c | |||
| b7a44024ab | |||
| 3c49bfe54c | |||
| d2ea7c7423 | |||
| db11812bd6 | |||
| 6db8fdbf75 | |||
| d23c37f87f | |||
| acd428d4f7 | |||
| c0a6f0adc2 | |||
| 42b73bb97f | |||
| dc36bfdded | |||
| 06db850387 | |||
| 0de92b5b61 | |||
| 8ff8e7beb6 | |||
| 8317099072 | |||
| 04c63dcf68 | |||
| fd23ccc23a | |||
| 4d5caeda9e | |||
| f1f41e5cac | |||
| 473eacda22 | |||
| 42029f86bf | |||
| 4c4a2638d7 | |||
| 5b57970c4c | |||
| 61492f953b | |||
| da6a1cc643 | |||
| 62fdffa5ee | |||
| fb13978c9d | |||
| 82c09a5328 | |||
| 2fbeafeb3f | |||
| 80f8c821cb | |||
| 7d3965a9fd | |||
| 1aca6c37d4 | |||
| aa740592dd | |||
| 6f8bcf8478 | |||
| 807a620fe9 | |||
| 6e138f51d3 | |||
| ed50e354ab | |||
| 48bd503b5c | |||
| 35351bdacd | |||
| a0063d42b1 | |||
| 8b3f9e1242 | |||
| eb5515fc1b | |||
| c621685aae | |||
| b98cfc9ca1 | |||
| 6f08a9d7c4 | |||
| 440ed2db47 | |||
| ed24a4c263 | |||
| 284b82a910 | |||
| f59d30569e | |||
| f23c7eb3c8 | |||
| 9a4dd41555 | |||
| 2067f76ced | |||
| 3b65af1bbb | |||
| bb316340a7 | |||
| a3af397fe9 | |||
| 0ac2b49d89 | |||
| c9f93dc4ba | |||
| 4909a86a13 | |||
| d66c68be5d | |||
| 23d4b7068e | |||
| f40d4e8de4 | |||
| e996275c2a | |||
| dd37045afc | |||
| 92bec29215 | |||
| 552afac26a | |||
| 3c671bcfab | |||
| 18ed8953db | |||
| 967fe8872e | |||
| 0e93e31bd0 | |||
| 19ddfd1e21 | |||
| 4d85362020 | |||
| 53c1b8eea3 | |||
| 2b2aeeba00 | |||
| 5067b8e541 | |||
| 8778403449 | |||
| af87e34f5d | |||
| 385602c194 | |||
| bf0fa55b9d | |||
| f063fd5f3d | |||
| 0a555942f8 | |||
| d3f680e313 | |||
| e8203419a3 | |||
| e3536ad1a7 | |||
| aa1382b2ad | |||
| 44ee3fab25 | |||
| e9b55c72f0 | |||
| a83cdf4b03 | |||
| 3fb48ee822 | |||
| 2d20af7199 | |||
| 579677e11c | |||
| 05e1801653 | |||
| 6774b2bfb7 | |||
| 108d015e9f | |||
| e0b9e9ef27 | |||
| 641c9bfb24 | |||
| cf993b4ef0 | |||
| 5b1d6d7ba4 | |||
| 9ed7c98933 | |||
| 68b0f3306c | |||
| a71cdc3e53 | |||
| 366d157d80 | |||
| 2d231cb390 | |||
| b11b3316af | |||
| e337527e63 | |||
| 882565a079 | |||
| 4653c455d5 | |||
| 800a6c09b7 | |||
| 751496ed48 | |||
| 734a004f23 | |||
| 01ce3ec99e | |||
| d9b26f9fb4 | |||
| 1e3bb94660 | |||
| 90882f91ef | |||
| 5157428e74 | |||
| 47ca7563b7 | |||
| f7829bbebb | |||
| 6093da4dc6 | |||
| 583e15745f | |||
| bd8c7edc3a | |||
| bd4c53f9c7 | |||
| 5440d136fe | |||
| c9ec6ce3e0 | |||
| 652f1cef4f | |||
| 0f1f8bde1b | |||
| fa4e4039b3 | |||
| cb466d3865 | |||
| 54be9f9a39 | |||
| 47a9c08f48 | |||
| b0bcab77e3 | |||
| 6624bcc1ca | |||
| ca391ca83b | |||
| 1438a4d41f | |||
| c9cf7c49dc | |||
| bcbd46d658 | |||
| 4d489ec992 | |||
| 65ad4b5c6b | |||
| c2857dbdda | |||
| d07bf745a3 | |||
| bd54e893b4 | |||
| 50c9ba4ec8 | |||
| 55c2d4ed0d |
@@ -7,7 +7,5 @@ extension/
|
||||
website/
|
||||
scripts/
|
||||
*.md
|
||||
*.bat
|
||||
*.sh
|
||||
.env
|
||||
server/.env
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
about: Create a report to help us improve KoalaSync
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
> **⚠️ Required:** Before submitting, open the KoalaSync **Status** tab in the extension popup and click **"Copy Logs"**. Paste the full output below — it contains essential system info, connection state, and debug data needed to diagnose your issue.
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Copy Logs Output</b></summary>
|
||||
|
||||
<!-- Paste the copied logs here -->
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Describe the Bug
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
### To Reproduce
|
||||
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
### Expected Behavior
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
### Actual Behavior
|
||||
|
||||
A clear and concise description of what actually happened.
|
||||
|
||||
### Screenshots / Screen Recordings
|
||||
|
||||
If applicable, add screenshots or recordings to help explain your problem.
|
||||
|
||||
### Environment
|
||||
|
||||
- **Browser:** (e.g. Chrome 125, Firefox 128)
|
||||
- **Extension Version:** (visible at the bottom of the Settings tab)
|
||||
- **OS:** (e.g. Windows 11, macOS 14.5)
|
||||
- **Self-Hosted Server?:** (yes / no — if yes, provide server version)
|
||||
- **Website/Platform:** (e.g. YouTube, Netflix, Twitch, Jellyfin, Emby)
|
||||
|
||||
### Additional Context
|
||||
|
||||
Add any other context about the problem here (e.g. network setup, VPN usage, multiple monitors, etc.).
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: 🚀 Feature Request
|
||||
about: Suggest a new feature or enhancement for KoalaSync
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
### Problem Description
|
||||
|
||||
A clear and concise description of the problem or limitation you're encountering. What's missing or what could be improved?
|
||||
|
||||
*As a user, I want to ... so that ...*
|
||||
|
||||
### Proposed Solution
|
||||
|
||||
Describe the feature you'd like to see. How should it work? Be as specific as possible.
|
||||
|
||||
### Use Cases & Benefits
|
||||
|
||||
- **When would you use this?** (e.g., specific websites, workflows, setups)
|
||||
- **What value does it add?** (e.g., saves time, enables new functionality, improves UX)
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
What workarounds or alternative approaches have you tried? Are there other ways to achieve a similar result?
|
||||
|
||||
### Additional Context
|
||||
|
||||
Add any other context, sketches, mockups, or references (e.g., links to similar features in other projects).
|
||||
@@ -0,0 +1,45 @@
|
||||
<!--
|
||||
Thanks for contributing to KoalaSync!
|
||||
|
||||
Please read the CONTRIBUTING.md and CODE_OF_CONDUCT.md before submitting.
|
||||
By submitting this PR, you agree to abide by our code of conduct.
|
||||
|
||||
Use conventional commits for the title: feat:, fix:, docs:, refactor:, chore:
|
||||
-->
|
||||
|
||||
### Description
|
||||
|
||||
<!-- What does this PR do and why? Link to relevant issue or motivation. -->
|
||||
|
||||
Closes #
|
||||
|
||||
### Type of Change
|
||||
|
||||
- [ ] Bug fix (non-breaking change that fixes an issue)
|
||||
- [ ] New feature (non-breaking change that adds functionality)
|
||||
- [ ] Breaking change (fix or feature that alters existing behavior)
|
||||
- [ ] Refactoring (no functional changes)
|
||||
- [ ] Documentation update
|
||||
- [ ] Build, dependencies, or CI
|
||||
|
||||
### How Has This Been Tested?
|
||||
|
||||
<!-- Describe the tests you ran and the environments (browsers, OS, etc.) -->
|
||||
|
||||
- [ ] Tested on Chrome
|
||||
- [ ] Tested on Firefox
|
||||
- [ ] `npm run lint` passes with zero errors and zero warnings
|
||||
- [ ] `node -c` passes on all modified `.js` files
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] My code follows the project's style guidelines
|
||||
- [ ] I have performed a self-review of my code
|
||||
- [ ] I have added/updated tests if needed
|
||||
- [ ] I have updated documentation if needed (`docs/`, README, etc.)
|
||||
- [ ] Protocol changes: I ran `node scripts/build-extension.js` and updated relevant docs
|
||||
- [ ] No new warnings, secrets, or hardcoded credentials introduced
|
||||
|
||||
### Additional Context
|
||||
|
||||
<!-- Screenshots, migration notes, performance data, etc. -->
|
||||
@@ -52,19 +52,55 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Sync Protocol Constants
|
||||
run: |
|
||||
chmod +x ./scripts/sync-constants.sh
|
||||
./scripts/sync-constants.sh
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Extension Zip
|
||||
- name: Inject version into source files
|
||||
run: |
|
||||
zip -r koala-sync-extension.zip extension/ -x "*.DS_Store*"
|
||||
VERSION=${{ steps.version.outputs.VERSION }}
|
||||
DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
echo "Injecting version $VERSION from tag $GITHUB_REF_NAME..."
|
||||
|
||||
# 1. extension/manifest.base.json
|
||||
jq --arg v "$VERSION" '.version = $v' extension/manifest.base.json > tmp.json && mv tmp.json extension/manifest.base.json
|
||||
echo " ✓ manifest.base.json -> $VERSION"
|
||||
|
||||
# 2. shared/constants.js — APP_VERSION
|
||||
sed -i "s/export const APP_VERSION = '.*'/export const APP_VERSION = '$VERSION'/" shared/constants.js
|
||||
echo " ✓ shared/constants.js -> $VERSION"
|
||||
|
||||
# 3. package.json
|
||||
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
|
||||
echo " ✓ package.json -> $VERSION"
|
||||
|
||||
# 4. website/version.json
|
||||
jq -n --arg v "$VERSION" --arg d "$DATE" '{version: $v, date: $d}' > website/version.json
|
||||
echo " ✓ website/version.json -> version $VERSION, date $DATE"
|
||||
|
||||
echo "Version injection complete."
|
||||
|
||||
- name: Commit and push version updates back to main
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add extension/manifest.base.json shared/constants.js package.json website/version.json
|
||||
git commit -m "chore(release): update versions to $GITHUB_REF_NAME [skip ci]" || echo "No changes to commit"
|
||||
git push origin HEAD:main
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Extensions
|
||||
run: |
|
||||
npm install
|
||||
npm run build:extension
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: koala-sync-extension.zip
|
||||
files: |
|
||||
dist/koalasync-chrome.zip
|
||||
dist/koalasync-firefox.zip
|
||||
name: Release ${{ github.ref_name }}
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
|
||||
@@ -38,8 +38,7 @@ coverage/
|
||||
# KoalaSync Specific
|
||||
# We ignore the synced files in the extension folder to ensure
|
||||
# the root 'shared/' remains the Single Source of Truth.
|
||||
extension/shared/*
|
||||
!extension/shared/README.md
|
||||
extension/shared/
|
||||
|
||||
# Temporary scratch files
|
||||
scratch/
|
||||
|
||||
@@ -5,6 +5,9 @@ Welcome to the KoalaSync project. This file is the primary entry point for any d
|
||||
> [!IMPORTANT]
|
||||
> **Privacy & Data Sovereignty**: KoalaSync follows a strict **Zero-External-Requests Policy**: The extension and website must not make requests to any third-party domains (Google Fonts, CDNs, etc.). All assets (fonts, icons, scripts) must be self-hosted or use system defaults.
|
||||
> - **Font Stack**: Use a modern system font stack (e.g., -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif) to maintain a premium look without external dependencies. Prohibit the use of `@import` or `<link>` for external font services.
|
||||
>
|
||||
> [!IMPORTANT]
|
||||
> **Caveman Communication Protocol**: To conserve context window and tokens, AI agents MUST communicate with the user in **caveman language** (broken, short German/English hybrid caveman style, e.g., "Ich Antigravity. Ich machen Git pull. Code fertig.") at all times. This rule is highly strict and must NOT be broken under any circumstances unless the user explicitly requests to drop it.
|
||||
|
||||
---
|
||||
|
||||
@@ -15,31 +18,43 @@ KoalaSync is a specialized tool for **synchronized video playback** across multi
|
||||
- **Identity**: Users are identified by a unique hex `peerId` combined with a customizable `username`.
|
||||
|
||||
## 2. Repository Structure
|
||||
- `extension/`: Chrome Extension (Manifest V3). Contains background service worker, content scripts, and popup UI.
|
||||
- `extension/`: Browser Extension (Chrome & Firefox, Manifest V3). Contains background service worker, content scripts, and popup UI.
|
||||
- `server/`: Node.js Relay Server using Socket.IO (WebSocket-only).
|
||||
- `website/`: **Landing Page** & Invitation Bridge (Marketing, Tutorials, and Downloads).
|
||||
- **`build.js`**: Zero-dependency static site compiler. Translates `template.html` + `locales/*.json` → `www/`. Also minifies CSS/JS automatically.
|
||||
- **`www/` is auto-generated**: Never edit files in `www/` directly. Always edit source files (`template.html`, `style.css`, `app.js`, `lang-init.js`, `locales/*.json`) and run `node website/build.js` to regenerate. CSS/JS are output as `.min.*` files — a built-in cleanup step removes stale artifacts on each build.
|
||||
- `shared/`: **Single Source of Truth** for protocol constants and event names.
|
||||
- `scripts/`: Utility scripts (e.g., `sync-constants.sh`).
|
||||
- `scripts/`: Development utilities (e.g., `build-extension.js`).
|
||||
- `docker-compose.yml`: Root-level orchestration for the relay server.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Single Source of Truth**: `shared/constants.js` and `shared/blacklist.js` are the master files. They must be synchronized to the `extension/shared/` directory using `.\scripts\sync-constants.bat` or `./scripts/sync-constants.sh`.
|
||||
> **Single Source of Truth**: `shared/constants.js` and `shared/blacklist.js` are the master files. They must be synchronized to the `extension/shared/` directory using `node scripts/build-extension.js`.
|
||||
> - **Extension Modules** (`background.js`, `popup.js`) import directly from `./shared/constants.js`.
|
||||
> - **Content Scripts** (`content.js`) use a **manual synchronous mirror** to prevent race conditions during page load. Always verify parity after sync.
|
||||
> - **Content Scripts** (`content.js`) use a **marker-injected synchronous copy** of the constants. The build script automatically replaces the marked blocks — no manual mirroring needed.
|
||||
|
||||
## 3. Mandatory Reading
|
||||
Before touching any code, you MUST read the following documents in order:
|
||||
1. [ARCHITECTURE.md](ARCHITECTURE.md) – Detailed communication flows, Dual Heartbeat, and two-phase sync protocol.
|
||||
1. [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) – Detailed communication flows, Dual Heartbeat, and two-phase sync protocol.
|
||||
2. [extension/README.md](extension/README.md) – Extension components, tab structure, and loading process.
|
||||
3. [SYNC_GUIDE.md](SYNC_GUIDE.md) – Protocol constants and synchronization requirements.
|
||||
3. [docs/SYNC_GUIDE.md](docs/SYNC_GUIDE.md) – Protocol constants and synchronization requirements.
|
||||
|
||||
## 4. The "Vanilla JS Mirror" Pattern
|
||||
To avoid boot-time race conditions in Manifest V3 without a bundler, the following architectural trade-off is enforced:
|
||||
- **Synchronous Execution**: `content.js` MUST execute synchronously to catch early media events.
|
||||
- **Manual Mirroring**: `content.js` maintains a manual mirror of the `EVENTS` constants from `shared/constants.js`.
|
||||
- **Maintenance**: Developers must ensure that any changes to `shared/constants.js` are manually reflected in `content.js` after running the sync scripts.
|
||||
- **Automated Injection**: The build script (`node scripts/build-extension.js`) automatically injects `EVENTS` and `HEARTBEAT_INTERVAL` into `content.js` using marker-based replacement (see `scripts/README.md` for marker details).
|
||||
- **Maintenance**: After modifying `shared/constants.js`, simply run the build script. No manual mirroring is required.
|
||||
|
||||
## 5. Design Guidelines
|
||||
## 5. File Responsibility Map
|
||||
|
||||
| File | Responsibility |
|
||||
|:-----|:---------------|
|
||||
| `background.js` | WebSocket client, state orchestrator, event router, session persistence |
|
||||
| `content.js` | Video element detection, media control, event origin detection (loop prevention) |
|
||||
| `popup.js` | UI rendering, user input handling, peer display, invitation link generation |
|
||||
| `bridge.js` | Landing page ↔ extension communication for invitation join flow |
|
||||
| `server/index.js` | Room management, message relay, rate limiting, authentication, peer lifecycle |
|
||||
|
||||
## 6. Design Guidelines
|
||||
The popup UI follows a strict design system. Do not modify these variables or the layout structure without explicit approval.
|
||||
- **Font**: System font stack. **MANDATORY**: No external CDNs or Google Fonts to ensure 100% privacy.
|
||||
- **Popup Width**: Fixed at `320px`.
|
||||
@@ -53,11 +68,12 @@ The popup UI follows a strict design system. Do not modify these variables or th
|
||||
| `--success` | `#22c55e` | Success states / Online dot |
|
||||
| `--error` | `#ef4444` | Errors / Offline dot |
|
||||
|
||||
## 5. Non-Negotiables (Core Logic)
|
||||
## 7. Non-Negotiables (Core Logic)
|
||||
The following features are critical and must not be removed or fundamentally altered:
|
||||
- **Two-Phase Force Sync**: The `Prepare` → `ACK` → `Execute` flow ensures all peers are buffered before playback resumes.
|
||||
- **Episode Auto-Sync**: Ensures series binges stay perfectly synced. A lobby initiates during title transitions, freezing peers until everyone is ready.
|
||||
- **Dual Heartbeat**:
|
||||
- **Background Heartbeat (30s)**: Ensures session persistence even without a video element.
|
||||
- **Background Heartbeat (1m)**: Ensures session persistence even without a video element.
|
||||
- **Content Heartbeat (15s)**: Transmits current video metadata (time, title).
|
||||
- **Dead Peer Pruning**: Server "Reaper" disconnects peers after 5 minutes of total silence (no heartbeats or events).
|
||||
- **Deduplication**: Server kills old sockets if a user re-joins with the same `peerId` to prevent ghosts.
|
||||
@@ -66,33 +82,68 @@ The following features are critical and must not be removed or fundamentally alt
|
||||
- **SW Keep-alive**: Use of `chrome.alarms` to prevent the Manifest V3 Service Worker from suspending.
|
||||
- **Diagnostics**: The "Dev" tab provides real-time access to the underlying `<video>` state for troubleshooting.
|
||||
- **Persistence**: `peerId` and `username` must be stored to remain stable across sessions.
|
||||
- **Room ID Format**: Room IDs are restricted to `[a-zA-Z0-9-]` only (alphanumeric + hyphens). This is enforced server-side.
|
||||
|
||||
## 6. Technical Constraints
|
||||
## 8. Technical Constraints
|
||||
- **No Bundler**: The extension uses plain ES Modules. Do not introduce build steps or npm packages into the `extension/` folder.
|
||||
- **Manual Protocol**: `background.js` implements a subset of the Socket.IO wire protocol natives.
|
||||
- **Manual Protocol**: `background.js` implements a subset of the Socket.IO wire protocol natively.
|
||||
- **Server Transport**: Restricted to `websocket` only. Polling is disabled.
|
||||
- **Docker Context**: The Docker build must run from the **Repo Root**.
|
||||
- **Manifest Settings**: `run_at` must remain `document_idle`, and `all_frames` must remain `false`.
|
||||
- **Strict Backward & Forward Compatibility (Store Delay Rule)**: Browser extensions are distributed through stores (e.g., Chrome Web Store, Firefox Add-ons) which can take up to 2 weeks to approve updates. Therefore, the server MUST NOT reject older extension clients unless a critical protocol version bump is explicitly authorized, and new extension versions MUST remain fully operational when connected to older servers (e.g., by silently falling back if a new feature is not supported). This is a core architectural requirement.
|
||||
|
||||
## 7. Security & Deployment
|
||||
## 9. Security & Deployment
|
||||
- **Tokens**: Security tokens are intentionally managed via `shared/constants.js` and server `.env`.
|
||||
- **Environment**: `.env` is excluded via `.gitignore`. Only `.env.example` should be committed.
|
||||
- **Revocation**: `MIN_VERSION` check on the server is used to deprecate old extension versions.
|
||||
- **Invitation Links**: Correctly propagate server URLs, Room IDs, and Passwords via the URL hash to the bridge.
|
||||
|
||||
## 8. Common Workflows
|
||||
## 10. Common Workflows
|
||||
|
||||
### ⚠️ Pre-Session Git Sync (MANDATORY)
|
||||
Before starting any task, committing, or pushing, you **MUST** run `git pull --rebase` to ensure your local branch is up-to-date with `origin/main`. CI pipelines and other agents may push commits concurrently. Skipping this step will cause merge conflicts and rejected pushes.
|
||||
|
||||
### Releasing a New Version (CRITICAL WORKFLOW FOR AI AGENTS)
|
||||
> [!CAUTION]
|
||||
> **AI AGENTS MUST FOLLOW THIS EXACT SEQUENCE WHEN RELEASING A NEW VERSION OR TAGGING.**
|
||||
> The CI pipeline automatically injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json`. You do NOT need to manually bump version numbers.
|
||||
> - **Website Versioning**: **NEVER** manually modify the version fallback strings in `website/index.html`. The website dynamically fetches the latest version and release date from `website/version.json` at runtime using `website/app.js`. Manual bumps in the HTML file are completely redundant and should be avoided.
|
||||
1. **MANDATORY SYNTAX & LINT CHECKS**: Before staging, committing, or pushing any changes, you **MUST** run both checks on every modified JavaScript file:
|
||||
- **Syntax Validation**: Run `node -c` on every single modified JavaScript file (e.g., `node -c extension/background.js` and `node -c extension/content.js`). **NEVER** commit or push code that fails this check.
|
||||
- **ESLint Validation**: Run `npm run lint` (or `npx eslint .`). The output must show **zero errors and zero warnings**. ESLint is configured to catch undefined variables, unused vars, unreachable code, and other semantic issues. **NEVER** commit or push code that fails this check.
|
||||
2. Commit all verified code changes and push to `main`.
|
||||
3. Create and push a new tag. **MANDATORY**: Tags MUST start with a `v` (e.g., `v1.4.0`). The GitHub Actions release workflow is strictly configured to ignore any tags without the `v` prefix.
|
||||
- **🚫 TAG IMMUTABILITY**: Once a tag is pushed to `origin`, it is **PERMANENT**. You MUST **NEVER** reuse, move, or force-push an existing tag — not even to "fix" a mistake. If a release is missing a fix, increment the version and create a **new** tag (e.g., `v1.7.0` → `v1.7.1`). Tags are immutable identifiers; moving them breaks CI pipelines, corrupts the release history, and causes unreproducible builds.
|
||||
- **🚫 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 sync script (`.\scripts\sync-constants.bat` or `./scripts/sync-constants.sh`).
|
||||
2. Run the build script (`node scripts/build-extension.js`).
|
||||
3. Implement the handler in `server/index.js` and `background.js`.
|
||||
|
||||
### Making Website Changes
|
||||
1. Edit source files in `website/` (`template.html`, `style.css`, `app.js`, `lang-init.js`, or `locales/*.json`).
|
||||
2. Run the compiler: `node website/build.js`. This generates the multilingual pages in `www/` and minifies CSS/JS.
|
||||
3. Verify the output: `node --check website/www/app.js && node --check website/www/lang-init.js`.
|
||||
4. Test locally: `npx serve website/www` or `python3 -m http.server 8080 -d website/www`.
|
||||
5. Commit both source changes and the updated `www/` output.
|
||||
|
||||
### Testing Locally
|
||||
1. Load `extension/` as an "Unpacked Extension" in Chrome.
|
||||
2. Start the server from the root: `docker-compose up --build`.
|
||||
3. Use **different browser profiles** or vendors to test multi-peer logic.
|
||||
4. Use the **Dev tab** to verify real-time video element metadata.
|
||||
1. Run the build script: `node scripts/build-extension.js`.
|
||||
2. Load `dist/chrome/` as an "Unpacked Extension" in Chrome (or `dist/firefox/` in Firefox).
|
||||
3. Start the server from the root: `docker-compose up --build`.
|
||||
4. Use **different browser profiles** or vendors to test multi-peer logic.
|
||||
5. Use the **Dev tab** to verify real-time video element metadata.
|
||||
|
||||
### Locking Old Versions
|
||||
1. Increase `APP_VERSION` in `shared/constants.js`.
|
||||
2. Update `MIN_VERSION` in the server's `.env` file and restart.
|
||||
1. Update `MIN_VERSION` in the server's `.env` file to the minimum acceptable version.
|
||||
2. Restart the server. Older extensions will be rejected with a "Version too old" error.
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# KoalaSync Architecture
|
||||
|
||||
This document describes the communication flows and internal logic of the KoalaSync system.
|
||||
|
||||
## 1. Extension Startup & Connection
|
||||
- **Initialization**: On startup, `background.js` reads settings (Server URL, Username, Last Room) from `chrome.storage.sync`.
|
||||
- **WebSocket Handshake**:
|
||||
1. Background creates a `new WebSocket` to `/socket.io/?EIO=4&transport=websocket&version=1.0.0`.
|
||||
2. Server performs security checks:
|
||||
- **IP Rate Limit**: Checks if the IP has exceeded connection limits.
|
||||
- **Protocol Version**: Client must match the server's protocol (currently `1.0.0`).
|
||||
3. Server responds with Engine.IO handshake (`0`) and the client joins the namespace (`40`).
|
||||
- **Room Join**: Background emits `JOIN_ROOM` containing `roomId`, `password`, `peerId`, and `username`.
|
||||
- **Deduplication**: If a user joins with a `peerId` that already has an active socket, the server kills the old socket to prevent "Ghost Peers".
|
||||
|
||||
## 2. Media Event Synchronization
|
||||
When a user interacts with a video:
|
||||
1. **Detection**: `content.js` listens to native events (`play`, `pause`, `seeked`) on the `<video>` element.
|
||||
2. **Prevention of Loops**: Uses `lastTargetState` to distinguish between user actions and programmatic actions triggered by the extension.
|
||||
3. **Reporting**: `content.js` sends a `CONTENT_EVENT` to `background.js`.
|
||||
4. **Relay**: The Server forwards the event to all other peers in the room.
|
||||
5. **Execution**: Remote peers receive the command and call `video.play()`, `video.pause()`, or `video.currentTime = targetTime`.
|
||||
|
||||
## 3. Two-Phase Force Sync
|
||||
Ensures all peers are frame-perfect and buffered before resuming:
|
||||
1. **Prepare**: Initiator sends `FORCE_SYNC_PREPARE` with the target timestamp.
|
||||
2. **Buffer**: Peers seek and pause. Once buffered (`readyState >= 3`), they send a `FORCE_SYNC_ACK`.
|
||||
3. **Execute**: Once the Initiator collects ACKs (or after a 5s timeout), they send `FORCE_SYNC_EXECUTE`.
|
||||
4. **Resume**: All peers call `play()` simultaneously.
|
||||
|
||||
## 4. Peer Lifecycle & Dual Heartbeat
|
||||
To maintain a clean room state and eliminate "Ghost Peers":
|
||||
- **Session Heartbeat (Background)**: Every 30 seconds, `background.js` sends an "I'm alive" signal to the server. This keeps you in the room even if no video is playing.
|
||||
- **Video Heartbeat (Content)**: Every 15 seconds, `content.js` sends current playback metadata (time, title, state) if a video is found.
|
||||
- **Server Pruning**: The server runs a "Reaper" every 2 minutes. If a peer has sent **zero** activity (no events and no heartbeats) for 5 minutes, they are forcefully disconnected.
|
||||
- **Immediate Cleanup**: Rooms are deleted instantly when the last peer leaves or disconnects.
|
||||
|
||||
## 5. Security & Stability
|
||||
- **Service Worker Lifecycle**: Uses `chrome.alarms` to prevent the Manifest V3 service worker from suspending while in an active room.
|
||||
- **Rate Limiting**: Server-side per-socket and per-IP rate limits to prevent sync-spamming or DoS.
|
||||
- **Noise Filtering**: Uses a curated blacklist of domains (Search Engines, Social Media) to declutter the "Target Tab" selector in the popup.
|
||||
- **Diagnostics**: A "Dev" tab provides real-time access to the underlying `<video>` state (`readyState`, `paused`, `currentTime`) for easier troubleshooting.
|
||||
|
||||
## 6. Constant Synchronization & Consistency
|
||||
To maintain a "Single Source of Truth" across the server and extension without using a bundler:
|
||||
- **Relay Server & Extension Modules**: `background.js` and `popup.js` import constants directly from `shared/constants.js`.
|
||||
- **Content Scripts**: To ensure zero-latency execution, `content.js` uses a manual mirror of `EVENTS`.
|
||||
- **Synchronization**: The `./scripts/sync-constants.sh` script ensures that the `shared/` folder within the `extension/` directory is kept up-to-date with the root `shared/` source.
|
||||
- **Verification**: Any protocol change requires a manual verification sweep across all three constant locations (Shared, Server, and Content Script Mirror).
|
||||
@@ -0,0 +1,61 @@
|
||||
# KoalaSync Changelog
|
||||
|
||||
All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.2] — 2026-06-02
|
||||
|
||||
### Fixed
|
||||
- Peer identity spoofing in relay server: client-supplied `peerId` could be used to impersonate other peers in PEER_STATUS events. Server now always stamps `peerId` with the authenticated sender's identity.
|
||||
- Amazon domain detection: replaced broad `includes('amazon.')` substring check with boundary-safe regex that correctly matches all Amazon storefronts (`amazon.com`, `amazon.de`, `amazon.co.uk`, etc.) while rejecting lookalike domains.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.1] — 2026-06-01
|
||||
|
||||
### Fixed
|
||||
- Video detection on Prime Video: `findVideo()` now scores all video elements by size, duration, and mute state instead of picking the first one. Fixes 0×0 placeholder being selected over the actual player.
|
||||
- History entries in debug report showing `?` instead of action names.
|
||||
- Prime Video status in compatibility matrix updated to reflect partial support.
|
||||
|
||||
### Added
|
||||
- Multi-video overview table in Copy Debug Report when a page has more than one `<video>` element. Shows resolution, mute state, playback state, readyState, duration, and marks the currently targeted video.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.0] — 2026-06-01
|
||||
|
||||
### 🌍 Multi-Language Extension (Biggest Feature!)
|
||||
- **6-Language UI**: The browser extension is now fully translated into **English, German, French, Spanish, Portuguese (Brazilian), and Russian**. Switch languages instantly in Settings without reload.
|
||||
- **Real-Time i18n**: Every label, button, tooltip, toast notification, empty state, and onboarding guide updates dynamically when the language changes.
|
||||
|
||||
### New Features
|
||||
- **Copy Debug Report (Markdown)**: The *Copy Logs* button in the Status tab now copies a fully formatted Markdown debug report — system info, connection status, video diagnostics, action history, and logs. One click, paste into a GitHub issue, all debugging data ready.
|
||||
- **Platform Auto-Detection**: The Dev tab now identifies streaming platforms (YouTube, Netflix, Twitch, Prime Video, Disney+, HBO Max, Vimeo, Dailymotion) and displays the detected platform.
|
||||
- **Enhanced Video Debug Info**: 20+ new fields in the Status tab including network state, buffered ranges, dimensions (with 0×0 warning), media error codes, shadow DOM status, seeking/ended/loop flags, volume, playback speed, and data attributes.
|
||||
- **No-Video Diagnostic Mode**: When no video is found, the Status tab shows platform, page title, video count, shadow DOM presence, and MediaSession data to help troubleshoot.
|
||||
|
||||
### Changed
|
||||
- **New TwoPointZero Branding**: Updated extension icons (16/32/48/96/128px).
|
||||
- **Larger Popup Logo**: Extension popup icon increased to 48px.
|
||||
- **Prime Video Unblocked**: Removed `amazon.` from the tab blacklist so Amazon/Prime Video tabs appear in the video selector.
|
||||
- **Improved Debug Report**: Full User-Agent string for accurate browser identification, UTC timestamp, connection details including server URL and room info.
|
||||
- **Smart Disconnect**: Improved disconnect handling when leaving rooms.
|
||||
- **Human-Readable Room IDs**: Expanded word lists for friendlier room names.
|
||||
- **Custom Server Support**: WEB_JOIN_REQUEST and join button for custom server invite flows.
|
||||
- **Reconnection Strategy**: Custom server reconnection improvements.
|
||||
- **Episode-Aware Sync**: Command sequencing with smarter episode transition detection and echo suppression for smoother series binges.
|
||||
- **Sync Status Refinements**: YouTube and Twitch sync behavior improved.
|
||||
- **No External Dependencies**: Extension remains dependency-free with no library overhead.
|
||||
|
||||
### Fixed
|
||||
- Hardcoded strings, missing translation keys, and Service Worker notification race conditions.
|
||||
|
||||
---
|
||||
|
||||
## Versioning Policy
|
||||
|
||||
- **MAJOR** (x.0.0): Breaking protocol changes, architecture rewrites, or major feature milestones.
|
||||
- **MINOR** (0.x.0): New features, significant enhancements, new translations, or UI redesigns.
|
||||
- **PATCH** (0.0.x): Bug fixes, minor improvements, and documentation updates. PATCH releases may not receive individual changelog entries if bundled with a MINOR release.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the project maintainer at **koaladev@koalamail.rocks**.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All project maintainers are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Project maintainers will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from project maintainers, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
@@ -0,0 +1,156 @@
|
||||
# Contributing to KoalaSync
|
||||
|
||||
Thanks for your interest in improving KoalaSync. All contributions are welcome — from bug reports and translations to core protocol changes.
|
||||
|
||||
Please note that by participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
---
|
||||
|
||||
## Ways to Contribute
|
||||
|
||||
| Area | Description |
|
||||
|------|-------------|
|
||||
| **Bug Reports** | Found a bug? Open an issue with repro steps (see template below). |
|
||||
| **Code** | Fix bugs, add features, or improve the extension / server / website. |
|
||||
| **Translations** | Help localize the extension and website into more languages. See [TRANSLATION.md](website/TRANSLATION.md). |
|
||||
| **Documentation** | Improve docs, fix typos, or add missing examples. |
|
||||
| **Security** | Found a vulnerability? See [SECURITY.md](SECURITY.md) — do NOT open a public issue. |
|
||||
|
||||
---
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js** v18+
|
||||
- **Docker** (for local relay server testing)
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Shik3i/KoalaSync.git
|
||||
cd KoalaSync
|
||||
npm install
|
||||
node scripts/build-extension.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `extension/` | Browser extension (Manifest V3, Chrome & Firefox) |
|
||||
| `server/` | Node.js + Socket.IO relay server (Dockerized) |
|
||||
| `website/` | Landing page, invitation bridge, and marketing site |
|
||||
| `shared/` | Protocol constants — single source of truth |
|
||||
| `scripts/` | Build and sync utilities |
|
||||
| `docs/` | Architecture, sync protocol, and deep-dive guides |
|
||||
|
||||
---
|
||||
|
||||
## Testing Locally
|
||||
|
||||
### Extension
|
||||
|
||||
1. Load `dist/chrome/` as an unpacked extension in Chrome (`chrome://extensions/` → Developer Mode → **Load unpacked**).
|
||||
2. For Firefox: load `dist/firefox/` via `about:debugging` → **Load Temporary Add-on**.
|
||||
3. Start the relay server: `docker compose up --build`.
|
||||
4. Use **two different browser profiles** (or Chrome + Firefox) to test multi-peer sync.
|
||||
5. Use the extension's **Dev tab** to inspect real-time video element state (`readyState`, `currentTime`, `paused`).
|
||||
|
||||
### Website
|
||||
|
||||
```bash
|
||||
node website/build.js # Compile static site → www/
|
||||
python3 -m http.server 8080 -d website/www # Serve locally
|
||||
```
|
||||
|
||||
Then open `http://localhost:8080`. For multi-language testing: `http://localhost:8080/de/`.
|
||||
|
||||
---
|
||||
|
||||
## Protocol Constants
|
||||
|
||||
KoalaSync uses a **single source of truth** for all protocol constants in `shared/constants.js`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> After modifying `shared/constants.js`, you **must** run the build script to sync changes to the extension:
|
||||
> ```bash
|
||||
> node scripts/build-extension.js
|
||||
> ```
|
||||
> This automatically injects constants into `content.js` and regenerates browser bundles in `dist/`.
|
||||
|
||||
---
|
||||
|
||||
## Code Standards
|
||||
|
||||
- **Vanilla JS**: The extension must remain dependency-free. No npm packages in `extension/`.
|
||||
- **Privacy-first**: Zero external requests — no CDNs, fonts, analytics, or trackers. All assets self-hosted.
|
||||
- **System font stack**: `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, ...` — never `@import` external fonts.
|
||||
- **Room IDs**: Restricted to `[a-zA-Z0-9-]` (alphanumeric + hyphens only). Enforced server-side.
|
||||
- **Comments**: Document complex sync logic. The codebase uses inline comments for protocol reasoning.
|
||||
|
||||
---
|
||||
|
||||
## Version Numbers
|
||||
|
||||
> [!CAUTION]
|
||||
> **Never manually bump version numbers.** The CI pipeline injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json` during release builds. Manual bumps cause conflicts.
|
||||
|
||||
---
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. **Branch** from `main` for your feature or fix.
|
||||
2. **Test locally** on both Chrome and Firefox.
|
||||
3. **Website changes**: Run `node website/build.js` and verify the compiled output in `www/`.
|
||||
4. **Lint**: Ensure `npm run lint` passes with zero errors and zero warnings.
|
||||
5. **Syntax**: Run `node -c` on every modified `.js` file.
|
||||
6. **Protocol changes**: Update relevant documentation in `docs/`.
|
||||
7. **Submit your PR** with a clear description and linked issue (if applicable).
|
||||
|
||||
---
|
||||
|
||||
## Bug Report Template
|
||||
|
||||
When filing a bug, the easiest way is to use the **Copy Logs** button in the extension's **Status** tab. It copies a fully formatted Markdown report to your clipboard containing:
|
||||
|
||||
- System info (version, protocol, peer ID, browser)
|
||||
- Connection status (server, room, peers, reconnect state)
|
||||
- Video debug info (playback state, readyState, network state, dimensions, error codes, shadow DOM detection, platform)
|
||||
- Action history (last 20 events)
|
||||
- Log entries (last 50)
|
||||
|
||||
Simply paste the clipboard contents into your GitHub issue and add:
|
||||
|
||||
| Field | Example |
|
||||
|-------|---------|
|
||||
| **Steps to Reproduce** | 1. Create room → 2. Join from second browser → 3. Play video |
|
||||
| **Expected Behavior** | Both peers play simultaneously |
|
||||
| **Actual Behavior** | Peer B remains paused |
|
||||
|
||||
If you cannot access the Status tab, include as much of the following manually:
|
||||
|
||||
| Field | Example |
|
||||
|-------|---------|
|
||||
| **Browser** | Chrome 125, Firefox 128 |
|
||||
| **Extension Version** | v1.9.3 (visible at bottom of Settings tab) |
|
||||
| **Website/Platform** | Netflix, YouTube, Twitch, Jellyfin, etc.
|
||||
|
||||
---
|
||||
|
||||
## Translation Contributions
|
||||
|
||||
KoalaSync supports 6 languages: English, German, French, Spanish, Portuguese (Brazilian), and Russian.
|
||||
|
||||
To add or improve translations:
|
||||
1. Edit the locale files in `website/locales/` (for the website).
|
||||
2. For extension translations, see [TRANSLATION.md](website/TRANSLATION.md).
|
||||
3. Run `node website/build.js` to regenerate the static site.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
If you discover a security vulnerability, **do not open a public issue**. Report it privately as described in [SECURITY.md](SECURITY.md).
|
||||
@@ -0,0 +1,83 @@
|
||||
# ==============================================================================
|
||||
# KoalaSync - Production Caddy Configuration Example
|
||||
# ==============================================================================
|
||||
# This file provides examples of both a lightweight "Simple" configuration
|
||||
# and a production-hardened "Advanced" configuration.
|
||||
# Replace domains, reverse proxy locations, and directories with your actual setup.
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# OPTION A: Simple Configuration
|
||||
# ------------------------------------------------------------------------------
|
||||
# Minimal configuration that serves the static website, enables gzip compression,
|
||||
# supports extension-less Clean URLs, and reverse proxies the relay server.
|
||||
|
||||
# sync.koalastuff.net {
|
||||
# root * /var/www/koalasync/website/www
|
||||
# encode zstd gzip
|
||||
#
|
||||
# # Clean URLs support (resolves /join to join.html, etc.)
|
||||
# try_files {path} {path}.html {path}/
|
||||
# file_server
|
||||
# }
|
||||
#
|
||||
# syncserver.koalastuff.net {
|
||||
# reverse_proxy localhost:3000
|
||||
# }
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# OPTION B: Advanced Configuration (Production-Hardened)
|
||||
# ------------------------------------------------------------------------------
|
||||
# Highly secure, optimized configuration using advanced HTTP security headers,
|
||||
# aggressive static assets caching, server signature concealment, and strict
|
||||
# hardware permission access policies.
|
||||
|
||||
(security_headers) {
|
||||
header {
|
||||
# Enable HTTP Strict Transport Security (HSTS)
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
# Prevent clickjacking attacks (Sameorigin)
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
# Prevent MIME-sniffing
|
||||
X-Content-Type-Options "nosniff"
|
||||
# Enable browser XSS protection
|
||||
X-XSS-Protection "1; mode=block"
|
||||
# Control referrer information
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
# Hide Caddy server stamp signature
|
||||
-Server
|
||||
}
|
||||
}
|
||||
|
||||
sync.koalastuff.net {
|
||||
encode zstd gzip
|
||||
root * /var/www/koalasync/website/www
|
||||
|
||||
# Clean URLs: Resolves paths without .html in the URL
|
||||
try_files {path} {path}.html {path}/
|
||||
file_server
|
||||
|
||||
# Static Caching for high-performance PageSpeed (1 year with validation)
|
||||
@static {
|
||||
file
|
||||
path *.ico *.css *.js *.png *.svg *.webp *.avif
|
||||
}
|
||||
header @static Cache-Control "public, max-age=31536000, must-revalidate"
|
||||
|
||||
# Security Headers & Content Security Policy (CSP)
|
||||
import security_headers
|
||||
header {
|
||||
# CSP hardened with base-uri and form-action limits
|
||||
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'; base-uri 'none'; form-action 'none';"
|
||||
|
||||
# Modern Permissions Policy (blocks browser hardware access for enhanced privacy)
|
||||
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
|
||||
}
|
||||
}
|
||||
|
||||
syncserver.koalastuff.net {
|
||||
import security_headers
|
||||
encode zstd gzip
|
||||
reverse_proxy KoalaSync:3000
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Privacy Policy
|
||||
|
||||
**KoalaSync does not collect, store, or sell any personal data.**
|
||||
|
||||
KoalaSync is designed with a **Security-First & Volatile** architecture. This means we prioritize keeping your data out of persistent storage, though certain technical data must be processed temporarily to ensure service stability and security.
|
||||
|
||||
## 1. Data Processing (In-Memory Only)
|
||||
KoalaSync does not use a database. All active session data exists only in the server's RAM and is purged immediately when no longer needed.
|
||||
- **Session Data**: To synchronize playback, the server must temporarily hold your `peerId`, `username`, and the `title` of the video you are watching. Additionally, playback metadata (`mediaTitle`, `playbackState`, `currentTime`, `volume`, `muted`) is held per peer for the duration of the session. All of this is deleted as soon as you leave the room.
|
||||
- **Room Passwords**: If you set a room password, it is stored only as an in-memory **keyed SHA-256 HMAC hash**. The server receives the plaintext password only during join validation, never stores it, and keeps only the hash for the short room lifetime.
|
||||
- **Routing Maps**: The server maintains ephemeral lookup tables (`socketToRoom`, `peerToSocket`) to route messages between peers. These contain only transport identifiers and are purged on disconnect.
|
||||
|
||||
### Data Retention
|
||||
| Data Type | Maximum Retention | Trigger for Deletion |
|
||||
|:----------|:------------------|:---------------------|
|
||||
| Session data (peerId, username, video metadata) | Duration of session | User leaves room or disconnects |
|
||||
| Room state | 2 hours max | Last peer leaves, or inactivity timeout |
|
||||
| Auth failure records (lockout after 5 failed attempts) | 15 minutes | Periodic cleanup |
|
||||
| Connection rate-limit counters | 60 seconds | Automatic expiry |
|
||||
| Event rate-limit counters | 10 seconds | Automatic expiry + periodic cleanup |
|
||||
|
||||
## 2. Security & Rate Limiting
|
||||
To prevent abuse and brute-force attacks, the following data is processed:
|
||||
- **Brute-Force Protection**: If multiple failed password attempts are detected, the server stores the `IP address` and `Room ID` in a temporary RAM-based lockout list for a maximum of 15 minutes.
|
||||
- **Connection Rate Limiting**: IP addresses are tracked for 60 seconds to prevent connection-flooding (DoS) attacks.
|
||||
- **Event Rate Limiting**: Per-socket event counters are tracked for 10-second windows to prevent event-spamming. These are keyed by ephemeral socket IDs and cleaned up periodically.
|
||||
- **Console Logging**: The official relay server (`syncserver.koalastuff.net`) outputs connection events (including IP addresses) to the server console for real-time monitoring. These logs are ephemeral and are not archived, sold, or linked to any persistent user identity.
|
||||
|
||||
## 3. Extension Permissions
|
||||
The browser extension requires the following permissions:
|
||||
- `storage`: To remember your local preferences (username, server URL, room settings).
|
||||
- `tabs` & `scripting`: To detect and control video elements on the pages you choose to sync.
|
||||
- `<all_urls>` (host permission): Required to detect `<video>` elements on any website the user chooses to synchronize. The extension only activates on the specific tab the user has actively selected — it does not scan, monitor, or interact with any other tabs or pages.
|
||||
- `alarms`: To keep the background service worker alive during active sync sessions.
|
||||
- `notifications`: To display sync status updates (e.g., "Peer joined", "Force Sync initiated").
|
||||
- **No History Access**: We do not read, store, or transmit your browsing history. We only interact with the specific tab you have actively selected for synchronization.
|
||||
|
||||
## 4. Zero Third-Party Requests
|
||||
KoalaSync is completely self-contained:
|
||||
- **No CDNs or External Libraries**: All scripts and styles are self-hosted.
|
||||
- **No Analytics**: We do not use Google Analytics, tracking pixels, or any third-party telemetry.
|
||||
- **No External Fonts**: We use system font stacks to prevent tracking via font services.
|
||||
|
||||
## 5. Self-Hosted Instances
|
||||
This privacy policy applies to the **official KoalaSync relay server** at `syncserver.koalastuff.net`. If you choose to self-host a relay server using our open-source Docker image, the data handling practices of that instance are the responsibility of the server operator.
|
||||
|
||||
---
|
||||
|
||||
**Auditable & Open Source**: Because KoalaSync is open source, you can verify these claims by reviewing the [Server Source Code](https://github.com/Shik3i/KoalaSync/blob/main/server/index.js) and the [Extension Logic](https://github.com/Shik3i/KoalaSync/blob/main/extension/content.js).
|
||||
@@ -1,69 +1,124 @@
|
||||
# KoalaSync
|
||||
<p align="center">
|
||||
<img src="website/assets/PlatformJuggler_New.webp" width="280" alt="KoalaSync Mascot">
|
||||
</p>
|
||||
|
||||
KoalaSync is a premium, lightweight Chrome Extension and Relay Server for synchronized video playback across any website (YouTube, Twitch, Netflix, and custom HTML5 players).
|
||||
<h1 align="center">KoalaSync</h1>
|
||||
|
||||
> [!TIP]
|
||||
> **New Developers & AI Agents**: Please read [AI_INIT.md](AI_INIT.md) before starting work.
|
||||
<p align="center">
|
||||
<a href="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml"><img src="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml/badge.svg" alt="Release Status"></a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/github/v/release/Shik3i/KoalaSync" alt="GitHub release"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Shik3i/KoalaSync?color=blue" alt="License"></a>
|
||||
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/"><img src="https://img.shields.io/badge/Firefox-Download-orange?logo=firefoxbrowser&logoColor=white" alt="Firefox Add-on"></a>
|
||||
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white" alt="Chrome Extension"></a>
|
||||
</p>
|
||||
|
||||
## Repository Structure
|
||||
- `extension/`: Chrome Extension (Manifest V3, Vanilla JS).
|
||||
- `server/`: Node.js + Socket.IO Relay Server (Containerized).
|
||||
- `website/`: Marketing landing page & **Invitation Bridge**.
|
||||
- `shared/`: Protocol constants and domain blacklist.
|
||||
- `scripts/`: Development utilities for protocol synchronization.
|
||||
<p align="center"><a href="CHANGELOG.md"><b>New v2.0.2 Release!</b> — See what's changed</a></p>
|
||||
|
||||
> [!NOTE]
|
||||
> For deep technical dives, see [ARCHITECTURE.md](ARCHITECTURE.md) and [SYNC_GUIDE.md](SYNC_GUIDE.md).
|
||||
<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**: 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.
|
||||
|
||||
---
|
||||
|
||||
### ✨ Key Features
|
||||
|
||||
## Key Features
|
||||
- **Global Synchronization**: Synchronize Play, Pause, and Seeking on any website with a `<video>` tag.
|
||||
- **Smart Matching**: Automatically highlights and sorts tabs containing matching video titles.
|
||||
- **Noise Filtering**: Built-in domain blacklist to hide non-video sites from selection.
|
||||
- **Smart Identity**: Customizable usernames combined with unique hexadecimal peer IDs.
|
||||
- **Episode Auto-Sync**: Perfectly sync series binges. All peers wait until everyone has loaded the next episode before starting together.
|
||||
- **Smart Matching**: Automatically highlights tabs containing matching video titles.
|
||||
- **Dual Heartbeat Architecture**: Robust session tracking that prevents ghost rooms and stale connections.
|
||||
- **Zero-Latency Relay**: Custom Socket.IO wire protocol implementation for maximum performance.
|
||||
- **Integrated Diagnostics**: A dedicated "Dev" tab for real-time video state debugging.
|
||||
- **Seamless Invitations**: Smart invitation links that automatically configure the server and room credentials for your friends.
|
||||
- **Efficient Relay**: Minimal overhead WebSocket message forwarding.
|
||||
- **Seamless Invitations**: Smart links that automatically configure server and room credentials for your friends.
|
||||
|
||||
## Setup Instructions
|
||||
---
|
||||
|
||||
### 1. Relay Server (Docker)
|
||||
The server runs on Node.js using Socket.IO, containerized for easy deployment.
|
||||
### 🚀 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).
|
||||
- `website/`: Marketing landing page & Invitation Bridge.
|
||||
- `shared/`: **Single Source of Truth** for protocol constants.
|
||||
- `scripts/`: Automated build and synchronization utilities.
|
||||
- `docs/`: Technical deep-dives ([Architecture](docs/ARCHITECTURE.md), [Sync Guide](docs/SYNC_GUIDE.md)).
|
||||
|
||||
#### Building from Source
|
||||
To build the extension from source and synchronize protocol constants:
|
||||
```bash
|
||||
# From the root directory
|
||||
docker-compose up -d --build
|
||||
npm install
|
||||
node scripts/build-extension.js
|
||||
```
|
||||
The server will be available at `ws://localhost:3000`.
|
||||
The compiled artifacts will be available in the `dist/` directory.
|
||||
|
||||
### 2. Chrome Extension
|
||||
1. **Synchronize Protocol**: From the root directory, run the sync script to copy the master constants to the extension folder:
|
||||
```bash
|
||||
./scripts/sync-constants.sh
|
||||
```
|
||||
2. Open Chrome and go to `chrome://extensions/`.
|
||||
3. Enable **Developer mode** (top right).
|
||||
4. Click **Load unpacked**.
|
||||
5. Select the `extension/` folder.
|
||||
#### For Self-Hosting (Docker)
|
||||
Deploy your own private relay server using our official image:
|
||||
```bash
|
||||
# Pull the latest image
|
||||
docker pull ghcr.io/shik3i/koalasync:latest
|
||||
|
||||
## Usage
|
||||
1. Open the extension and go to the **Settings** tab to set your **Username**.
|
||||
2. Go to the **Room** tab, enter your Server URL (default: `ws://localhost:3000`), and click **Join / Create Room**.
|
||||
3. In the **Sync** tab, select the tab containing the video you want to sync.
|
||||
4. Share the **Invite Link** from the Room tab. When your friends click it, they will automatically join your room and server.
|
||||
5. Use **Force Sync** to perfectly align everyone to your current timestamp.
|
||||
# Or use our example compose file
|
||||
cp docker-compose.caddy.example.yml docker-compose.yml
|
||||
docker-compose up -d
|
||||
```
|
||||
The server will be available at `ws://localhost:3000`. See [Docker network compose](docker-compose.caddy.example.yml) or [Static IP compose](docker-compose.ip.example.yml) for ready-to-use Docker Compose files.
|
||||
|
||||
## Technical Details
|
||||
- **Manifest V3**: Uses a persistent Service Worker with Alarm-based keep-alive.
|
||||
- **Manual Socket.IO Protocol**: The extension implements the Socket.IO v4 wire protocol natively for extreme performance and zero dependencies.
|
||||
- **Dead Peer Pruning**: The server automatically prunes peers after 5 minutes of total inactivity (detected via dual heartbeats).
|
||||
- **Two-Phase Sync**: Ensures all peers are buffered (`readyState >= 3`) before resuming playback.
|
||||
To connect your extension to a self-hosted server, open the popup → **Room** tab → select **Custom Server** → enter your server's WebSocket URL (e.g., `ws://localhost:3000`).
|
||||
|
||||
## Security & Privacy
|
||||
> [!IMPORTANT]
|
||||
> **Privacy First**: KoalaSync stores no data on disk. All room states exist only in RAM and are purged immediately when empty. There is zero telemetry, tracking, or analytics.
|
||||
> **⚠️ Note**: `ws://` only works for `localhost`. If you deploy to a real domain, you **must** use `wss://` (e.g., `wss://sync.yourdomain.com`). This requires a TLS-terminating reverse proxy (e.g., Caddy, Nginx, or Traefik) in front of the relay server. See [Caddyfile.example](Caddyfile.example) for a production-ready template.
|
||||
|
||||
## Troubleshooting
|
||||
- **Logs**: Check the **Dev** tab in the extension popup for live connection logs and video state diagnostics.
|
||||
- **Handshake**: Verify you see `Joined Namespace /` in the logs.
|
||||
- **Permissions**: Ensure the target site hasn't blocked script injection (rare for most video sites).
|
||||
To verify your relay is reachable from outside, visit `https://your-domain.com` in a browser — it should return `{"status":"online","service":"KoalaSync Relay"}`.
|
||||
|
||||
---
|
||||
|
||||
### 🌐 Localization & Translations
|
||||
|
||||
Both the official KoalaSync website and the **v2.0 Browser Extension** feature full dynamic localization:
|
||||
- **Available Languages**: Support is included for 6 languages: English (`en`), German (`de`), French (`fr`), Spanish (`es`), Portuguese (Brazil) (`pt-BR`), and Russian (`ru`).
|
||||
- **Real-Time Extension Localization**: Inside the extension Settings panel, users can swap languages instantly. The entire interface, notifications, Empty States, and onboarding guides re-translate dynamically in real-time.
|
||||
- **Contributing**: We welcome community translations for both the website and the extension! Please refer directly to the [TRANSLATION.md](website/TRANSLATION.md) guide for step-by-step instructions on how to audit, refine, or add new languages.
|
||||
|
||||
|
||||
---
|
||||
|
||||
### 📖 Documentation & Links
|
||||
|
||||
- **[CHANGELOG.md](CHANGELOG.md)**: Full version history for the extension and relay server.
|
||||
- **[TESTED_SERVICES.md](TESTED_SERVICES.md)**: Detailed compatibility matrix of tested streaming platforms and known limitations.
|
||||
- **[TRANSLATION.md](website/TRANSLATION.md)**: Translation and localization guide for contributors.
|
||||
- **[PRIVACY.md](PRIVACY.md)**: Data Handling and Privacy Policy.
|
||||
- **[CONTRIBUTING.md](CONTRIBUTING.md)**: How to help make KoalaSync better.
|
||||
- **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)**: Our community standards and expectations.
|
||||
- **[HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md)**: Step-by-step walkthrough of the complete user flow.
|
||||
- **[ARCHITECTURE.md](docs/ARCHITECTURE.md)**: Deep-dive into the two-phase sync and heartbeat logic.
|
||||
- **[SECURITY.md](SECURITY.md)**: Disclosure policy and security practices.
|
||||
- **[Caddyfile.example](Caddyfile.example)**: Production Caddy configuration for website and relay.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub>Built with ❤️ by <a href="https://github.com/Shik3i">Shik3i</a>. KoalaSync is Open Source under the <a href="LICENSE">MIT License</a>.</sub>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Security Policy
|
||||
|
||||
KoalaSync is built on a **zero-persistence, privacy-first** architecture. We take security seriously and appreciate responsible disclosure of vulnerabilities.
|
||||
|
||||
---
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Only the latest stable release receives security patches.
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| Latest release | :white_check_mark: Active |
|
||||
| Older versions | :x: Unsupported |
|
||||
|
||||
Users on older versions are encouraged to update. The server enforces a minimum client version via `MIN_VERSION`.
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
The following components are within scope for security reports:
|
||||
|
||||
| Component | Examples |
|
||||
|-----------|----------|
|
||||
| **Relay Server** (`server/`) | Authentication bypass, rate-limit evasion, room hijacking, DoS vectors |
|
||||
| **Browser Extension** (`extension/`) | XSS via content scripts, privilege escalation, data exfiltration, tab snooping |
|
||||
| **WebSocket Protocol** | Message injection, replay attacks, man-in-the-middle (WSS bypass) |
|
||||
| **Website** (`website/`) | XSS, CSP bypass, invitation-hash leaks |
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Theoretical attacks requiring physical device access
|
||||
- Social engineering or phishing
|
||||
- Denial of service via resource exhaustion on self-hosted instances
|
||||
- Vulnerabilities in third-party browser extensions or websites
|
||||
|
||||
---
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
> [!CAUTION]
|
||||
> **Do NOT open a public GitHub issue for security vulnerabilities.** Public disclosure before a patch is available puts users at risk.
|
||||
|
||||
Instead, email the project maintainer privately:
|
||||
|
||||
**`koalasync_admin@koalamail.rocks`**
|
||||
|
||||
Encrypt sensitive findings with our PGP key (available on request).
|
||||
|
||||
### What to Include
|
||||
|
||||
- **Affected component**: Server / Extension / Website / Protocol
|
||||
- **Steps to reproduce**: Clear, minimal steps to trigger the vulnerability
|
||||
- **Impact**: What an attacker could achieve (data access, privilege escalation, etc.)
|
||||
- **Environment**: Browser version, extension version, server configuration
|
||||
- **Suggested fix** (optional): If you have ideas for a patch
|
||||
|
||||
### What to Expect
|
||||
|
||||
| Timeline | Action |
|
||||
|----------|--------|
|
||||
| **Within 48 hours** | Acknowledgment of your report |
|
||||
| **Within 7 days** | Initial assessment and severity confirmation |
|
||||
| **As needed** | Collaborative discussion for clarification |
|
||||
| **After patch** | Notification that the fix is deployed |
|
||||
| **After rollout** | Public acknowledgment in release notes (or anonymity if preferred) |
|
||||
|
||||
---
|
||||
|
||||
## Architecture & Threat Model
|
||||
|
||||
KoalaSync's security is grounded in its architecture:
|
||||
|
||||
- **RAM-only relay**: No database, no persistent logs. All session data evaporates on disconnect.
|
||||
- **Keyed SHA-256 room password hashes**: Plaintext passwords are never stored. Room passwords are held only as in-memory HMAC-SHA256 hashes for the short room lifetime, with brute-force protection: 5 attempts → 15-minute IP lockout.
|
||||
- **Rate limiting**: Connection rate (IP-based, 60s window) and event rate (per-socket, 10s window).
|
||||
- **URL-hash credential isolation**: Invitation credentials live in the URL fragment (`#join:...`) — never sent to the web server.
|
||||
- **Strict CSP**: `default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'`.
|
||||
- **No third-party requests**: Zero CDNs, fonts, analytics, or external scripts.
|
||||
|
||||
If you find a way to bypass any of these protections, we want to know about it.
|
||||
|
||||
---
|
||||
|
||||
## Responsible Disclosure
|
||||
|
||||
We follow the principle of **coordinated vulnerability disclosure**:
|
||||
|
||||
1. You report privately.
|
||||
2. We investigate and develop a patch.
|
||||
3. We deploy to the Chrome Web Store, Firefox Add-ons, and Docker registry.
|
||||
4. We credit you publicly (unless you prefer to remain anonymous).
|
||||
|
||||
We do not pursue legal action against researchers who act in good faith and follow this disclosure process.
|
||||
@@ -1,43 +0,0 @@
|
||||
# KoalaSync Protocol Synchronization Guide
|
||||
|
||||
## Why do we need to sync?
|
||||
KoalaSync uses a "Single Source of Truth" for its communication protocol constants located in the root `shared/` directory. However, Chrome Extensions (Manifest V3) are strictly sandboxed and **cannot load or import files from outside their root directory**.
|
||||
|
||||
To ensure that the extension and the relay server are always using the exact same event names and protocol versions, we maintain a mirrored copy of the shared files within the `extension/shared/` folder.
|
||||
|
||||
## When should you run the sync script?
|
||||
You MUST run the synchronization script in any of the following scenarios:
|
||||
1. **After a fresh `git clone` or `git pull`** (as the synced files are ignored by git).
|
||||
2. **After modifying** `shared/constants.js`.
|
||||
3. **After modifying** `shared/blacklist.js`.
|
||||
4. **Before committing** changes to the repository if any protocol-related files were touched.
|
||||
5. **Before deploying** the server or releasing the extension.
|
||||
|
||||
## How to sync
|
||||
|
||||
### On Windows
|
||||
Run the batch script from the repository root:
|
||||
```powershell
|
||||
.\scripts\sync-constants.bat
|
||||
```
|
||||
|
||||
### On macOS / Linux
|
||||
Run the shell script from the repository root:
|
||||
```bash
|
||||
./scripts/sync-constants.sh
|
||||
```
|
||||
|
||||
## What does it do?
|
||||
The script performs the following actions:
|
||||
1. Ensures the `extension/shared/` directory exists.
|
||||
2. Copies `shared/constants.js` to `extension/shared/constants.js`.
|
||||
3. Copies `shared/blacklist.js` to `extension/shared/blacklist.js`.
|
||||
|
||||
## Protocol Versioning
|
||||
As of v1.0.0-RC5, the system enforces a strict `protocolVersion` check during the `JOIN_ROOM` handshake.
|
||||
- The version is defined in `shared/constants.js`.
|
||||
- If the extension and server versions mismatch, the server will reject the connection with an `Incompatible protocol version` error.
|
||||
- **Always run the sync script** after bumping the version number to ensure both components are updated.
|
||||
|
||||
> [!CAUTION]
|
||||
> **NEVER** edit the files inside `extension/shared/` directly. They will be overwritten the next time the sync script is run. Always edit the files in the root `shared/` directory and then run the sync script.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Tested Streaming Services
|
||||
|
||||
This document tracks which streaming platforms and media servers have been tested with KoalaSync.
|
||||
|
||||
| Service | Sync Works | Media Title | Episode Auto-Sync | Notes |
|
||||
|---------|:----------:|:-----------:|:-----------------:|-------|
|
||||
| **YouTube** | ✅ Full | ✅ Full | ❌ | Best-in-class support. Native player API, reliable title detection. |
|
||||
| **Twitch** | ✅ Full | ✅ Full | ❌ | Live-only platform. Tested on regular streams. |
|
||||
| **Netflix** | ✅ Full | ⚠️ Hidden | ⚠️ Manual | Sync works perfectly, but DRM prevents media title detection. Episode transitions may require manual lobby. |
|
||||
| **Emby** | ✅ Full | ✅ Full | ✅ Full | Self-hosted. Full HTML5 player access. |
|
||||
| **Jellyfin** | ✅ Full | ✅ Full | ✅ Full | Self-hosted. Full HTML5 player access. |
|
||||
| **Plex** | Not tested | Not tested | Not tested | Community reports indicate compatibility via HTML5 player mode. |
|
||||
| **Disney+** | Not tested | Not tested | Not tested | Widevine DRM may restrict title detection similar to Netflix. |
|
||||
| **Prime Video** | ⚠️ Partial | ⚠️ Partial | ❌ | Video elements detected (2 on page, picks larger one). Playback state + time readable. However, the preview/trailer video may be selected instead of the main content. Play/Pause commands may not reach the correct player. Title detection from MediaSession API may work for some content. |
|
||||
| **HBO Max / Max** | Not tested | Not tested | Not tested | — |
|
||||
| **Crunchyroll** | Not tested | Not tested | Not tested | — |
|
||||
| **Vimeo** | Not tested | Not tested | Not tested | — |
|
||||
| **Dailymotion** | Not tested | Not tested | Not tested | — |
|
||||
| **ARD / ZDF Mediathek** | Not tested | Not tested | Not tested | German public broadcasters. HTML5 players expected to work. |
|
||||
|
||||
## Legend
|
||||
|
||||
| Symbol | Meaning |
|
||||
|--------|---------|
|
||||
| ✅ Full | Works without limitations. |
|
||||
| ⚠️ Partial | Works with caveats (see Notes). |
|
||||
| ❌ N/A | Not applicable or not supported. |
|
||||
|
||||
## How to Contribute
|
||||
|
||||
Tested a service that's not listed? Found different behavior than documented?
|
||||
|
||||
1. Test KoalaSync on the service with two browser profiles
|
||||
2. Use the extension's **Dev tab** to check `readyState`, `currentTime`, and media title
|
||||
3. Open a GitHub issue or PR updating this table
|
||||
|
||||
## Technical Background
|
||||
|
||||
KoalaSync works on any website with a **standard HTML5 `<video>` element** that allows script injection.
|
||||
|
||||
Limited functionality on certain platforms is typically caused by:
|
||||
- **DRM/Copy Protection** (e.g., Widevine on Netflix, Disney+) which restricts access to media metadata like title and playback state
|
||||
- **Shadow DOM encapsulation** that hides video elements from content scripts
|
||||
- **Strict Content Security Policies** (CSP) that block script injection
|
||||
|
||||
Websites with heavily obfuscated custom players (e.g., complex Shadow DOM, iframe isolation) may require platform-specific workarounds in `content.js`.
|
||||
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 889 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,18 @@
|
||||
services: # Top-level key defining all containers in this Compose file
|
||||
koala-sync: # Name of the KoalaSync service
|
||||
image: ghcr.io/shik3i/koalasync:latest # Pulls the latest KoalaSync image from GitHub Container Registry
|
||||
container_name: KoalaSync # Sets a fixed container name instead of an auto-generated one
|
||||
restart: always # Always restart the container if it stops or if Docker starts
|
||||
environment: # Environment variables passed into the container
|
||||
- TZ=Europe/Berlin # Sets the timezone inside the container
|
||||
- PORT=3000 # Port KoalaSync listens on inside the container
|
||||
- MIN_VERSION=1.0.0 # Minimum client version allowed to connect
|
||||
- MAX_ROOMS=100 # Maximum number of rooms that can exist
|
||||
- MAX_PEERS_PER_ROOM=25 # Maximum number of peers allowed per room
|
||||
- ADMIN_METRICS_TOKEN= # Optional: enables aggregate-only /health metrics with Bearer auth
|
||||
pids_limit: 2048 # Limits the container to 2048 process IDs for safety
|
||||
networks: # Attaches the service to the networks listed below
|
||||
- caddy_net # Joins the pre-existing Caddy network for reverse proxying
|
||||
networks: # Top-level networks definition
|
||||
caddy_net: # Network name as referenced by the service
|
||||
external: true # Marks the network as managed outside of Compose (created by Caddy)
|
||||
@@ -0,0 +1,22 @@
|
||||
services: # Top-level key defining all containers in this Compose file
|
||||
koala-sync: # Name of the KoalaSync service
|
||||
image: ghcr.io/shik3i/koalasync:latest # Pulls the latest KoalaSync image from GitHub Container Registry
|
||||
container_name: KoalaSync # Sets a fixed container name instead of an auto-generated one
|
||||
restart: always # Always restart the container if it stops or if Docker starts
|
||||
ports: # Exposes the container port to the host
|
||||
- "3000:3000" # Maps host port 3000 to container port 3000
|
||||
environment: # Environment variables passed into the container
|
||||
- TZ=Europe/Berlin # Sets the timezone inside the container
|
||||
- PORT=3000 # Port KoalaSync listens on inside the container
|
||||
- MIN_VERSION=1.0.0 # Minimum client version allowed to connect
|
||||
- MAX_ROOMS=100 # Maximum number of rooms that can exist
|
||||
- MAX_PEERS_PER_ROOM=25 # Maximum number of peers allowed per room
|
||||
- ADMIN_METRICS_TOKEN= # Optional: enables aggregate-only /health metrics with Bearer auth
|
||||
pids_limit: 2048 # Limits the container to 2048 process IDs for safety
|
||||
networks: # Attaches the service to the networks listed below
|
||||
bond0_network: # Network name as referenced by the service
|
||||
ipv4_address: 192.168.1.XXX # Static IPv4 address for the KoalaSync container
|
||||
networks: # Top-level networks definition
|
||||
bond0_network: # Network name inside Compose
|
||||
external: true # Marks the network as managed outside of Compose
|
||||
name: bond0 # Name of the pre-existing network on the Docker host
|
||||
@@ -1,4 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
server:
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# KoalaSync Architecture
|
||||
|
||||
This document describes the communication flows and internal logic of the KoalaSync system.
|
||||
|
||||
## 1. Extension Startup & Connection
|
||||
- **Initialization**: On startup, `background.js` reads settings (Server URL, Username, Last Room) from `chrome.storage.sync`.
|
||||
- **WebSocket Handshake**:
|
||||
1. Background creates a `new WebSocket` to `/socket.io/?EIO=4&transport=websocket&version=1.0.0`.
|
||||
2. Server performs security checks:
|
||||
- **IP Rate Limit**: Checks if the IP has exceeded connection limits.
|
||||
- **Protocol Version**: Client must match the server's protocol (currently `1.0.0`).
|
||||
3. Server responds with Engine.IO handshake (`0`) and the client joins the namespace (`40`).
|
||||
- **Room Join**: Background emits `JOIN_ROOM` containing `roomId`, `password`, `peerId`, and `username`.
|
||||
- **Deduplication**: If a user joins with a `peerId` that already has an active socket, the server kills the old socket to prevent "Ghost Peers". Deduplication re-validates after acquiring the room creation lock to avoid kicking the wrong socket during concurrent joins.
|
||||
|
||||
## 2. Media Event Synchronization
|
||||
When a user interacts with a video:
|
||||
1. **Detection**: `content.js` listens to native events (`play`, `pause`, `seeked`) on the `<video>` element, including videos inside Shadow DOM (YouTube, Netflix, etc.).
|
||||
2. **Prevention of Loops**: Uses an `expectedEvents` Set to distinguish between user actions and programmatic actions. Expected events are consumed on match and expire via timeout. Timeout IDs are cleaned up immediately to prevent memory leaks.
|
||||
3. **Reporting**: `content.js` sends a `CONTENT_EVENT` to `background.js`.
|
||||
4. **Relay**: The Server forwards the event to all other peers in the room.
|
||||
5. **Execution**: Remote peers receive the command via `SERVER_COMMAND` (which includes the original `senderId` for correct ACK routing) and call `video.play()`, `video.pause()`, or `video.currentTime = targetTime`.
|
||||
6. **ACK Routing**: `content.js` echoes the `commandSenderId` back in `CMD_ACK`, ensuring the `EVENT_ACK` is routed to the correct initiating peer even when multiple commands arrive concurrently.
|
||||
|
||||
## 3. Two-Phase Force Sync
|
||||
Ensures all peers are buffered and synchronized before resuming:
|
||||
1. **Prepare**: Initiator sends `FORCE_SYNC_PREPARE` with the target timestamp.
|
||||
2. **Buffer**: Peers seek and pause. Once buffered (`readyState >= 3`), they send a `FORCE_SYNC_ACK`. (Note: `content.js` limits polling to 8000ms).
|
||||
3. **Execute**: Once the Initiator collects ACKs (or after an 8.5s timeout), they send `FORCE_SYNC_EXECUTE`.
|
||||
> [!IMPORTANT]
|
||||
> **Network Transit Buffer Rule**: The orchestrator (`background.js`) must always use a timeout at least 500ms longer than the worker (`content.js`) to account for IPC and network transit time. Never align them exactly 1:1, as this will introduce a race condition on slow connections.
|
||||
4. **Resume**: All peers call `play()` simultaneously.
|
||||
|
||||
## 4. Episode Auto-Sync
|
||||
Maintains continuous synchronized viewing when watching series:
|
||||
1. **Detection**: `content.js` monitors the Media Session API for title changes.
|
||||
2. **Lobby Creation**: When a new title is detected, the peer initiates an `EPISODE_LOBBY` and broadcasts the new title.
|
||||
3. **Wait State**: All peers freeze their video until they have also loaded the exact same title.
|
||||
4. **Mid-Lobby Joins**: If a new user joins the room during an active lobby, the lobby initiator broadcasts the active lobby state so the newcomer can sync up.
|
||||
5. **Resume**: Once all peers report `EPISODE_READY`, the lobby is resolved and playback resumes perfectly.
|
||||
|
||||
## 5. Peer Lifecycle & Dual Heartbeat
|
||||
To maintain a clean room state and eliminate "Ghost Peers":
|
||||
- **Session Heartbeat (Background)**: Every 30 seconds, `background.js` sends an "I'm alive" signal to the server. This keeps you in the room even if no video is playing.
|
||||
- **Video Heartbeat (Content)**: Every 15 seconds, `content.js` sends current playback metadata (time, title, state) if a video is found.
|
||||
- **Server Pruning**: The server runs a "Reaper" every 2 minutes. If a peer has sent **zero** activity (no events and no heartbeats) for 5 minutes, they are forcefully disconnected.
|
||||
- **Immediate Cleanup**: Rooms are deleted instantly when the last peer leaves or disconnects.
|
||||
- **Reconnect Strategy**: Aggressive backoff — 500ms base, 1.5x multiplier, capped at 5s. Max 20 attempts before marking as failed. Events are queued during disconnect and flushed after namespace rejoin.
|
||||
|
||||
> [!CAUTION]
|
||||
> **Identity Rule**: Differentiate between `peerId` and `socket.id`. Use `socket.id` exclusively for ephemeral transport routing on the server. Use `peerId` exclusively for identity, state management, and room tracking across the stack.
|
||||
|
||||
## 6. Broadcast Protocol & Routing
|
||||
KoalaSync uses a megaphone routing approach to minimize server logic:
|
||||
- **`emit()` Broadcast Behavior**: Any `emit()` from the extension client is unconditionally broadcast to **all other peers in the room**. It is not a direct message.
|
||||
- **Storm Prevention**: When dispatching state updates in response to a new user joining (e.g., an active lobby state), ensure ONLY the initiator (or a designated leader) calls `emit()` to prevent $O(N)$ broadcast storms.
|
||||
|
||||
## 7. Security & Stability
|
||||
- **Service Worker Lifecycle**: Uses `chrome.alarms` (30s interval) to prevent the Manifest V3 service worker from suspending while in an active room. On wake, runtime state is restored from `chrome.storage.session` via `ensureState()`.
|
||||
- **Reconnect Visualization**: Badge shows "..." (orange) during reconnect. Popup displays "Reconnecting..." with attempt counter.
|
||||
- **Rate Limiting**: Server-side per-socket and per-IP rate limits to prevent sync-spamming or DoS. Real client IP extracted via `x-forwarded-for` header behind proxies/CDNs.
|
||||
- **Room Creation Lock**: Per-room mutex prevents race conditions when multiple peers join a new room simultaneously.
|
||||
- **CORS**: Allows `chrome-extension://` origins for WebSocket fallback compatibility.
|
||||
- **Message Buffer**: `maxHttpBufferSize` set to 4KB to accommodate large `JOIN_ROOM` payloads.
|
||||
- **Process Guards**: `uncaughtException` and `unhandledRejection` handlers prevent silent server crashes.
|
||||
- **Noise Filtering**: Uses a curated blacklist of domains (Search Engines, Social Media) to declutter the "Target Tab" selector in the popup.
|
||||
- **Diagnostics**: A "Dev" tab provides real-time access to the underlying `<video>` state (`readyState`, `paused`, `currentTime`) for easier troubleshooting.
|
||||
|
||||
## 8. Constant Synchronization & Consistency
|
||||
To maintain a "Single Source of Truth" across the server and extension without using a bundler:
|
||||
- **Relay Server & Extension Modules**: `background.js` and `popup.js` import constants directly from `shared/constants.js`.
|
||||
- **Content Scripts**: To ensure zero-latency execution, `content.js` uses a synchronized copy of `EVENTS` and constants.
|
||||
- **Automation**: The `node scripts/build-extension.js` script automatically injects these constants into `content.js` during the build process, eliminating the risk of manual mirror mismatch.
|
||||
- **Verification**: Any protocol change is automatically propagated across the stack by running the build script.
|
||||
@@ -0,0 +1,213 @@
|
||||
# KoalaSync — How It Works (Step-by-Step)
|
||||
|
||||
This guide walks through the complete user flow of KoalaSync, from creating a room to synchronized playback. It is designed for **store reviewers**, **end-users**, and **manual testers** to understand exactly what happens at each step, what data is sent, and where it goes.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Installing the Extension
|
||||
|
||||
1. Download the extension from the [Releases](https://github.com/Shik3i/KoalaSync/releases) page (or install from the Chrome Web Store / Firefox Add-ons).
|
||||
2. The extension adds a small icon to your browser toolbar.
|
||||
3. On first install, a unique 8-character **Peer ID** is generated locally and stored in `chrome.storage.local`. This ID is never sent to any external service — it only travels to the relay server when you join a room.
|
||||
|
||||
> **What's stored locally**: `peerId` (8-char hex), `username` (customizable, defaults to a readable adjective-noun pair), `serverUrl`, `filterNoise` preference. All stored via `chrome.storage.sync` and `chrome.storage.local`.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Connecting to the Relay Server
|
||||
|
||||
When you open the extension popup, the background service worker connects to the relay server:
|
||||
|
||||
1. **WebSocket Handshake**: `background.js` opens a WebSocket to `wss://syncserver.koalastuff.net/socket.io/?EIO=4&transport=websocket`.
|
||||
2. **Security Checks** (server-side):
|
||||
- The server checks the client's **IP rate limit** (max 10 connections per 60 seconds).
|
||||
- The server validates the **authentication token** (hardcoded in `shared/constants.js`) to verify this is a legitimate KoalaSync client.
|
||||
- The server checks the **extension version** against `MIN_VERSION` to reject outdated clients.
|
||||
3. **Connection Established**: The server responds with an Engine.IO handshake (`0{...}`), followed by a Socket.IO namespace join (`40`). The connection status dot in the popup turns green.
|
||||
|
||||
> **Data sent to server**: `token` (authentication), `version` (e.g., `1.3.1`). No personal data is transmitted during connection.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Creating a Room
|
||||
|
||||
Click **"Create Room"** in the popup's Room tab:
|
||||
|
||||
1. The extension generates a random Room ID (e.g., `happy-koala-42`) and a random 6-character password.
|
||||
2. Room IDs are restricted to `[a-zA-Z0-9-]` (alphanumeric + hyphens only).
|
||||
3. The extension emits a `JOIN_ROOM` event to the server.
|
||||
|
||||
> **Data sent in `JOIN_ROOM`**:
|
||||
> ```json
|
||||
> {
|
||||
> "roomId": "happy-koala-42",
|
||||
> "password": "x7k2m9",
|
||||
> "peerId": "a1b2c3d4",
|
||||
> "username": "MyName",
|
||||
> "tabTitle": "YouTube - My Video",
|
||||
> "protocolVersion": "1.0.0"
|
||||
> }
|
||||
> ```
|
||||
|
||||
4. **Server-side processing**:
|
||||
- All fields are **sanitized**: `roomId` is stripped of invalid characters and clamped to 64 chars; `peerId` clamped to 16 chars; `password` clamped to 128 chars; `username` clamped to 30 chars.
|
||||
- The server **hashes the password** with a keyed SHA-256 HMAC and stores only that hash in RAM (the plaintext is never stored).
|
||||
- A new room object is created in memory with the peer's data.
|
||||
- The server responds with `ROOM_DATA` containing the list of peers in the room.
|
||||
|
||||
5. **Popup updates**: The Room tab switches to the "Active Room" view, showing your Room ID and an invitation link.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Sharing an Invitation Link
|
||||
|
||||
Click the **📋 Copy** button next to the invite link:
|
||||
|
||||
1. The extension constructs a URL in this format:
|
||||
```
|
||||
https://sync.koalastuff.net/join.html#join:<roomId>:<password>:<serverFlag>:<encodedServerUrl>
|
||||
```
|
||||
- `serverFlag`: `0` for official server, `1` for custom server.
|
||||
- `encodedServerUrl`: Only populated if using a custom server.
|
||||
|
||||
2. **Important**: The room credentials are in the **URL hash** (`#`), which means they are **never sent to the web server** — the hash fragment stays entirely in the browser. The landing page server never sees your room ID or password.
|
||||
|
||||
3. Send this link to your friend via any messaging app.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Your Friend Opens the Invitation Link
|
||||
|
||||
When your friend opens the link in their browser:
|
||||
|
||||
1. **`join.html` loads** on `sync.koalastuff.net`. The page displays "INVITATION DETECTED" with the Room ID.
|
||||
|
||||
2. **Extension detection**: The page checks for `document.documentElement.dataset.koalasyncInstalled`, which is set by `bridge.js` (a content script injected only on `sync.koalastuff.net`).
|
||||
|
||||
3. **If the extension IS installed**:
|
||||
- The page shows "Joining room automatically..."
|
||||
- After 500ms, the page dispatches a `KOALASYNC_JOIN_REQUEST` custom DOM event with `{ roomId, password, useCustomServer, serverUrl }`.
|
||||
- `bridge.js` catches this event and forwards it to `background.js` via `chrome.runtime.sendMessage`.
|
||||
- `background.js` stores the credentials in `chrome.storage.sync` and emits `JOIN_ROOM` to the server.
|
||||
- The server validates the password against the stored keyed SHA-256 HMAC hash.
|
||||
- On success, the server responds with `ROOM_DATA` and broadcasts `PEER_STATUS { status: 'joined' }` to all existing peers.
|
||||
- The join page updates to show "✅ Successfully joined!".
|
||||
|
||||
4. **If the extension is NOT installed**:
|
||||
- The page shows download links (Chrome Web Store / GitHub).
|
||||
- The user installs the extension, returns to the link, and the flow continues from step 3.
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Selecting a Video Tab
|
||||
|
||||
Both users now need to select which browser tab contains the video to sync:
|
||||
|
||||
1. Open a video on any website (YouTube, Twitch, Netflix, etc.).
|
||||
2. In the extension popup → **Sync** tab → use the **"Target Tab"** dropdown.
|
||||
3. The dropdown lists all open tabs, filtered to exclude noise (search engines, social media — configurable via Settings).
|
||||
4. Tabs with a **matching video title** are highlighted with a ⭐ prefix for easy identification.
|
||||
5. Selecting a tab causes `background.js` to set `currentTabId` and inject `content.js` into that tab via `chrome.scripting.executeScript`.
|
||||
|
||||
> **What `content.js` does on injection**: Finds the first `<video>` element on the page and attaches event listeners for `play`, `pause`, `seeked`, and `loadeddata`. (Time and volume state are tracked via a 15-second heartbeat interval, not continuous event listeners). It uses an `expectedEvents` Set to distinguish between user actions and programmatic actions (loop prevention).
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Synchronized Playback
|
||||
|
||||
When User A presses **Play** on their video:
|
||||
|
||||
1. `content.js` detects the native `play` event on the `<video>` element.
|
||||
2. It checks the `expectedEvents` Set — if this event was expected (caused by a remote command), it's consumed silently. If not, it's a **user action**.
|
||||
3. For user actions, `content.js` sends `{ type: 'CONTENT_EVENT', action: 'play', payload: { currentTime, ... } }` to `background.js`.
|
||||
4. `background.js` adds an `actionTimestamp` and emits the `PLAY` event to the server.
|
||||
5. **Server relay**: The server sanitizes all fields (strings clamped, numbers validated, booleans type-checked) and constructs a clean `relayPayload` with `senderId` set to User A's `peerId`. The raw client data is never forwarded directly.
|
||||
6. The server broadcasts the sanitized payload to all other peers in the room.
|
||||
7. User B's `background.js` receives the `PLAY` event and calls `routeToContent()`, which sends a `SERVER_COMMAND` message to User B's `content.js`.
|
||||
8. User B's `content.js` adds `'playing'` to its `expectedEvents` Set (so it won't echo the event back), then calls `video.play()`.
|
||||
|
||||
> **The same flow applies to Pause and Seek**, with Seek additionally sending `targetTime` for the time position.
|
||||
|
||||
---
|
||||
|
||||
## Step 8: Force Sync (Two-Phase Protocol)
|
||||
|
||||
If videos drift out of sync, either user can click **"Force Sync"**:
|
||||
|
||||
### Phase 1 — Prepare
|
||||
1. The initiator's `content.js` captures the current `video.currentTime` as the `targetTime`.
|
||||
2. `background.js` emits `FORCE_SYNC_PREPARE` with `{ targetTime }` to all peers.
|
||||
3. All peers (including the initiator) **pause** their video and **seek** to `targetTime`.
|
||||
4. Each peer's `content.js` polls `video.readyState` until it reaches `≥ 3` (buffered enough to play), with an 8-second timeout.
|
||||
5. Once buffered, each peer sends `FORCE_SYNC_ACK` back.
|
||||
|
||||
### Phase 2 — Execute
|
||||
6. Once all ACKs are received (or after 8.5 seconds), the initiator emits `FORCE_SYNC_EXECUTE`.
|
||||
7. All peers call `video.play()` simultaneously, achieving synchronized playback.
|
||||
|
||||
> **Why two phases?** Without buffering confirmation, peers with slower connections would start playing before they've loaded the target timestamp, causing immediate desync.
|
||||
|
||||
---
|
||||
|
||||
## Step 9: Heartbeat & Peer Health
|
||||
|
||||
While in a room, two heartbeats keep the session alive:
|
||||
|
||||
| Heartbeat | Interval | Source | Purpose |
|
||||
|:----------|:---------|:-------|:--------|
|
||||
| **Background** | 30 seconds | `background.js` | Signals "I'm still connected" and triggers aggressive reconnect (500ms base, max 5s) |
|
||||
| **Content** | 15 seconds | `content.js` | Sends video metadata: `currentTime`, `mediaTitle`, `playbackState`, `volume`, `muted` |
|
||||
|
||||
- **Server Reaper**: Every 2 minutes, the server checks for peers with no activity for 5+ minutes and disconnects them ("dead peer pruning").
|
||||
- **Room Cleanup**: Empty rooms are deleted immediately. Inactive rooms are pruned after 2 hours.
|
||||
|
||||
---
|
||||
|
||||
## Step 10: Leaving a Room
|
||||
|
||||
When a user clicks **"Leave"** or closes their browser:
|
||||
|
||||
1. `background.js` emits `LEAVE_ROOM` (or the WebSocket `disconnect` fires automatically).
|
||||
2. The server calls `removePeerFromRoom()`, which:
|
||||
- Removes the peer from the room's `peers` Set, `peerIds` Map, and `peerData` Map.
|
||||
- Removes the socket from the global `socketToRoom` and `peerToSocket` maps.
|
||||
- Broadcasts `PEER_STATUS { status: 'left' }` to remaining peers.
|
||||
- If the room is now empty, **deletes the room entirely** — no data persists.
|
||||
3. The event rate-limit counter for that socket is also cleaned up.
|
||||
|
||||
> **After disconnect, zero data about the user remains on the server.** There is no database, no log file, no analytics record. The session existed only in RAM and is now gone.
|
||||
|
||||
---
|
||||
|
||||
## Episode Auto-Sync Flow
|
||||
|
||||
When watching a series and an episode ends:
|
||||
|
||||
1. `content.js` monitors the [Media Session API](https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API) for title changes.
|
||||
2. When a new title is detected, the peer broadcasts `EPISODE_LOBBY` with the expected new title.
|
||||
3. All peers' videos freeze. The UI shows an "Episode Lobby" card with peer readiness status.
|
||||
4. Each peer's `content.js` polls for the new title to appear in the `<video>` element's metadata.
|
||||
5. Once a peer detects the matching title, they send `EPISODE_READY`.
|
||||
6. When all peers report ready, the lobby resolves and playback resumes simultaneously.
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Summary
|
||||
|
||||
```
|
||||
┌─────────────┐ WebSocket ┌──────────────┐ WebSocket ┌─────────────┐
|
||||
│ Extension │ ←─────────────────→│ Relay Server │←──────────────────→│ Extension │
|
||||
│ (User A) │ JOIN_ROOM │ (RAM only) │ JOIN_ROOM │ (User B) │
|
||||
│ │ PLAY/PAUSE/SEEK │ │ PLAY/PAUSE/SEEK │ │
|
||||
│ │ FORCE_SYNC_* │ Sanitizes & │ FORCE_SYNC_* │ │
|
||||
│ │ PEER_STATUS │ relays only │ PEER_STATUS │ │
|
||||
│ │ EPISODE_* │ │ EPISODE_* │ │
|
||||
└──────┬──────┘ └───────────────┘ └──────┬──────┘
|
||||
│ │
|
||||
┌────┴─────┐ ┌─────┴────┐
|
||||
│ content │ Listens to <video> events │ content │
|
||||
│ .js │ Controls playback │ .js │
|
||||
└──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
> **The relay server is a pure message forwarder.** It never interprets video content, accesses URLs, or stores session history. All media control happens locally inside each user's browser via the `<video>` DOM API.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Technical Documentation
|
||||
|
||||
This directory contains deep-dives into the KoalaSync protocol and architecture.
|
||||
|
||||
- [HOW_IT_WORKS.md](HOW_IT_WORKS.md): Step-by-step walkthrough of every user flow, from room creation to synchronized playback. Ideal for store reviewers and manual testers.
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md): Communication flows, Dual Heartbeat, and Sync logic.
|
||||
- [SYNC_GUIDE.md](SYNC_GUIDE.md): Protocol constants and sync requirements.
|
||||
@@ -0,0 +1,15 @@
|
||||
# KoalaSync Roadmap
|
||||
|
||||
Dieses Dokument erfasst zukünftige technische Pläne und Optimierungen für das KoalaSync-System.
|
||||
|
||||
---
|
||||
|
||||
## Geplante Optimierungen & Technische Roadmap
|
||||
|
||||
### 1. Behebung des synchronen Sortierungs-Flaschenhalses bei Auth-Failure LRU-Eviction
|
||||
* **Kategorie**: Performance / DoS-Prävention
|
||||
* **Hintergrund**: Der Server schützt Räume vor Brute-Force-Angriffen durch die Nachverfolgung fehlerhafter Anmeldeversuche (`failedAuthAttempts` Map). Bei Erreichen des Limits von 50.000 Einträgen wird ein Bereinigungsverfahren gestartet, das die gesamte Map in ein Array konvertiert und dieses per `Array.from().sort()` sortiert.
|
||||
* **Problem**: Dies blockiert den Node.js-Main-Thread für mehrere Millisekunden und stellt einen potenziellen Denial-of-Service-Vektor (DoS) dar, wenn ein Angreifer gezielt Fehlversuche spamt.
|
||||
* **Geplante Lösung**:
|
||||
- Umstellung auf eine echte, $O(1)$-basierte LRU-Cache-Datenstruktur (z. B. doppelt verkettete Liste in Kombination mit einer Map).
|
||||
- Alternativ: Ein vereinfachtes zeitbasiertes Ablauf-Verfahren oder ein schrittweises Löschen von Segmenten (Chunk-Eviction), um Blockaden des Main-Threads vollständig auszuschließen.
|
||||
@@ -0,0 +1,37 @@
|
||||
# KoalaSync Protocol Synchronization Guide
|
||||
|
||||
## Why do we need to sync?
|
||||
KoalaSync uses a "Single Source of Truth" for its communication protocol constants located in the root `shared/` directory. However, Browser Extensions (Manifest V3) are strictly sandboxed and **cannot load or import files from outside their root directory**.
|
||||
|
||||
To ensure that the extension and the relay server are always using the exact same event names and protocol versions, we maintain a mirrored copy of the shared files within the `extension/shared/` folder.
|
||||
|
||||
## When should you run the build script?
|
||||
You MUST run the build script in any of the following scenarios:
|
||||
1. **After a fresh `git clone` or `git pull`** (as the synced files are ignored by git).
|
||||
2. **After modifying** `shared/constants.js`.
|
||||
3. **After modifying** `shared/blacklist.js`.
|
||||
4. **Before committing** changes to the repository if any protocol-related files were touched.
|
||||
5. **Before deploying** the server or releasing the extension.
|
||||
|
||||
## How to sync
|
||||
|
||||
Run the Node.js build script from the repository root:
|
||||
```bash
|
||||
node scripts/build-extension.js
|
||||
```
|
||||
|
||||
## What does it do?
|
||||
The build script performs the following actions:
|
||||
1. Synchronizes protocol constants by copying `shared/constants.js`, `shared/blacklist.js`, and `shared/README.md` into `extension/shared/`.
|
||||
2. Injects `EVENTS` and `HEARTBEAT_INTERVAL` into `content.js` via marker-based replacement.
|
||||
3. Compiles browser-specific manifest files.
|
||||
4. Packages the final ready-to-publish extension artifacts for Chrome and Firefox into the `dist/` directory.
|
||||
|
||||
## Protocol Versioning
|
||||
The system enforces a strict `protocolVersion` check during the `JOIN_ROOM` handshake.
|
||||
- The version is defined in `shared/constants.js`.
|
||||
- If the extension and server versions mismatch, the server will reject the connection with an `Incompatible protocol version` error.
|
||||
- **Always run the build script** after bumping the version number to ensure both components are updated.
|
||||
|
||||
> [!CAUTION]
|
||||
> **NEVER** edit the files inside `extension/shared/` directly. They will be overwritten the next time the build script is run. Always edit the files in the root `shared/` directory and then run the build script.
|
||||
@@ -0,0 +1,70 @@
|
||||
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",
|
||||
IntersectionObserver: "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",
|
||||
URLSearchParams: "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", "website/build.js"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
require: "readonly",
|
||||
__dirname: "readonly",
|
||||
__filename: "readonly",
|
||||
process: "readonly",
|
||||
module: "readonly",
|
||||
exports: "readonly",
|
||||
Buffer: "readonly"
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -1,6 +1,6 @@
|
||||
# KoalaSync Chrome Extension
|
||||
# KoalaSync Browser Extension
|
||||
|
||||
A Manifest V3 Chrome Extension for synchronized video playback across any website.
|
||||
A Manifest V3 Browser Extension (Chrome & Firefox) for synchronized video playback across any website.
|
||||
|
||||
## Key Features
|
||||
- **Manifest V3**: Optimized Service Worker architecture with session persistence.
|
||||
@@ -8,11 +8,12 @@ A Manifest V3 Chrome Extension for synchronized video playback across any websit
|
||||
- **Smart Peer IDs**: Hexadecimal IDs combined with customizable Usernames for easy identification.
|
||||
- **Dual Heartbeat**: Advanced session tracking (Background) and video synchronization (Content) to prevent ghost sessions.
|
||||
- **Live Diagnostics**: Built-in "Dev" tab for real-time video state debugging (ReadyState, CurrentTime, etc.).
|
||||
- **Dynamic i18n (Multi-Language)**: Fully localized in 6 languages (`en`, `de`, `fr`, `es`, `pt-BR`, `ru`) with auto-detected fallback and dynamic on-the-fly language selectors.
|
||||
|
||||
## Tab Overview
|
||||
1. **Room**: Manage connections, view active peers, and share invitation links.
|
||||
2. **Sync**: Control video playback (Play/Pause/Force Sync) and view recent activity.
|
||||
3. **Settings**: Customize your Username and toggle domain-based Noise Filtering.
|
||||
3. **Settings**: Customize your Username, toggle domain-based Noise Filtering, and switch the App Language.
|
||||
4. **Dev**: Monitor connection status and view real-time video element metadata for debugging.
|
||||
|
||||
## Privacy & Permissions
|
||||
@@ -20,14 +21,20 @@ KoalaSync requires `<all_urls>` permission to detect and interact with video ele
|
||||
- **No Browsing History**: We do not track or store your browsing history.
|
||||
- **State Management**: Sensitive data (Room Passwords) is stored locally using `chrome.storage`.
|
||||
- **Zero Telemetry**: No analytics or external tracking scripts.
|
||||
- **Zero Runtime Dependencies**: The extension is built with pure Vanilla JS and contains no external libraries or tracking scripts, ensuring performance and privacy.
|
||||
|
||||
## Installation
|
||||
1. **Sync Protocol**: Run `./scripts/sync-constants.sh` (macOS/Linux) or `scripts\sync-constants.bat` (Windows) from the root.
|
||||
1. **Prepare Extension**: From the repository root, run:
|
||||
```bash
|
||||
node scripts/build-extension.js
|
||||
```
|
||||
2. Open Chrome and go to `chrome://extensions/`.
|
||||
3. Enable **Developer mode** (top right).
|
||||
4. Click **Load unpacked** and select the `extension` folder.
|
||||
4. Click **Load unpacked** and select the `dist/chrome` folder.
|
||||
|
||||
## Development
|
||||
If you modify `shared/constants.js`, you must synchronize the changes across the extension and server:
|
||||
- **Windows**: Run `scripts\sync-constants.bat`
|
||||
- **Linux/macOS**: Run `scripts/sync-constants.sh`
|
||||
If you modify `shared/constants.js`, you must synchronize the changes by running the build script from the root:
|
||||
```bash
|
||||
node scripts/build-extension.js
|
||||
```
|
||||
This ensures that the `extension/shared` folder is updated with the latest protocol constants.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* global cloneInto */
|
||||
/**
|
||||
* KoalaSync Bridge Script
|
||||
* Injected into koalasync.shik3i.net to facilitate communication between
|
||||
* Injected into sync.koalastuff.net to facilitate communication between
|
||||
* the landing page and the extension.
|
||||
*/
|
||||
|
||||
@@ -22,12 +23,15 @@ window.addEventListener('KOALASYNC_JOIN_REQUEST', (e) => {
|
||||
// 3. Listen for Status Updates from the Extension and relay to Website
|
||||
chrome.runtime.onMessage.addListener((msg) => {
|
||||
if (msg.type === 'JOIN_STATUS') {
|
||||
const event = new CustomEvent('KOALASYNC_STATUS', {
|
||||
detail: {
|
||||
success: msg.success,
|
||||
message: msg.message
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
const detail = { success: msg.success, message: msg.message };
|
||||
// Firefox MV3 content scripts run in an isolated world. When dispatching
|
||||
// a CustomEvent with a detail object, Firefox wraps it in an XrayWrapper
|
||||
// that the page's JavaScript cannot destructure (Permission denied).
|
||||
// cloneInto() exposes the object to the page's context correctly.
|
||||
// Chrome doesn't have this issue — cloneInto() is undefined there.
|
||||
const safeDetail = typeof cloneInto === 'function'
|
||||
? cloneInto(detail, document.defaultView)
|
||||
: detail;
|
||||
window.dispatchEvent(new CustomEvent('KOALASYNC_STATUS', { detail: safeDetail }));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,12 +9,13 @@
|
||||
if (window.koalaSyncInjected && chrome.runtime.id) {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// Context invalidated, proceed with re-injection
|
||||
}
|
||||
window.koalaSyncInjected = true;
|
||||
|
||||
// Local Protocol Constants (Mirroring shared/constants.js)
|
||||
// --- SHARED_EVENTS_INJECT_START ---
|
||||
// This block is automatically updated by /scripts/build-extension.js
|
||||
const EVENTS = {
|
||||
PLAY: "play",
|
||||
PAUSE: "pause",
|
||||
@@ -22,29 +23,226 @@
|
||||
FORCE_SYNC_PREPARE: "force_sync_prepare",
|
||||
FORCE_SYNC_ACK: "force_sync_ack",
|
||||
FORCE_SYNC_EXECUTE: "force_sync_execute",
|
||||
PEER_STATUS: "peer_status"
|
||||
PEER_STATUS: "peer_status",
|
||||
EPISODE_LOBBY: "episode_lobby",
|
||||
EPISODE_READY: "episode_ready"
|
||||
};
|
||||
// --- SHARED_EVENTS_INJECT_END ---
|
||||
|
||||
let expectedEvents = new Set();
|
||||
let expectedTimeouts = {};
|
||||
// Suppresses native event reporting after a programmatic action.
|
||||
// Each entry is a per-type timer (key = 'playing'|'paused'|'seek').
|
||||
// While a timer exists, matching native events are consumed and not relayed.
|
||||
// Timers self-clean after 300ms if the native event never fires.
|
||||
let _suppressTimers = {};
|
||||
|
||||
function expectEvent(state) {
|
||||
expectedEvents.add(state);
|
||||
if (expectedTimeouts[state]) clearTimeout(expectedTimeouts[state]);
|
||||
const timeout = state === 'seek' ? 10000 : 1500;
|
||||
expectedTimeouts[state] = setTimeout(() => {
|
||||
expectedEvents.delete(state);
|
||||
}, timeout);
|
||||
function _setSuppress(state) {
|
||||
if (_suppressTimers[state]) clearTimeout(_suppressTimers[state]);
|
||||
_suppressTimers[state] = setTimeout(() => {
|
||||
delete _suppressTimers[state];
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function _clearSuppress(state) {
|
||||
if (_suppressTimers[state]) {
|
||||
clearTimeout(_suppressTimers[state]);
|
||||
delete _suppressTimers[state];
|
||||
}
|
||||
}
|
||||
|
||||
// --- Seek Relay Filtering ---
|
||||
// Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks
|
||||
// from being relayed to peers as user-initiated seeks.
|
||||
const MIN_SEEK_DELTA = 2.0;
|
||||
let lastReportedSeekTime = null; // last currentTime we relayed as a SEEK
|
||||
let seekDebounceTimer = null; // debounce timer for rapid seek events
|
||||
|
||||
// --- Episode Auto-Sync State ---
|
||||
let lastKnownMediaTitle = null;
|
||||
let episodeTransitionDebounce = null;
|
||||
let _pendingLobbyTitle = null; // Title we're waiting to match (from remote lobby)
|
||||
let lobbyPollTimer = null;
|
||||
let _autoSyncEnabled = true; // Cached setting, updated via storage.onChanged
|
||||
|
||||
// Cache the autoSyncNextEpisode setting
|
||||
chrome.storage.sync.get(['autoSyncNextEpisode'], (data) => {
|
||||
_autoSyncEnabled = data.autoSyncNextEpisode !== false; // default: enabled
|
||||
});
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area === 'sync' && changes.autoSyncNextEpisode) {
|
||||
_autoSyncEnabled = changes.autoSyncNextEpisode.newValue !== false;
|
||||
}
|
||||
});
|
||||
|
||||
function reportLog(message, level = 'info') {
|
||||
chrome.runtime.sendMessage({ type: 'LOG', message, level }).catch(() => {});
|
||||
}
|
||||
|
||||
// --- Helper: find the best video element on the page ---
|
||||
function findVideo() {
|
||||
const videos = document.querySelectorAll('video');
|
||||
return videos.length > 0 ? videos[0] : null;
|
||||
// Prefers larger, visible videos over tiny preview/trailer elements.
|
||||
function findVideo(root = document) {
|
||||
const candidates = Array.from(root.querySelectorAll('video'));
|
||||
|
||||
// Scan likely media hosts even when light-DOM videos exist; many players
|
||||
// expose a tiny preview/ad video outside Shadow DOM and the real player inside.
|
||||
const potentialHosts = root.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i], [id*="media" i], [class*="media" i], [id*="stream" i], [class*="stream" i], ytd-player, netflix-player, emby-player, jellyfin-player, video-player');
|
||||
for (const el of potentialHosts) {
|
||||
if (el.shadowRoot) {
|
||||
const found = findVideo(el.shadowRoot);
|
||||
if (found) candidates.push(found);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
// Multiple videos found → pick the best one
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
let best = null;
|
||||
let bestScore = -1;
|
||||
for (const v of candidates) {
|
||||
if (v.tagName !== 'VIDEO') continue;
|
||||
// Score: visible area + bonus for unmuted + bonus for longer duration
|
||||
const area = (v.videoWidth || v.offsetWidth || 0) * (v.videoHeight || v.offsetHeight || 0);
|
||||
const unmutedBonus = v.muted ? 0 : 100000;
|
||||
const durationBonus = (v.duration && isFinite(v.duration) ? v.duration : 0) * 100;
|
||||
const score = area + unmutedBonus + durationBonus;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = v;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// --- Episode Auto-Sync: Detection ---
|
||||
function getMediaTitle() {
|
||||
return (navigator.mediaSession && navigator.mediaSession.metadata)
|
||||
? navigator.mediaSession.metadata.title
|
||||
: null;
|
||||
}
|
||||
|
||||
// Extract a canonical episode identifier from a title string.
|
||||
// Handles: S01E01, S1E1, S01 - E01, Season 1 Episode 1, "Folge 5", "Episode 5", "Ep. 5", "#5"
|
||||
// Returns null if no episode pattern found.
|
||||
function extractEpisodeId(title) {
|
||||
if (!title || typeof title !== 'string') return null;
|
||||
// S01E01 patterns (with optional spaces, dashes, dots between S and E)
|
||||
const se = title.match(/S(?:eason\s*)?(\d+)[\s\-\.]*E(?:pisode\s*)?(\d+)/i);
|
||||
if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`;
|
||||
// "Episode X", "Folge X", "Ep. X", "#X"
|
||||
const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i);
|
||||
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Returns true if two titles likely refer to the same episode.
|
||||
// Strict: both must have IDs and match, OR neither has IDs and exact match.
|
||||
function sameEpisode(titleA, titleB) {
|
||||
if (!titleA && !titleB) return true; // Both unknown → assume same (backward compat)
|
||||
if (!titleA || !titleB) return false; // One unknown, one known → different
|
||||
const idA = extractEpisodeId(titleA);
|
||||
const idB = extractEpisodeId(titleB);
|
||||
if (idA && idB) return idA === idB; // Both have parseable IDs → compare IDs
|
||||
if (idA || idB) return false; // One has ID, other doesn't → different
|
||||
return titleA === titleB; // Neither has ID → exact string match
|
||||
}
|
||||
|
||||
// Returns true only when we are CERTAIN the episodes differ.
|
||||
// Permissive: only blocks if BOTH titles have parseable IDs AND they differ.
|
||||
// Films, music, unparseable titles always pass through.
|
||||
function isDifferentEpisode(titleA, titleB) {
|
||||
if (!titleA || !titleB) return false; // Unknown → allow
|
||||
const idA = extractEpisodeId(titleA);
|
||||
const idB = extractEpisodeId(titleB);
|
||||
if (!idA || !idB) return false; // At least one unparseable → allow
|
||||
return idA !== idB; // Both parseable → only block if different
|
||||
}
|
||||
|
||||
function checkEpisodeTransition() {
|
||||
const currentTitle = getMediaTitle();
|
||||
const video = findVideo();
|
||||
|
||||
// Only trigger if: we had a previous title, the title changed,
|
||||
// a video exists, and we're near the start of new content.
|
||||
if (lastKnownMediaTitle && currentTitle
|
||||
&& !sameEpisode(currentTitle, lastKnownMediaTitle)
|
||||
&& video
|
||||
&& video.currentTime < 5
|
||||
&& video.readyState >= 1) {
|
||||
onEpisodeTransition(currentTitle);
|
||||
}
|
||||
|
||||
// Always track the latest known title
|
||||
if (currentTitle) lastKnownMediaTitle = currentTitle;
|
||||
}
|
||||
|
||||
function onEpisodeTransition(newTitle) {
|
||||
// Debounce: prevent duplicate fires from multiple signals
|
||||
if (episodeTransitionDebounce) return;
|
||||
episodeTransitionDebounce = setTimeout(() => {
|
||||
episodeTransitionDebounce = null;
|
||||
}, 2000);
|
||||
|
||||
reportLog(`Episode transition detected: "${newTitle}"`, 'info');
|
||||
|
||||
// Do NOT pause here. We notify background.js first.
|
||||
// Background checks the setting; if enabled it creates a lobby
|
||||
// and sends back PAUSE_FOR_LOBBY so we only freeze if the feature is on.
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'EPISODE_CHANGED',
|
||||
payload: { newTitle }
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function checkAndReportLobbyReady(expectedTitle) {
|
||||
const video = findVideo();
|
||||
const currentTitle = getMediaTitle();
|
||||
|
||||
if (video && currentTitle && sameEpisode(currentTitle, expectedTitle)
|
||||
&& video.currentTime < 5 && video.readyState >= 1) {
|
||||
// Match! Pause at start and report ready.
|
||||
if (!video.paused) {
|
||||
_setSuppress('paused');
|
||||
video.pause();
|
||||
}
|
||||
stopLobbyPoll();
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'EPISODE_READY_LOCAL',
|
||||
payload: { title: currentTitle }
|
||||
}).catch(() => {});
|
||||
reportLog(`Episode lobby: Ready for "${currentTitle}"`, 'success');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function startLobbyPoll(expectedTitle) {
|
||||
stopLobbyPoll();
|
||||
_pendingLobbyTitle = expectedTitle;
|
||||
|
||||
// NOTE: Do NOT pause here. Three callers reach this function:
|
||||
// 1. PAUSE_FOR_LOBBY (initiator): already paused by that handler before calling us.
|
||||
// 2. EPISODE_LOBBY (non-initiator): peer may still be on the PREVIOUS episode — pausing
|
||||
// would freeze them mid-episode. The pause happens inside checkAndReportLobbyReady()
|
||||
// only once their title actually matches.
|
||||
// 3. CONTENT_BOOT recovery: same reasoning as (2).
|
||||
|
||||
// Check immediately
|
||||
if (checkAndReportLobbyReady(expectedTitle)) return;
|
||||
|
||||
// Poll every 2 seconds — no log spam, internal only
|
||||
lobbyPollTimer = setInterval(() => {
|
||||
checkAndReportLobbyReady(expectedTitle);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
|
||||
function stopLobbyPoll() {
|
||||
_pendingLobbyTitle = null;
|
||||
if (lobbyPollTimer) {
|
||||
clearInterval(lobbyPollTimer);
|
||||
lobbyPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper: YouTube/Twitch specific actions ---
|
||||
@@ -58,24 +256,24 @@
|
||||
reportLog(`Media Action Error: Invalid seek payload - ${JSON.stringify(data)}`, 'error');
|
||||
return;
|
||||
}
|
||||
data.targetTime = target;
|
||||
data = { ...data, targetTime: target };
|
||||
}
|
||||
|
||||
try {
|
||||
const host = window.location.hostname.toLowerCase();
|
||||
const isYouTube = host.includes('youtube.com');
|
||||
const isTwitch = host.includes('twitch.tv');
|
||||
const isYouTube = host === 'youtube.com' || host.endsWith('.youtube.com');
|
||||
const isTwitch = host === 'twitch.tv' || host.endsWith('.twitch.tv');
|
||||
|
||||
if (isYouTube) {
|
||||
const ytButton = document.querySelector('.ytp-play-button');
|
||||
if (ytButton) {
|
||||
const isCurrentlyPlaying = !video.paused;
|
||||
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
|
||||
expectEvent(action === EVENTS.PLAY ? 'playing' : 'paused');
|
||||
_setSuppress(action === EVENTS.PLAY ? 'playing' : 'paused');
|
||||
ytButton.click();
|
||||
}
|
||||
if (action === EVENTS.SEEK) {
|
||||
expectEvent('seek');
|
||||
_setSuppress('seek');
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
return;
|
||||
@@ -87,11 +285,11 @@
|
||||
if (twitchButton) {
|
||||
const isCurrentlyPlaying = !video.paused;
|
||||
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
|
||||
expectEvent(action === EVENTS.PLAY ? 'playing' : 'paused');
|
||||
_setSuppress(action === EVENTS.PLAY ? 'playing' : 'paused');
|
||||
twitchButton.click();
|
||||
}
|
||||
if (action === EVENTS.SEEK) {
|
||||
expectEvent('seek');
|
||||
_setSuppress('seek');
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
return;
|
||||
@@ -100,19 +298,19 @@
|
||||
|
||||
// Fallback for native HTML5
|
||||
if (action === EVENTS.PLAY) {
|
||||
expectEvent('playing');
|
||||
_setSuppress('playing');
|
||||
video.play().catch((e) => {
|
||||
reportLog(`Playback prevented: ${e.message}`, 'warn');
|
||||
expectedEvents.delete('playing');
|
||||
_clearSuppress('playing');
|
||||
});
|
||||
} else if (action === EVENTS.PAUSE) {
|
||||
expectEvent('paused');
|
||||
_setSuppress('paused');
|
||||
video.pause();
|
||||
} else if (action === EVENTS.SEEK) {
|
||||
expectEvent('seek');
|
||||
_setSuppress('seek');
|
||||
video.currentTime = data.targetTime;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e) {
|
||||
reportLog(`Media Action Error: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
@@ -148,22 +346,44 @@
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.action === 'get_current_time') {
|
||||
const video = findVideo();
|
||||
sendResponse({ currentTime: video ? video.currentTime : undefined });
|
||||
sendResponse({ currentTime: video ? video.currentTime : null });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'SERVER_COMMAND') {
|
||||
const { action, payload } = message;
|
||||
let actionCompleted = false;
|
||||
|
||||
// Guard: Don't execute sync commands if peers are on different episodes.
|
||||
// Only active when autoSyncNextEpisode setting is enabled (default: on).
|
||||
// Only blocks when BOTH sides have parseable S01E01-style IDs that differ.
|
||||
// Films and unparseable titles always pass through.
|
||||
const syncActions = [EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK,
|
||||
EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE];
|
||||
if (_autoSyncEnabled && syncActions.includes(action)) {
|
||||
const senderTitle = payload?.mediaTitle;
|
||||
const myTitle = getMediaTitle();
|
||||
if (isDifferentEpisode(senderTitle, myTitle)) {
|
||||
reportLog(`Episode mismatch: sender="${senderTitle || '?'}" vs mine="${myTitle || '?'}" — skipping ${action}. Disable "Auto-Sync next Episode" in settings if this causes issues.`, 'warn');
|
||||
if (action !== EVENTS.FORCE_SYNC_PREPARE && action !== EVENTS.FORCE_SYNC_EXECUTE) {
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (action === EVENTS.PLAY) {
|
||||
tryMediaAction(EVENTS.PLAY);
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
|
||||
actionCompleted = true;
|
||||
} else if (action === EVENTS.PAUSE) {
|
||||
tryMediaAction(EVENTS.PAUSE);
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
|
||||
actionCompleted = true;
|
||||
} else if (action === EVENTS.SEEK) {
|
||||
tryMediaAction(EVENTS.SEEK, payload);
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
|
||||
actionCompleted = true;
|
||||
} else if (action === EVENTS.FORCE_SYNC_PREPARE) {
|
||||
if (!payload || payload.targetTime === undefined) return;
|
||||
const video = findVideo();
|
||||
@@ -172,22 +392,119 @@
|
||||
reportLog(`Media Action Error: Invalid force sync payload - ${JSON.stringify(payload)}`, 'error');
|
||||
return;
|
||||
}
|
||||
expectEvent('paused');
|
||||
expectEvent('seek');
|
||||
_setSuppress('paused');
|
||||
_setSuppress('seek');
|
||||
video.pause();
|
||||
video.currentTime = payload.targetTime;
|
||||
pollSeekReady(payload.targetTime).then((ready) => {
|
||||
if (ready) chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' });
|
||||
});
|
||||
chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' }).catch(() => {});
|
||||
if (ready) {
|
||||
scheduleProactiveHeartbeat();
|
||||
} else {
|
||||
reportLog('Force Sync: Seek ready timeout, proceeding anyway', 'warn');
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
} else if (action === EVENTS.FORCE_SYNC_EXECUTE) {
|
||||
stopLobbyPoll();
|
||||
tryMediaAction(EVENTS.PLAY);
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp });
|
||||
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
|
||||
actionCompleted = true;
|
||||
}
|
||||
|
||||
if (actionCompleted) {
|
||||
scheduleProactiveHeartbeat();
|
||||
}
|
||||
}
|
||||
|
||||
// Episode Auto-Sync: Lobby notification from background
|
||||
if (message.type === 'EPISODE_LOBBY') {
|
||||
const expectedTitle = message.expectedTitle;
|
||||
if (expectedTitle) {
|
||||
reportLog(`Episode lobby received: waiting for "${expectedTitle}"`, 'info');
|
||||
startLobbyPoll(expectedTitle);
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Episode Auto-Sync: Lobby cancelled by background
|
||||
if (message.type === 'EPISODE_LOBBY_CANCEL') {
|
||||
stopLobbyPoll();
|
||||
sendResponse({ status: 'ok' });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Episode Auto-Sync: Background confirmed lobby created, pause the video
|
||||
if (message.type === 'PAUSE_FOR_LOBBY') {
|
||||
const video = findVideo();
|
||||
if (video && !video.paused) {
|
||||
_setSuppress('paused');
|
||||
video.pause();
|
||||
}
|
||||
// Start lobby poll now that we know the feature is enabled
|
||||
if (message.expectedTitle) {
|
||||
startLobbyPoll(message.expectedTitle);
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'GET_VIDEO_STATE') {
|
||||
const video = findVideo();
|
||||
|
||||
const platform = (() => {
|
||||
const h = window.location.hostname.toLowerCase();
|
||||
if (h === 'youtube.com' || h.endsWith('.youtube.com')) return 'YouTube';
|
||||
if (h === 'twitch.tv' || h.endsWith('.twitch.tv')) return 'Twitch';
|
||||
if (h === 'netflix.com' || h.endsWith('.netflix.com')) return 'Netflix';
|
||||
if (h === 'primevideo.com' || h.endsWith('.primevideo.com') || /(^|\.)amazon\.(com\.[a-z]{2}|co\.[a-z]{2}|[a-z]{2,})$/.test(h)) return 'Prime Video';
|
||||
if (h === 'disneyplus.com' || h.endsWith('.disneyplus.com')) return 'Disney+';
|
||||
if (h === 'hulu.com' || h.endsWith('.hulu.com')) return 'Hulu';
|
||||
if (h === 'hbomax.com' || h.endsWith('.hbomax.com') || h === 'max.com' || h.endsWith('.max.com')) return 'Max/HBO';
|
||||
if (h === 'vimeo.com' || h.endsWith('.vimeo.com')) return 'Vimeo';
|
||||
if (h === 'dailymotion.com' || h.endsWith('.dailymotion.com')) return 'Dailymotion';
|
||||
return 'Generic';
|
||||
})();
|
||||
|
||||
const networkStates = ['NETWORK_EMPTY', 'NETWORK_IDLE', 'NETWORK_LOADING', 'NETWORK_NO_SOURCE'];
|
||||
const readyStates = ['HAVE_NOTHING', 'HAVE_METADATA', 'HAVE_CURRENT_DATA', 'HAVE_FUTURE_DATA', 'HAVE_ENOUGH_DATA'];
|
||||
|
||||
const videoCount = document.querySelectorAll('video').length;
|
||||
const inShadowDom = (() => {
|
||||
let el = video;
|
||||
while (el) {
|
||||
if (el.toString() === '[object ShadowRoot]') return true;
|
||||
el = el.parentNode;
|
||||
}
|
||||
// Also check if any potential host has shadow root (even if no video found)
|
||||
if (!video) {
|
||||
const hosts = document.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i]');
|
||||
for (const host of hosts) {
|
||||
if (host.shadowRoot) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
// Build multi-video summary for debug reports
|
||||
const allVideos = [];
|
||||
const allVideoEls = document.querySelectorAll('video');
|
||||
for (let i = 0; i < allVideoEls.length; i++) {
|
||||
const v = allVideoEls[i];
|
||||
allVideos.push({
|
||||
index: i,
|
||||
width: v.videoWidth || v.offsetWidth || 0,
|
||||
height: v.videoHeight || v.offsetHeight || 0,
|
||||
muted: v.muted,
|
||||
paused: v.paused,
|
||||
duration: (v.duration && isFinite(v.duration)) ? Math.round(v.duration) : 0,
|
||||
readyState: v.readyState,
|
||||
src: (v.currentSrc || v.src || '').substring(0, 80),
|
||||
selected: v === video
|
||||
});
|
||||
}
|
||||
|
||||
if (video) {
|
||||
const dataAttributes = {};
|
||||
if (video.attributes) {
|
||||
@@ -205,30 +522,78 @@
|
||||
artwork: Array.from(navigator.mediaSession.metadata.artwork || []).map(a => a.src)
|
||||
} : null;
|
||||
|
||||
const errorInfo = video.error ? {
|
||||
code: video.error.code,
|
||||
message: video.error.message
|
||||
} : null;
|
||||
|
||||
sendResponse({
|
||||
found: true,
|
||||
paused: video.paused,
|
||||
currentTime: video.currentTime,
|
||||
duration: video.duration || 0,
|
||||
readyState: video.readyState,
|
||||
readyStateLabel: readyStates[video.readyState] || 'UNKNOWN',
|
||||
networkState: video.networkState,
|
||||
networkStateLabel: networkStates[video.networkState] || 'UNKNOWN',
|
||||
muted: video.muted,
|
||||
volume: video.volume,
|
||||
playbackRate: video.playbackRate,
|
||||
videoWidth: video.videoWidth,
|
||||
videoHeight: video.videoHeight,
|
||||
seeking: video.seeking,
|
||||
ended: video.ended,
|
||||
error: errorInfo,
|
||||
buffered: video.buffered && video.buffered.length > 0
|
||||
? Array.from({ length: video.buffered.length }, (_, i) =>
|
||||
`${video.buffered.start(i).toFixed(1)}-${video.buffered.end(i).toFixed(1)}s`).join(', ')
|
||||
: 'none',
|
||||
loop: video.loop,
|
||||
url: window.location.href,
|
||||
pageTitle: document.title,
|
||||
id: video.id || 'none',
|
||||
className: video.className || 'none',
|
||||
src: video.src || 'none',
|
||||
currentSrc: video.currentSrc || 'none',
|
||||
dataAttributes,
|
||||
metadata
|
||||
metadata,
|
||||
videoCount,
|
||||
inShadowDom,
|
||||
platform,
|
||||
allVideos
|
||||
});
|
||||
} else {
|
||||
sendResponse({ error: 'No video found' });
|
||||
sendResponse({
|
||||
found: false,
|
||||
videoCount,
|
||||
inShadowDom,
|
||||
platform,
|
||||
allVideos,
|
||||
url: window.location.href,
|
||||
pageTitle: document.title,
|
||||
metadata: (navigator.mediaSession && navigator.mediaSession.metadata) ? {
|
||||
title: navigator.mediaSession.metadata.title,
|
||||
artist: navigator.mediaSession.metadata.artist,
|
||||
album: navigator.mediaSession.metadata.album
|
||||
} : null
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Detect native events
|
||||
function reportEvent(action) {
|
||||
if (seekDebounceTimer && (action === EVENTS.PLAY || action === EVENTS.PAUSE)) {
|
||||
clearTimeout(seekDebounceTimer);
|
||||
seekDebounceTimer = null;
|
||||
const v = findVideo();
|
||||
if (v && Number.isFinite(v.currentTime)) {
|
||||
lastReportedSeekTime = v.currentTime;
|
||||
reportLog(`[Seek] Debounce flushed immediately due to ${action.toUpperCase()}`, 'info');
|
||||
reportEvent(EVENTS.SEEK);
|
||||
}
|
||||
}
|
||||
|
||||
const video = findVideo();
|
||||
if (!video) return;
|
||||
|
||||
@@ -239,10 +604,14 @@
|
||||
|
||||
const eventState = action === EVENTS.PLAY ? 'playing' : (action === EVENTS.PAUSE ? 'paused' : (action === EVENTS.SEEK ? 'seek' : null));
|
||||
|
||||
if (eventState && expectedEvents.has(eventState)) {
|
||||
expectedEvents.delete(eventState); // Consume the match
|
||||
return; // Ignore event caused by our programmatic action
|
||||
if (_suppressTimers[eventState]) {
|
||||
_clearSuppress(eventState);
|
||||
return;
|
||||
}
|
||||
|
||||
// Suppress only SEEK during visibility grace period (tab re-focus ghost jump).
|
||||
// Play/Pause pass through — user may want to immediately pause after tabbing back.
|
||||
if (Date.now() < visibilityGraceUntil && action === EVENTS.SEEK) return;
|
||||
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'CONTENT_EVENT',
|
||||
@@ -254,26 +623,119 @@
|
||||
timestamp: Date.now()
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger proactive heartbeat to push stabilized state
|
||||
scheduleProactiveHeartbeat();
|
||||
}
|
||||
|
||||
// --- Tab Visibility Handling ---
|
||||
// Browsers (especially Firefox) aggressively throttle background tabs.
|
||||
// When the user returns to a video tab, the video element may have lost
|
||||
// time-sync and fires spurious seek events as it recovers (jumping back).
|
||||
// We suppress only SEEK for a short grace period after tab re-focus.
|
||||
// Play/Pause are NOT suppressed — the user may legitimately want to
|
||||
// pause immediately after switching back.
|
||||
let pageVisible = !document.hidden;
|
||||
let visibilityGraceUntil = 0;
|
||||
const VISIBILITY_GRACE_MS = 300;
|
||||
|
||||
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);
|
||||
const handleSeeked = () => reportEvent(EVENTS.SEEK);
|
||||
|
||||
let lastVideoSrc = null;
|
||||
// Seek filtering: ignore HLS/DASH buffering micro-seeks.
|
||||
// Only relay if delta >= MIN_SEEK_DELTA AND not already debouncing.
|
||||
const handleSeeked = () => {
|
||||
const video = findVideo();
|
||||
if (!video) return;
|
||||
const current = video.currentTime;
|
||||
if (!Number.isFinite(current)) return;
|
||||
|
||||
// Step 1: Check _suppressTimers (programmatic seek from remote peer)
|
||||
if (_suppressTimers['seek']) {
|
||||
_clearSuppress('seek');
|
||||
lastReportedSeekTime = current;
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Suppress during visibility grace period (tab re-focus ghost events)
|
||||
if (Date.now() < visibilityGraceUntil) return;
|
||||
|
||||
const delta = lastReportedSeekTime !== null ? Math.abs(current - lastReportedSeekTime) : null;
|
||||
const deltaStr = delta !== null ? `Δ${delta.toFixed(2)}s` : 'Δ?';
|
||||
|
||||
// Step 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 4: Debounce rapid consecutive seeks (e.g. scrubbing)
|
||||
// — wait 300ms for the user to settle before relaying
|
||||
if (seekDebounceTimer) clearTimeout(seekDebounceTimer);
|
||||
seekDebounceTimer = setTimeout(() => {
|
||||
seekDebounceTimer = null;
|
||||
const v = findVideo();
|
||||
if (!v) return;
|
||||
const settled = v.currentTime;
|
||||
const finalDelta = lastReportedSeekTime !== null ? Math.abs(settled - lastReportedSeekTime) : null;
|
||||
const finalDeltaStr = finalDelta !== null ? `Δ${finalDelta.toFixed(2)}s` : 'Δ?';
|
||||
lastReportedSeekTime = settled;
|
||||
reportLog(`[Seek] Relayed @ ${settled.toFixed(2)}s (${finalDeltaStr})`, 'info');
|
||||
reportEvent(EVENTS.SEEK);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
|
||||
let lastVideoSrc = undefined;
|
||||
|
||||
// Episode detection handler for loadeddata event
|
||||
const handleLoadedData = () => {
|
||||
checkEpisodeTransition();
|
||||
};
|
||||
|
||||
function setupListeners() {
|
||||
const video = findVideo();
|
||||
if (video) {
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
video.removeEventListener('seeked', handleSeeked);
|
||||
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;
|
||||
|
||||
if (!lastKnownMediaTitle) {
|
||||
lastKnownMediaTitle = getMediaTitle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,11 +746,21 @@
|
||||
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 (!video.dataset.koalaAttached || (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc)) {
|
||||
if (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc) {
|
||||
checkEpisodeTransition();
|
||||
}
|
||||
setupListeners();
|
||||
}
|
||||
}
|
||||
@@ -302,37 +774,92 @@
|
||||
observerTimeout = setTimeout(checkVideo, 1000 - (now - lastMutate));
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
|
||||
// Heartbeat
|
||||
// --- SHARED_HEARTBEAT_INJECT_START ---
|
||||
const HEARTBEAT_INTERVAL_VAL = 15000;
|
||||
// --- SHARED_HEARTBEAT_INJECT_END ---
|
||||
|
||||
// Heartbeat Refactoring (Self-scheduling setTimeout with proactive heartbeat scheduling)
|
||||
let heartbeatTimeout = null;
|
||||
let proactiveHeartbeatTimeout = null;
|
||||
let heartbeatErrorCount = 0;
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
|
||||
function sendHeartbeat() {
|
||||
const video = findVideo();
|
||||
if (video) {
|
||||
const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null;
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'HEARTBEAT',
|
||||
payload: {
|
||||
playbackState: video.paused ? 'paused' : 'playing',
|
||||
currentTime: video.currentTime,
|
||||
mediaTitle: mediaTitle,
|
||||
volume: video.volume,
|
||||
muted: video.muted
|
||||
if (!video) return;
|
||||
|
||||
const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null;
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'HEARTBEAT',
|
||||
payload: {
|
||||
playbackState: video.paused ? 'paused' : 'playing',
|
||||
currentTime: video.currentTime,
|
||||
mediaTitle: mediaTitle,
|
||||
volume: video.volume,
|
||||
muted: video.muted
|
||||
}
|
||||
}).catch(err => {
|
||||
if (err.message.includes('Extension context invalidated')) {
|
||||
heartbeatErrorCount++;
|
||||
if (heartbeatErrorCount === 1) {
|
||||
reportLog('Extension reloaded. Please refresh the page if sync stops working.', 'warn');
|
||||
}
|
||||
}).catch(err => {
|
||||
if (err.message.includes('Extension context invalidated')) {
|
||||
heartbeatErrorCount++;
|
||||
if (heartbeatErrorCount === 1) {
|
||||
reportLog('Extension reloaded. Please refresh the page if sync stops working.', 'warn');
|
||||
}
|
||||
clearInterval(heartbeatInterval);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 15000);
|
||||
if (heartbeatTimeout) clearTimeout(heartbeatTimeout);
|
||||
if (proactiveHeartbeatTimeout) clearTimeout(proactiveHeartbeatTimeout);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function schedulePeriodicHeartbeat() {
|
||||
if (heartbeatTimeout) clearTimeout(heartbeatTimeout);
|
||||
heartbeatTimeout = setTimeout(() => {
|
||||
sendHeartbeat();
|
||||
schedulePeriodicHeartbeat();
|
||||
}, HEARTBEAT_INTERVAL_VAL);
|
||||
}
|
||||
|
||||
function scheduleProactiveHeartbeat() {
|
||||
if (proactiveHeartbeatTimeout) clearTimeout(proactiveHeartbeatTimeout);
|
||||
proactiveHeartbeatTimeout = setTimeout(() => {
|
||||
sendHeartbeat();
|
||||
schedulePeriodicHeartbeat(); // Reschedules the next periodic check to be exactly 15s from now
|
||||
}, 500); // 500ms stabilization delay
|
||||
}
|
||||
|
||||
// Initial Setup
|
||||
setupListeners();
|
||||
|
||||
// Maintain a persistent keep-alive port connection to prevent background SW suspension
|
||||
let keepAlivePort = null;
|
||||
function connectKeepAlivePort() {
|
||||
try {
|
||||
if (chrome.runtime.id) {
|
||||
keepAlivePort = chrome.runtime.connect({ name: 'keepAlive' });
|
||||
keepAlivePort.onDisconnect.addListener(() => {
|
||||
keepAlivePort = null;
|
||||
setTimeout(connectKeepAlivePort, 1000);
|
||||
});
|
||||
}
|
||||
} catch (_e) {
|
||||
// Extension context invalidated or disabled
|
||||
}
|
||||
}
|
||||
connectKeepAlivePort();
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// extension/i18n.js
|
||||
export const SUPPORTED_LANGUAGES = ['en', 'de', 'fr', 'es', 'pt-BR', 'ru'];
|
||||
export const DEFAULT_LANGUAGE = 'en';
|
||||
|
||||
let activeDictionary = {};
|
||||
|
||||
/**
|
||||
* Resolves, loads, and merges the target language with the English baseline fallback.
|
||||
* @param {string} langCode - Target language (e.g. 'de')
|
||||
*/
|
||||
export async function loadLocale(langCode) {
|
||||
const resolvedLang = SUPPORTED_LANGUAGES.includes(langCode) ? langCode : DEFAULT_LANGUAGE;
|
||||
|
||||
try {
|
||||
// Load Baseline English
|
||||
const enResponse = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
|
||||
const enDict = await enResponse.json();
|
||||
|
||||
if (resolvedLang === DEFAULT_LANGUAGE) {
|
||||
activeDictionary = enDict;
|
||||
return;
|
||||
}
|
||||
|
||||
// Load Target Locale
|
||||
const targetResponse = await fetch(chrome.runtime.getURL(`locales/${resolvedLang}.json`));
|
||||
const targetDict = await targetResponse.json();
|
||||
|
||||
// Airtight Fallback Merge: target overrides en, missing elements fallback to en
|
||||
activeDictionary = Object.assign({}, enDict, targetDict);
|
||||
} catch (err) {
|
||||
console.error('[i18n] Failed to load locale. Defaulting to English:', err);
|
||||
// Fallback directly to static English if fetching fails
|
||||
try {
|
||||
const enResponse = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
|
||||
activeDictionary = await enResponse.json();
|
||||
} catch (_) {
|
||||
activeDictionary = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the translated string for a given key. Supports optional value interpolation.
|
||||
* @param {string} key - Dictionary key
|
||||
* @param {object} [placeholders] - Key-value map for replacements (e.g., { name: 'Alice' })
|
||||
* @returns {string} Translated string or the key itself
|
||||
*/
|
||||
export function getMessage(key, placeholders = null) {
|
||||
let msg = activeDictionary[key] || key;
|
||||
if (placeholders && typeof placeholders === 'object') {
|
||||
for (const [k, v] of Object.entries(placeholders)) {
|
||||
msg = msg.replace(new RegExp(`{${k}}`, 'g'), v);
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs dynamic DOM replacements for elements carrying data-i18n attributes.
|
||||
*/
|
||||
export function translateDOM() {
|
||||
// 1. Text Content
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
const translated = getMessage(key);
|
||||
|
||||
// Special case: Preserve logo image elements inside headers (like h1 logo)
|
||||
const img = el.querySelector('img');
|
||||
if (img) {
|
||||
el.innerHTML = '';
|
||||
el.appendChild(img);
|
||||
el.appendChild(document.createTextNode(' ' + translated));
|
||||
} else {
|
||||
el.textContent = translated;
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Tooltips (titles)
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
el.setAttribute('title', getMessage(key));
|
||||
});
|
||||
|
||||
// 3. Placeholders
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
el.setAttribute('placeholder', getMessage(key));
|
||||
});
|
||||
}
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 852 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
@@ -0,0 +1,180 @@
|
||||
{
|
||||
"LANG_CODE": "de",
|
||||
"HTML_CLASS": "lang-de",
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "Raum",
|
||||
"TAB_ROOM_TOOLTIP": "Raum-Einstellungen und Verbindung",
|
||||
"TAB_SYNC": "Sync",
|
||||
"TAB_SYNC_TOOLTIP": "Video-Synchronisations-Steuerung und Aktionen",
|
||||
"TAB_SETTINGS": "Optionen",
|
||||
"TAB_SETTINGS_TOOLTIP": "Erweiterungseinstellungen",
|
||||
"TAB_STATUS": "Status",
|
||||
"TAB_STATUS_TOOLTIP": "Erweiterte Diagnose & Protokolle",
|
||||
"BTN_CREATE_ROOM": "+ Neuer Raum",
|
||||
"MANUAL_CONNECT_HEADER": "Manuell verbinden / Erweitert",
|
||||
"LABEL_SERVER": "Server",
|
||||
"BTN_SERVER_OFFICIAL": "Offiziell",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "Verwende den offiziellen, zuverlässigen Server",
|
||||
"BTN_SERVER_CUSTOM": "Eigener",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Verbinde dich mit deinem eigenen, selbstgehosteten Server",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://dein-server:3000",
|
||||
"LABEL_ROOM_ID": "Raum-ID",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "Der eindeutige Identifikator für deinen Sync-Raum",
|
||||
"PLACEHOLDER_ROOM_ID": "Raum-ID eingeben",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "Die eindeutige ID des Raums, dem du beitreten möchtest",
|
||||
"LABEL_PASSWORD": "Passwort (Optional)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Optionales Passwort, um den Raumzugang zu beschränken",
|
||||
"PLACEHOLDER_PASSWORD": "Raum-Passwort (optional)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Passwort für den Raum (leer lassen, wenn keines vorhanden)",
|
||||
"BTN_JOIN_ROOM": "Raum beitreten / erstellen",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Mit dem Raum verbinden",
|
||||
"LABEL_PUBLIC_ROOMS": "Öffentliche Räume",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Liste der öffentlich verfügbaren Räume auf diesem Server",
|
||||
"BTN_REFRESH": "AKTUALISIEREN",
|
||||
"BTN_REFRESH_TOOLTIP": "Die Liste der öffentlichen Räume aktualisieren",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Aktualisiere...",
|
||||
"LABEL_ACTIVE_ROOM": "Aktiver Raum",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "Der Raum, mit dem du gerade verbunden bist",
|
||||
"ACTIVE_ROOM_NONE": "KEINER",
|
||||
"ACTIVE_SERVER_OFFICIAL": "Offizieller Server",
|
||||
"LABEL_INVITE_LINK": "Einladungslink",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Teile diesen Link mit Freunden, damit sie beitreten können",
|
||||
"LABEL_PEERS_IN_ROOM": "Teilnehmer im Raum",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Andere Benutzer, die derzeit mit diesem Raum verbunden sind",
|
||||
"NO_PEERS_CONNECTED": "Keine Teilnehmer verbunden",
|
||||
"BTN_LEAVE_ROOM": "Raum verlassen",
|
||||
"LABEL_SELECT_VIDEO": "Video auswählen",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Wähle den Browser-Tab aus, der das zu synchronisierende Video enthält",
|
||||
"OPTION_SELECT_TAB": "-- Wähle einen Tab --",
|
||||
"LABEL_REMOTE_CONTROL": "Fernsteuerung",
|
||||
"BTN_COPY_INVITE": "📋 Einladungslink",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Einladungslink kopieren",
|
||||
"BTN_PLAY": "▶ Abspielen",
|
||||
"BTN_PLAY_TOOLTIP": "Sende einen Play-Befehl an alle",
|
||||
"BTN_PAUSE": "⏸ Pause",
|
||||
"BTN_PAUSE_TOOLTIP": "Sende einen Pause-Befehl an alle",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC_TOOLTIP": "Erzwinge die Synchronisation aller Teilnehmer",
|
||||
"OPTION_JUMP_TO_OTHERS": "Zu anderen springen",
|
||||
"OPTION_JUMP_TO_ME": "Zu mir springen",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "Synchronisationsziel auswählen",
|
||||
"LABEL_LAST_ACTIVITY": "Letzter Aktivitätsstatus",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Zeigt den neuesten Play-, Pause- oder Seek-Befehl",
|
||||
"NO_RECENT_COMMANDS": "Keine aktuellen Befehle",
|
||||
"LOBBY_HEADER": "EPISODEN-LOBBY",
|
||||
"LOBBY_WAITING_FOR": "🎬 Warte auf: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "Warte auf Teilnehmer...",
|
||||
"BTN_SKIP_PLAY": "Überspringen & Abspielen",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Lobby abbrechen und trotzdem abspielen",
|
||||
"LOBBY_CONNECT_FIRST": "Zuerst Raum beitreten",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "Du musst über einen Einladungslink beitreten oder einen neuen Raum erstellen, um Videos zu synchronisieren.",
|
||||
"BTN_CREATE_ROOM_ALT": "Neuen Raum erstellen",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Einen neuen zufälligen Raum erstellen und beitreten",
|
||||
"LABEL_USERNAME": "Dein Benutzername",
|
||||
"LABEL_USERNAME_TOOLTIP": "Der Benutzername hilft anderen, dich zu identifizieren.",
|
||||
"PLACEHOLDER_USERNAME": "Anonymer Koala",
|
||||
"LABEL_HIDE_CLUTTER": "Aufgeräumte Tab-Liste",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtert Nicht-Video-Tabs und irrelevante Domains heraus, um die Liste sauber zu halten",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Nächste Episode auto-syncen",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausiert automatisch und wartet auf alle Teilnehmer bei Episodenwechsel, startet dann synchron.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Einladungslink auto-kopieren",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Kopiert den Einladungslink automatisch beim Erstellen eines Raums.",
|
||||
"LABEL_NOTIFICATIONS": "Browser-Benachrichtigungen",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Zeigt Systembenachrichtigungen, wenn jemand beitritt/verlässt oder abspielt/pausiert.",
|
||||
"LABEL_LANGUAGE": "Sprache",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Wähle deine bevorzugte Sprache für die Erweiterung aus",
|
||||
"LABEL_TROUBLESHOOTING": "Fehlerbehebung",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "Werkzeuge zur Behebung von Verbindungsproblemen",
|
||||
"BTN_REGEN_ID": "Peer-ID neu generieren",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Generiere deine interne ID neu und verbinde dich erneut",
|
||||
"REGEN_ID_DESC": "Verwende dies, wenn du Fehler wegen doppelter Identität siehst.",
|
||||
"LABEL_CONN_STATUS": "Verbindungsstatus",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Aktueller WebSocket-Verbindungsstatus",
|
||||
"CONN_STATUS_DISCONNECTED": "Getrennt",
|
||||
"BTN_RETRY": "WIEDERHOLEN",
|
||||
"BTN_RETRY_TOOLTIP": "Versuchen, die Verbindung zum Server wiederherzustellen",
|
||||
"BTN_COPY_LOGS": "Logs kopieren",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Logs zum Teilen in die Zwischenablage kopieren",
|
||||
"LABEL_VIDEO_DEBUG": "Video-Debug-Info",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Technische Details zum aktuell ausgewählten Video-Element",
|
||||
"VIDEO_DEBUG_EMPTY": "Kein Tab ausgewählt oder kein Video erkannt.",
|
||||
"LABEL_HISTORY": "Aktionsverlauf",
|
||||
"LABEL_HISTORY_TOOLTIP": "Chronologisches Protokoll aller Sync-Befehle im Raum",
|
||||
"HISTORY_EMPTY": "Noch keine Aktivität",
|
||||
"LABEL_LOGS": "Logs (Letzte 50)",
|
||||
"LABEL_LOGS_TOOLTIP": "Technische Verbindungsprotokolle zur Fehlersuche",
|
||||
"BTN_CLEAR": "LEEREN",
|
||||
"BTN_CLEAR_TOOLTIP": "Protokollausgabe leeren",
|
||||
"LABEL_GITHUB": "GitHub-Repository",
|
||||
"BTN_ONBOARDING_SKIP": "Überspringen",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "Einführung überspringen",
|
||||
"BTN_ONBOARDING_NEXT": "Weiter",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "Zum nächsten Schritt gehen",
|
||||
"ONBOARDING_1_TITLE": "Willkommen bei KoalaSync!",
|
||||
"ONBOARDING_1_TEXT": "Schau Videos in perfekter Synchronität zusammen — egal wo ihr seid. Machen wir eine kurze Tour!",
|
||||
"ONBOARDING_2_TITLE": "1. Raum erstellen",
|
||||
"ONBOARDING_2_TEXT": "Hier geht's los. Erstelle einen Raum und teile den Einladungslink mit deinen Freunden.",
|
||||
"ONBOARDING_3_TITLE": "2. Video auswählen",
|
||||
"ONBOARDING_3_TEXT": "Wähle hier das Video aus, das du synchronisieren willst. Abspielen, Pause, Spulen — alle bleiben synchron.",
|
||||
"ONBOARDING_4_TITLE": "3. Personalisieren",
|
||||
"ONBOARDING_4_TEXT": "Wähle einen lustigen Benutzernamen, damit deine Freunde wissen, wer du bist.",
|
||||
"ONBOARDING_5_TITLE": "Alles startklar!",
|
||||
"ONBOARDING_5_TEXT": "Zeit, das Popcorn zu holen. Viel Spaß beim gemeinsamen Schauen!",
|
||||
"ERR_CONN_TIMEOUT": "Verbindung abgelaufen. Bitte versuche es erneut.",
|
||||
"ERR_INVALID_SERVER_URL": "Ungültiges Server-URL-Format.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identität noch nicht geladen. Warte kurz und versuche es erneut.",
|
||||
"ERR_NO_PEERS_TIME": "Keine anderen Teilnehmer mit bekannter Zeit. Wechsle zu 'Zu mir springen'.",
|
||||
"ERR_NO_VIDEO_TAB": "Verbindung zum Video-Tab fehlgeschlagen.",
|
||||
"ERR_SELECT_VIDEO": "Bitte wähle zuerst ein Video aus!",
|
||||
"TOAST_INVITE_COPIED": "Einladungslink kopiert!",
|
||||
"TOAST_COPY_FAILED": "Kopieren in Zwischenablage fehlgeschlagen",
|
||||
"TOAST_LOBBY_SKIPPED": "Episoden-Lobby übersprungen.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Überspringen der Lobby fehlgeschlagen.",
|
||||
"TOAST_LOGS_COPIED": "Kopiert!",
|
||||
"TOAST_PEER_JOINED": "{name} ist dem Raum beigetreten",
|
||||
"TOAST_PEER_LEFT": "{name} hat den Raum verlassen",
|
||||
"TOAST_PEER_ACTION": "{name} hat {action}",
|
||||
"STATUS_CONNECTED": "Verbunden",
|
||||
"STATUS_RECONNECTING": "Verbinde erneut...",
|
||||
"STATUS_CONNECTING": "Verbinde...",
|
||||
"STATUS_FAILED": "Fehlgeschlagen",
|
||||
"STATUS_DISCONNECTED": "Getrennt",
|
||||
"BTN_STATE_JOINING": "🚀 Trete bei...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Verbinde erneut...",
|
||||
"BTN_STATE_PLAYING": "▶ Spiele ab...",
|
||||
"BTN_STATE_PAUSING": "⏸ Pausiere...",
|
||||
"BTN_STATE_SYNCING_GROUP": "Synce zur Gruppe ({time})...",
|
||||
"BTN_STATE_SYNCING": "Synchronisiere...",
|
||||
"BTN_STATE_SYNCED": "✅ Synchronisiert!",
|
||||
"NOTIF_PLAY": "die Wiedergabe gestartet",
|
||||
"NOTIF_PAUSE": "die Wiedergabe pausiert",
|
||||
"NOTIF_SEEK": "im Video gespult",
|
||||
"NOTIF_FORCE_PREPARE": "eine erzwungene Synchronisation gestartet",
|
||||
"NOTIF_FORCE_EXECUTE": "alle Teilnehmer synchronisiert",
|
||||
"DEBUG_NO_TAB": "Kein Ziel-Tab ausgewählt.",
|
||||
"DEBUG_COMM_FAIL": "Kommunikation mit dem Tab-Video fehlgeschlagen.",
|
||||
"EMPTY_PEERS_TITLE": "Noch keine Teilnehmer",
|
||||
"EMPTY_PEERS_HINT": "Teile deinen Einladungslink, um loszulegen",
|
||||
"EMPTY_HISTORY_TITLE": "Noch keine Aktivität",
|
||||
"EMPTY_HISTORY_HINT": "Abspielen, Pausieren oder Spulen, um Verlauf zu sehen",
|
||||
"EMPTY_LOGS_TITLE": "Keine Logs",
|
||||
"EMPTY_LOGS_HINT": "Verbindungsereignisse werden hier angezeigt",
|
||||
"EMPTY_ROOMS_TITLE": "Keine aktiven Räume",
|
||||
"EMPTY_ROOMS_HINT": "Raum erstellen oder aktualisieren, um öffentliche zu finden",
|
||||
"LABEL_YOU": "Du",
|
||||
"ONBOARDING_DONE": "Fertig!",
|
||||
"LABEL_LOBBY_PEER_READY": "Bereit",
|
||||
"LABEL_LOBBY_PEER_LOADING": "Lädt...",
|
||||
"LABEL_PASSWORD_PROTECTED": "Passwortgeschützt",
|
||||
"LABEL_PEERS_COUNT": "{count} Teilnehmer",
|
||||
"LABEL_CUSTOM_SERVER": "Eigener Server",
|
||||
"BTN_STATE_CREATING": "🚀 Erstelle Raum...",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Episoden-Synchronisation fehlgeschlagen",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "Auto-Sync abgebrochen: {reason}. Du musst eventuell manuell synchronisieren.",
|
||||
"LOBBY_CANCEL_TIMEOUT": "Zeitüberschreitung",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Zeitüberschreitung (wiederhergestellt)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Alle anderen Teilnehmer haben den Raum verlassen",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Zeitüberschreitung — nicht alle Teilnehmer haben die Episode geladen",
|
||||
"LOBBY_CANCEL_USER": "Vom Benutzer abgebrochen",
|
||||
"NOTIF_ERROR_TITLE": "KoalaSync-Fehler"
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
{
|
||||
"LANG_CODE": "en",
|
||||
"HTML_CLASS": "lang-en",
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "Room",
|
||||
"TAB_ROOM_TOOLTIP": "Room settings and connection",
|
||||
"TAB_SYNC": "Sync",
|
||||
"TAB_SYNC_TOOLTIP": "Video sync controls and remote actions",
|
||||
"TAB_SETTINGS": "Settings",
|
||||
"TAB_SETTINGS_TOOLTIP": "Extension preferences",
|
||||
"TAB_STATUS": "Status",
|
||||
"TAB_STATUS_TOOLTIP": "Advanced Diagnostics & Logs",
|
||||
"BTN_CREATE_ROOM": "+ Create New Room",
|
||||
"MANUAL_CONNECT_HEADER": "Manual Connect / Advanced",
|
||||
"LABEL_SERVER": "Server",
|
||||
"BTN_SERVER_OFFICIAL": "Official",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "Use the official reliable server",
|
||||
"BTN_SERVER_CUSTOM": "Custom",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Connect to your own self-hosted server",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://your-server:3000",
|
||||
"LABEL_ROOM_ID": "Room ID",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "The unique identifier for your sync room",
|
||||
"PLACEHOLDER_ROOM_ID": "Enter Room ID",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "The unique ID of the room you want to join",
|
||||
"LABEL_PASSWORD": "Password (Optional)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Optional password to restrict room access",
|
||||
"PLACEHOLDER_PASSWORD": "Room Password (optional)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Password for the room (leave empty if none)",
|
||||
"BTN_JOIN_ROOM": "Join / Create Room",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Connect to the room",
|
||||
"LABEL_PUBLIC_ROOMS": "Public Rooms",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "List of publicly available rooms on this server",
|
||||
"BTN_REFRESH": "REFRESH",
|
||||
"BTN_REFRESH_TOOLTIP": "Refresh the list of public rooms",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Refreshing...",
|
||||
"LABEL_ACTIVE_ROOM": "Active Room",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "The room you are currently connected to",
|
||||
"ACTIVE_ROOM_NONE": "NONE",
|
||||
"ACTIVE_SERVER_OFFICIAL": "Official Server",
|
||||
"LABEL_INVITE_LINK": "Invite Link",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Share this link with friends so they can join",
|
||||
"LABEL_PEERS_IN_ROOM": "Peers in Room",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Other users currently connected to this room",
|
||||
"NO_PEERS_CONNECTED": "No peers connected",
|
||||
"BTN_LEAVE_ROOM": "Leave Room",
|
||||
"LABEL_SELECT_VIDEO": "Select Video",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Choose the browser tab containing the video to sync",
|
||||
"OPTION_SELECT_TAB": "-- Select a Tab --",
|
||||
"LABEL_REMOTE_CONTROL": "Remote Control",
|
||||
"BTN_COPY_INVITE": "📋 Invite Link",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copy Invite Link",
|
||||
"BTN_PLAY": "▶ Play",
|
||||
"BTN_PLAY_TOOLTIP": "Send a Play command to everyone",
|
||||
"BTN_PAUSE": "⏸ Pause",
|
||||
"BTN_PAUSE_TOOLTIP": "Send a Pause command to everyone",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC_TOOLTIP": "Force all users to sync up",
|
||||
"OPTION_JUMP_TO_OTHERS": "Jump to Others",
|
||||
"OPTION_JUMP_TO_ME": "Jump to Me",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "Choose sync target",
|
||||
"LABEL_LAST_ACTIVITY": "Last Activity Status",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Shows the most recent play, pause, or seek command",
|
||||
"NO_RECENT_COMMANDS": "No recent commands",
|
||||
"LOBBY_HEADER": "EPISODE LOBBY",
|
||||
"LOBBY_WAITING_FOR": "🎬 Waiting for: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "Waiting for peers...",
|
||||
"BTN_SKIP_PLAY": "Skip & Play anyway",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Cancel lobby and play anyway",
|
||||
"LOBBY_CONNECT_FIRST": "Connect to a room first",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "You need to join a room via an invite link or create a new one to sync videos.",
|
||||
"BTN_CREATE_ROOM_ALT": "Create New Room",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Create a new random room and join it",
|
||||
"LABEL_USERNAME": "Your Username",
|
||||
"LABEL_USERNAME_TOOLTIP": "Username helps others identify you.",
|
||||
"PLACEHOLDER_USERNAME": "Anonymous Koala",
|
||||
"LABEL_HIDE_CLUTTER": "Hide Clutter Tabs",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filters out non-video tabs and unrelated domains to keep the list clean",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Auto-Sync Next Episode",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pauses automatically and waits for all peers when an episode changes, then sync-starts together.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Auto-Copy Invite Link",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Automatically copies the invite link to your clipboard when creating a new room.",
|
||||
"LABEL_NOTIFICATIONS": "Browser Notifications",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Shows native system notifications when someone joins/leaves or plays/pauses.",
|
||||
"LABEL_LANGUAGE": "App Language",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Choose your preferred extension language",
|
||||
"LABEL_TROUBLESHOOTING": "Troubleshooting",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "Tools for fixing connection issues",
|
||||
"BTN_REGEN_ID": "Regenerate Peer ID",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Regenerate your internal ID and reconnect",
|
||||
"REGEN_ID_DESC": "Use this if you see \"Duplicate Identity\" errors.",
|
||||
"LABEL_CONN_STATUS": "Connection Status",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Current WebSocket connection state",
|
||||
"CONN_STATUS_DISCONNECTED": "Disconnected",
|
||||
"BTN_RETRY": "RETRY",
|
||||
"BTN_RETRY_TOOLTIP": "Attempt to reconnect to the server",
|
||||
"BTN_COPY_LOGS": "Copy Logs",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Copy logs to clipboard for sharing",
|
||||
"LABEL_VIDEO_DEBUG": "Video Debug Info",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Technical details about the currently selected video element",
|
||||
"VIDEO_DEBUG_EMPTY": "No tab selected or video detected.",
|
||||
"LABEL_HISTORY": "Full Action History",
|
||||
"LABEL_HISTORY_TOOLTIP": "Chronological log of all sync commands in the room",
|
||||
"HISTORY_EMPTY": "No activity yet",
|
||||
"LABEL_LOGS": "Logs (Last 50)",
|
||||
"LABEL_LOGS_TOOLTIP": "Technical connection logs for debugging",
|
||||
"BTN_CLEAR": "CLEAR",
|
||||
"BTN_CLEAR_TOOLTIP": "Clear log output",
|
||||
"LABEL_GITHUB": "GitHub Repository",
|
||||
"BTN_ONBOARDING_SKIP": "Skip",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "Skip the tutorial",
|
||||
"BTN_ONBOARDING_NEXT": "Next",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "Go to next step",
|
||||
"ONBOARDING_1_TITLE": "Welcome to KoalaSync!",
|
||||
"ONBOARDING_1_TEXT": "Watch videos together in perfect sync — no matter where you are. Let's take a quick tour!",
|
||||
"ONBOARDING_2_TITLE": "1. Create a Room",
|
||||
"ONBOARDING_2_TEXT": "Start here. Create a room and share the invite link with your friends.",
|
||||
"ONBOARDING_3_TITLE": "2. Select Video",
|
||||
"ONBOARDING_3_TEXT": "Navigate here to select the video you want to sync. Play, pause, and seek — everyone stays in sync.",
|
||||
"ONBOARDING_4_TITLE": "3. Personalize",
|
||||
"ONBOARDING_4_TEXT": "Pick a fun username so your friends know who you are.",
|
||||
"ONBOARDING_5_TITLE": "You're all set!",
|
||||
"ONBOARDING_5_TEXT": "Time to grab some popcorn. Enjoy watching together!",
|
||||
"ERR_CONN_TIMEOUT": "Connection timed out. Please try again.",
|
||||
"ERR_INVALID_SERVER_URL": "Invalid Server URL format.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identity not yet loaded. Wait a moment and try again.",
|
||||
"ERR_NO_PEERS_TIME": "No other peers with a known time. Switch to 'Jump to Me'.",
|
||||
"ERR_NO_VIDEO_TAB": "Could not connect to video tab.",
|
||||
"ERR_SELECT_VIDEO": "Please select a video first!",
|
||||
"TOAST_INVITE_COPIED": "Invite link copied!",
|
||||
"TOAST_COPY_FAILED": "Failed to copy to clipboard",
|
||||
"TOAST_LOBBY_SKIPPED": "Episode Lobby skipped.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Failed to skip lobby.",
|
||||
"TOAST_LOGS_COPIED": "Copied!",
|
||||
"TOAST_PEER_JOINED": "{name} joined the room",
|
||||
"TOAST_PEER_LEFT": "{name} left the room",
|
||||
"TOAST_PEER_ACTION": "{name} {action}",
|
||||
"STATUS_CONNECTED": "Connected",
|
||||
"STATUS_RECONNECTING": "Reconnecting...",
|
||||
"STATUS_CONNECTING": "Connecting...",
|
||||
"STATUS_FAILED": "Failed",
|
||||
"STATUS_DISCONNECTED": "Disconnected",
|
||||
"BTN_STATE_JOINING": "🚀 Joining...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Reconnecting...",
|
||||
"BTN_STATE_PLAYING": "▶ Playing...",
|
||||
"BTN_STATE_PAUSING": "⏸ Pausing...",
|
||||
"BTN_STATE_SYNCING_GROUP": "Syncing to group ({time})...",
|
||||
"BTN_STATE_SYNCING": "Syncing...",
|
||||
"BTN_STATE_SYNCED": "✅ Synced!",
|
||||
"NOTIF_PLAY": "started playback",
|
||||
"NOTIF_PAUSE": "paused playback",
|
||||
"NOTIF_SEEK": "seeked the video",
|
||||
"NOTIF_FORCE_PREPARE": "started force sync",
|
||||
"NOTIF_FORCE_EXECUTE": "synchronized everyone",
|
||||
"DEBUG_NO_TAB": "No target tab selected.",
|
||||
"DEBUG_COMM_FAIL": "Could not communicate with tab video.",
|
||||
"EMPTY_PEERS_TITLE": "No peers yet",
|
||||
"EMPTY_PEERS_HINT": "Share your invite link to get started",
|
||||
"EMPTY_HISTORY_TITLE": "No activity yet",
|
||||
"EMPTY_HISTORY_HINT": "Play, pause, or seek to see history",
|
||||
"EMPTY_LOGS_TITLE": "No logs",
|
||||
"EMPTY_LOGS_HINT": "Connection events will appear here",
|
||||
"EMPTY_ROOMS_TITLE": "No active rooms",
|
||||
"EMPTY_ROOMS_HINT": "Create a room or refresh to find public ones",
|
||||
"LABEL_YOU": "You",
|
||||
"ONBOARDING_DONE": "Done!",
|
||||
"LABEL_LOBBY_PEER_READY": "Ready",
|
||||
"LABEL_LOBBY_PEER_LOADING": "Loading...",
|
||||
"LABEL_PASSWORD_PROTECTED": "Password Protected",
|
||||
"LABEL_PEERS_COUNT": "{count} peers",
|
||||
"LABEL_CUSTOM_SERVER": "Custom Server",
|
||||
"BTN_STATE_CREATING": "🚀 Creating Room...",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Episode Sync Failed",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "Auto-sync cancelled: {reason}. You may need to manually sync.",
|
||||
"LOBBY_CANCEL_TIMEOUT": "Timeout",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Timeout (recovered)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "All other peers left",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Timeout — not all peers loaded the episode",
|
||||
"LOBBY_CANCEL_USER": "Cancelled by user",
|
||||
"NOTIF_ERROR_TITLE": "KoalaSync Error"
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
{
|
||||
"LANG_CODE": "es",
|
||||
"HTML_CLASS": "lang-es",
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "Sala",
|
||||
"TAB_ROOM_TOOLTIP": "Configuración de sala y conexión",
|
||||
"TAB_SYNC": "Sincro",
|
||||
"TAB_SYNC_TOOLTIP": "Controles de sincronización de video y acciones",
|
||||
"TAB_SETTINGS": "Opciones",
|
||||
"TAB_SETTINGS_TOOLTIP": "Preferencias de la extensión",
|
||||
"TAB_STATUS": "Estado",
|
||||
"TAB_STATUS_TOOLTIP": "Diagnósticos avanzados y registros",
|
||||
"BTN_CREATE_ROOM": "+ Crear nueva sala",
|
||||
"MANUAL_CONNECT_HEADER": "Conexión manual / Avanzado",
|
||||
"LABEL_SERVER": "Servidor",
|
||||
"BTN_SERVER_OFFICIAL": "Oficial",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "Usar el servidor oficial confiable",
|
||||
"BTN_SERVER_CUSTOM": "Perso",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Conectarse a su propio servidor auto-alojado",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://tu-servidor:3000",
|
||||
"LABEL_ROOM_ID": "ID de sala",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "El identificador único para tu sala de sincronización",
|
||||
"PLACEHOLDER_ROOM_ID": "Ingresa el ID de la sala",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "El ID único de la sala a la que deseas unirte",
|
||||
"LABEL_PASSWORD": "Contraseña (Opcional)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Contraseña opcional para restringir el acceso a la sala",
|
||||
"PLACEHOLDER_PASSWORD": "Contraseña de la sala (opcional)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Contraseña para la sala (dejar vacío si no hay)",
|
||||
"BTN_JOIN_ROOM": "Unirse / Crear sala",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Conectarse a la sala",
|
||||
"LABEL_PUBLIC_ROOMS": "Salas públicas",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Lista de salas públicas disponibles en este servidor",
|
||||
"BTN_REFRESH": "ACTUALIZAR",
|
||||
"BTN_REFRESH_TOOLTIP": "Actualizar la lista de salas públicas",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Actualizando...",
|
||||
"LABEL_ACTIVE_ROOM": "Sala activa",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "La sala a la que estás conectado actualmente",
|
||||
"ACTIVE_ROOM_NONE": "NINGUNA",
|
||||
"ACTIVE_SERVER_OFFICIAL": "Servidor oficial",
|
||||
"LABEL_INVITE_LINK": "Enlace de invitación",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Comparte este enlace con amigos para que se unan",
|
||||
"LABEL_PEERS_IN_ROOM": "Participantes en la sala",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Otros usuarios conectados actualmente a esta sala",
|
||||
"NO_PEERS_CONNECTED": "Sin participantes conectados",
|
||||
"BTN_LEAVE_ROOM": "Salir de la sala",
|
||||
"LABEL_SELECT_VIDEO": "Seleccionar video",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Elige la pestaña del navegador que contiene el video a sincronizar",
|
||||
"OPTION_SELECT_TAB": "-- Selecciona una pestaña --",
|
||||
"LABEL_REMOTE_CONTROL": "Control remoto",
|
||||
"BTN_COPY_INVITE": "📋 Enlace de invitación",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copiar enlace de invitación",
|
||||
"BTN_PLAY": "▶ Reproducir",
|
||||
"BTN_PLAY_TOOLTIP": "Enviar un comando de reproducción a todos",
|
||||
"BTN_PAUSE": "⏸ Pausar",
|
||||
"BTN_PAUSE_TOOLTIP": "Enviar un comando de pausa a todos",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC_TOOLTIP": "Forzar la sincronización de todos los usuarios",
|
||||
"OPTION_JUMP_TO_OTHERS": "Unirse a otros",
|
||||
"OPTION_JUMP_TO_ME": "Traerlos a mí",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "Elegir el objetivo de sincronización",
|
||||
"LABEL_LAST_ACTIVITY": "Última actividad",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Muestra el comando más reciente de reproducción, pausa o búsqueda",
|
||||
"NO_RECENT_COMMANDS": "Sin comandos recientes",
|
||||
"LOBBY_HEADER": "SALA DE ESPERA DE EPISODIO",
|
||||
"LOBBY_WAITING_FOR": "🎬 Esperando a: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "Esperando participantes...",
|
||||
"BTN_SKIP_PLAY": "Omitir y reproducir",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Cancelar la sala de espera y reproducir de todos modos",
|
||||
"LOBBY_CONNECT_FIRST": "Únete a una sala primero",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "Necesitas unirte a una sala a través de un enlace de invitación o crear una nueva para sincronizar videos.",
|
||||
"BTN_CREATE_ROOM_ALT": "Crear nueva sala",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Crear una nueva sala aleatoria y unirse",
|
||||
"LABEL_USERNAME": "Tu nombre de usuario",
|
||||
"LABEL_USERNAME_TOOLTIP": "El nombre de usuario ayuda a otros a identificarte.",
|
||||
"PLACEHOLDER_USERNAME": "Koala anónimo",
|
||||
"LABEL_HIDE_CLUTTER": "Lista de pestañas limpia",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra pestañas que no son de video y dominios no relacionados para mantener limpia la lista",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Sincro auto del siguiente episodio",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automáticamente y espera a todos al cambiar de episodio, luego inicia de forma sincrónica.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Auto-copiar enlace",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Copia automáticamente el enlace de invitación a tu portapapeles al crear una nueva sala.",
|
||||
"LABEL_NOTIFICATIONS": "Notificaciones del navegador",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Muestra notificaciones del sistema cuando alguien se une/sale o reproduce/pausa.",
|
||||
"LABEL_LANGUAGE": "Idioma de la aplicación",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Elige tu idioma preferido para la extensión",
|
||||
"LABEL_TROUBLESHOOTING": "Resolución de problemas",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "Herramientas para solucionar problemas de conexión",
|
||||
"BTN_REGEN_ID": "Regenerar ID de participante",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Regenerar tu ID interno y volver a conectarte",
|
||||
"REGEN_ID_DESC": "Usa esto si ves errores de 'Identidad duplicada'.",
|
||||
"LABEL_CONN_STATUS": "Estado de la conexión",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Estado actual de la conexión WebSocket",
|
||||
"CONN_STATUS_DISCONNECTED": "Desconectado",
|
||||
"BTN_RETRY": "REINTENTAR",
|
||||
"BTN_RETRY_TOOLTIP": "Intentar volver a conectarse al servidor",
|
||||
"BTN_COPY_LOGS": "Copiar registros",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Copiar registros al portapapeles para compartir",
|
||||
"LABEL_VIDEO_DEBUG": "Información de depuración de video",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Detalles técnicos sobre el elemento de video seleccionado actualmente",
|
||||
"VIDEO_DEBUG_EMPTY": "No hay pestaña seleccionada o no se detectó ningún video.",
|
||||
"LABEL_HISTORY": "Historial completo",
|
||||
"LABEL_HISTORY_TOOLTIP": "Registro cronológico de todos los comandos de sincronización en la sala",
|
||||
"HISTORY_EMPTY": "Sin actividad aún",
|
||||
"LABEL_LOGS": "Logs (Últimos 50)",
|
||||
"LABEL_LOGS_TOOLTIP": "Registros técnicos de conexión para depuración",
|
||||
"BTN_CLEAR": "LIMPIAR",
|
||||
"BTN_CLEAR_TOOLTIP": "Limpiar salida de registros",
|
||||
"LABEL_GITHUB": "Repositorio de GitHub",
|
||||
"BTN_ONBOARDING_SKIP": "Omitir",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "Omitir el tutorial",
|
||||
"BTN_ONBOARDING_NEXT": "Siguiente",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "Ir al siguiente paso",
|
||||
"ONBOARDING_1_TITLE": "¡Bienvenido a KoalaSync!",
|
||||
"ONBOARDING_1_TEXT": "Mira videos juntos en perfecta sincronización, sin importar dónde estén. ¡Hagamos un recorrido rápido!",
|
||||
"ONBOARDING_2_TITLE": "1. Crear una sala",
|
||||
"ONBOARDING_2_TEXT": "Comienza aquí. Crea una sala y comparte el enlace de invitación con tus amigos.",
|
||||
"ONBOARDING_3_TITLE": "2. Seleccionar video",
|
||||
"ONBOARDING_3_TEXT": "Navega aquí para seleccionar el video que deseas sincronizar. Reproduce, pausa y busca: todos permanecen sincronizados.",
|
||||
"ONBOARDING_4_TITLE": "3. Personalizar",
|
||||
"ONBOARDING_4_TEXT": "Elige un nombre de usuario divertido para que tus amigos sepan quién eres.",
|
||||
"ONBOARDING_5_TITLE": "¡Todo listo!",
|
||||
"ONBOARDING_5_TEXT": "Hora de preparar las palomitas. ¡Disfruta viendo con tus amigos!",
|
||||
"ERR_CONN_TIMEOUT": "Tiempo de conexión agotado. Inténtalo de nuevo.",
|
||||
"ERR_INVALID_SERVER_URL": "Formato de URL de servidor no válido.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identidad no cargada aún. Espera un momento e inténtalo de nuevo.",
|
||||
"ERR_NO_PEERS_TIME": "No hay otros participantes con posición conocida. Cambia a 'Traerlos a mí'.",
|
||||
"ERR_NO_VIDEO_TAB": "No se pudo conectar a la pestaña de video.",
|
||||
"ERR_SELECT_VIDEO": "¡Selecciona un video primero!",
|
||||
"TOAST_INVITE_COPIED": "¡Enlace de invitación copiado!",
|
||||
"TOAST_COPY_FAILED": "Error al copiar al portapapeles",
|
||||
"TOAST_LOBBY_SKIPPED": "Sala de espera de episodio omitida.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Error al omitir la sala de espera.",
|
||||
"TOAST_LOGS_COPIED": "¡Copiado!",
|
||||
"TOAST_PEER_JOINED": "{name} se unió a la sala",
|
||||
"TOAST_PEER_LEFT": "{name} salió de la sala",
|
||||
"TOAST_PEER_ACTION": "{name} ha {action}",
|
||||
"STATUS_CONNECTED": "Conectado",
|
||||
"STATUS_RECONNECTING": "Reconectando...",
|
||||
"STATUS_CONNECTING": "Conectando...",
|
||||
"STATUS_FAILED": "Error",
|
||||
"STATUS_DISCONNECTED": "Desconectado",
|
||||
"BTN_STATE_JOINING": "🚀 Uniéndose...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Reconectando...",
|
||||
"BTN_STATE_PLAYING": "▶ Reproduciendo...",
|
||||
"BTN_STATE_PAUSING": "⏸ Pausando...",
|
||||
"BTN_STATE_SYNCING_GROUP": "Sincronizando al grupo ({time})...",
|
||||
"BTN_STATE_SYNCING": "Sincronizando...",
|
||||
"BTN_STATE_SYNCED": "✅ ¡Sincronizado!",
|
||||
"NOTIF_PLAY": "inició la reproducción",
|
||||
"NOTIF_PAUSE": "pausó la reproducción",
|
||||
"NOTIF_SEEK": "cambió la posición del video",
|
||||
"NOTIF_FORCE_PREPARE": "inició una sincronización forzada",
|
||||
"NOTIF_FORCE_EXECUTE": "sincronizó a todos",
|
||||
"DEBUG_NO_TAB": "No hay pestaña objetivo seleccionada.",
|
||||
"DEBUG_COMM_FAIL": "No se pudo comunicar con el video de la pestaña.",
|
||||
"EMPTY_PEERS_TITLE": "Sin participantes aún",
|
||||
"EMPTY_PEERS_HINT": "Comparte tu enlace de invitación para comenzar",
|
||||
"EMPTY_HISTORY_TITLE": "Sin actividad aún",
|
||||
"EMPTY_HISTORY_HINT": "Reproduce, pausa o busca para ver el historial",
|
||||
"EMPTY_LOGS_TITLE": "Sin registros",
|
||||
"EMPTY_LOGS_HINT": "Los eventos de conexión aparecerán aquí",
|
||||
"EMPTY_ROOMS_TITLE": "Sin salas activas",
|
||||
"EMPTY_ROOMS_HINT": "Crea una sala o actualiza para buscar salas públicas",
|
||||
"LABEL_YOU": "Tú",
|
||||
"ONBOARDING_DONE": "¡Hecho!",
|
||||
"LABEL_LOBBY_PEER_READY": "Listo",
|
||||
"LABEL_LOBBY_PEER_LOADING": "Cargando...",
|
||||
"LABEL_PASSWORD_PROTECTED": "Protegido con contraseña",
|
||||
"LABEL_PEERS_COUNT": "{count} participantes",
|
||||
"LABEL_CUSTOM_SERVER": "Servidor personalizado",
|
||||
"BTN_STATE_CREATING": "🚀 Creando sala...",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Error al sincronizar episodio",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "Sincronización automática cancelada: {reason}. Es posible que debas sincronizar manualmente.",
|
||||
"LOBBY_CANCEL_TIMEOUT": "Tiempo de espera agotado",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Tiempo de espera agotado (recuperado)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Todos los demás participantes se han ido",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Tiempo de espera agotado: no todos los participantes cargaron el episodio",
|
||||
"LOBBY_CANCEL_USER": "Cancelado por el usuario",
|
||||
"NOTIF_ERROR_TITLE": "Error de KoalaSync"
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
{
|
||||
"LANG_CODE": "fr",
|
||||
"HTML_CLASS": "lang-fr",
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "Salon",
|
||||
"TAB_ROOM_TOOLTIP": "Paramètres de salon et connexion",
|
||||
"TAB_SYNC": "Synchro",
|
||||
"TAB_SYNC_TOOLTIP": "Contrôles de synchronisation vidéo et actions",
|
||||
"TAB_SETTINGS": "Options",
|
||||
"TAB_SETTINGS_TOOLTIP": "Préférences de l'extension",
|
||||
"TAB_STATUS": "Statut",
|
||||
"TAB_STATUS_TOOLTIP": "Diagnostics avancés & Journaux",
|
||||
"BTN_CREATE_ROOM": "+ Créer un nouveau salon",
|
||||
"MANUAL_CONNECT_HEADER": "Connexion manuelle / Avancé",
|
||||
"LABEL_SERVER": "Serveur",
|
||||
"BTN_SERVER_OFFICIAL": "Officiel",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "Utiliser le serveur officiel fiable",
|
||||
"BTN_SERVER_CUSTOM": "Perso",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Se connecter à votre propre serveur auto-hébergé",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://votre-serveur:3000",
|
||||
"LABEL_ROOM_ID": "ID du salon",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "L'identifiant unique de votre salon de synchronisation",
|
||||
"PLACEHOLDER_ROOM_ID": "Entrer l'ID du salon",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "L'ID unique du salon que vous souhaitez rejoindre",
|
||||
"LABEL_PASSWORD": "Mot de passe (Optionnel)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Mot de passe optionnel pour restreindre l'accès au salon",
|
||||
"PLACEHOLDER_PASSWORD": "Mot de passe du salon (optionnel)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Mot de passe du salon (laisser vide si aucun)",
|
||||
"BTN_JOIN_ROOM": "Rejoindre / Créer le salon",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Se connecter au salon",
|
||||
"LABEL_PUBLIC_ROOMS": "Salons publics",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Liste des salons publics disponibles sur ce serveur",
|
||||
"BTN_REFRESH": "ACTUALISER",
|
||||
"BTN_REFRESH_TOOLTIP": "Actualiser la liste des salons publics",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Actualisation...",
|
||||
"LABEL_ACTIVE_ROOM": "Salon actif",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "Le salon auquel vous êtes actuellement connecté",
|
||||
"ACTIVE_ROOM_NONE": "AUCUN",
|
||||
"ACTIVE_SERVER_OFFICIAL": "Serveur officiel",
|
||||
"LABEL_INVITE_LINK": "Lien d'invitation",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Partagez ce lien avec vos amis pour qu'ils vous rejoignent",
|
||||
"LABEL_PEERS_IN_ROOM": "Membres dans le salon",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Autres utilisateurs connectés à ce salon",
|
||||
"NO_PEERS_CONNECTED": "Aucun membre connecté",
|
||||
"BTN_LEAVE_ROOM": "Quitter le salon",
|
||||
"LABEL_SELECT_VIDEO": "Choisir une vidéo",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Choisissez l'onglet du navigateur contenant la vidéo à synchroniser",
|
||||
"OPTION_SELECT_TAB": "-- Choisir un onglet --",
|
||||
"LABEL_REMOTE_CONTROL": "Contrôle à distance",
|
||||
"BTN_COPY_INVITE": "📋 Lien d'invitation",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copier le lien d'invitation",
|
||||
"BTN_PLAY": "▶ Lecture",
|
||||
"BTN_PLAY_TOOLTIP": "Envoyer une commande Lecture à tout le monde",
|
||||
"BTN_PAUSE": "⏸ Pause",
|
||||
"BTN_PAUSE_TOOLTIP": "Envoyer une commande Pause à tout le monde",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC_TOOLTIP": "Forcer tous les utilisateurs à se synchroniser",
|
||||
"OPTION_JUMP_TO_OTHERS": "Rejoindre les autres",
|
||||
"OPTION_JUMP_TO_ME": "Les amener à moi",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "Choisir la cible de synchronisation",
|
||||
"LABEL_LAST_ACTIVITY": "Dernière activité",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Affiche la dernière commande de lecture, pause ou recherche",
|
||||
"NO_RECENT_COMMANDS": "Aucune commande récente",
|
||||
"LOBBY_HEADER": "LOBBY D'ÉPISODE",
|
||||
"LOBBY_WAITING_FOR": "🎬 En attente de : \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "En attente des membres...",
|
||||
"BTN_SKIP_PLAY": "Ignorer & Lancer",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Annuler le lobby et lancer la lecture quand même",
|
||||
"LOBBY_CONNECT_FIRST": "Rejoignez d'abord un salon",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "Vous devez rejoindre un salon via un lien d'invitation ou en créer un nouveau pour synchroniser des vidéos.",
|
||||
"BTN_CREATE_ROOM_ALT": "Créer un nouveau salon",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Créer un salon aléatoire et le rejoindre",
|
||||
"LABEL_USERNAME": "Votre pseudo",
|
||||
"LABEL_USERNAME_TOOLTIP": "Votre pseudo permet aux autres de vous identifier.",
|
||||
"PLACEHOLDER_USERNAME": "Koala anonyme",
|
||||
"LABEL_HIDE_CLUTTER": "Liste d'onglets épurée",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtre les onglets non-vidéo et les domaines non pertinents pour garder la liste propre",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Synchro auto l'épisode suivant",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Met en pause et attend tous les membres lors d'un changement d'épisode, puis démarre de manière synchrone.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Copie auto du lien",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Copie automatiquement le lien d'invitation dans votre presse-papiers lors de la création d'un salon.",
|
||||
"LABEL_NOTIFICATIONS": "Notifications de navigateur",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Affiche des notifications système lorsqu'un membre arrive/part ou lance/met en pause la lecture.",
|
||||
"LABEL_LANGUAGE": "Langue de l'application",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Choisissez la langue de l'application",
|
||||
"LABEL_TROUBLESHOOTING": "Dépannage",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "Outils pour résoudre les problèmes de connexion",
|
||||
"BTN_REGEN_ID": "Régénérer l'identifiant",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Régénérer votre identifiant interne et vous reconnecter",
|
||||
"REGEN_ID_DESC": "Utilisez cette option si vous rencontrez des erreurs de 'Double identité'.",
|
||||
"LABEL_CONN_STATUS": "Statut de la connexion",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Statut actuel de la connexion WebSocket",
|
||||
"CONN_STATUS_DISCONNECTED": "Déconnecté",
|
||||
"BTN_RETRY": "RÉESSAYER",
|
||||
"BTN_RETRY_TOOLTIP": "Tenter de se reconnecter au serveur",
|
||||
"BTN_COPY_LOGS": "Copier les journaux",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Copier les journaux dans le presse-papiers",
|
||||
"LABEL_VIDEO_DEBUG": "Infos de débogage vidéo",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Détails techniques de l'élément vidéo actuellement sélectionné",
|
||||
"VIDEO_DEBUG_EMPTY": "Aucun onglet sélectionné ou aucune vidéo détectée.",
|
||||
"LABEL_HISTORY": "Historique complet",
|
||||
"LABEL_HISTORY_TOOLTIP": "Historique chronologique de toutes les commandes de synchronisation dans le salon",
|
||||
"HISTORY_EMPTY": "Aucune activité pour le moment",
|
||||
"LABEL_LOGS": "Journaux (50 derniers)",
|
||||
"LABEL_LOGS_TOOLTIP": "Journaux techniques pour le débogage de la connexion",
|
||||
"BTN_CLEAR": "EFFACER",
|
||||
"BTN_CLEAR_TOOLTIP": "Effacer la sortie des journaux",
|
||||
"LABEL_GITHUB": "Dépôt GitHub",
|
||||
"BTN_ONBOARDING_SKIP": "Passer",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "Passer le didacticiel",
|
||||
"BTN_ONBOARDING_NEXT": "Suivant",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "Étape suivante",
|
||||
"ONBOARDING_1_TITLE": "Bienvenue sur KoalaSync !",
|
||||
"ONBOARDING_1_TEXT": "Regardez des vidéos en parfaite synchronisation — où que vous soyez. Faisons un petit tour !",
|
||||
"ONBOARDING_2_TITLE": "1. Créer un salon",
|
||||
"ONBOARDING_2_TEXT": "Commencez ici. Créez un salon et partagez le lien d'invitation avec vos amis.",
|
||||
"ONBOARDING_3_TITLE": "2. Choisir une vidéo",
|
||||
"ONBOARDING_3_TEXT": "Naviguez ici pour sélectionner la vidéo à synchroniser. Lecture, pause, recherche — tout le monde reste synchrone.",
|
||||
"ONBOARDING_4_TITLE": "3. Personnaliser",
|
||||
"ONBOARDING_4_TEXT": "Choisissez un pseudo sympa pour que vos amis sachent qui vous êtes.",
|
||||
"ONBOARDING_5_TITLE": "Vous êtes prêt !",
|
||||
"ONBOARDING_5_TEXT": "Il est temps de préparer le pop-corn. Bon visionnage !",
|
||||
"ERR_CONN_TIMEOUT": "Délai de connexion dépassé. Veuillez réessayer.",
|
||||
"ERR_INVALID_SERVER_URL": "Format d'adresse de serveur non valide.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identifiant non encore chargé. Veuillez patienter et réessayer.",
|
||||
"ERR_NO_PEERS_TIME": "Aucun autre membre avec une position connue. Basculez sur 'Les amener à moi'.",
|
||||
"ERR_NO_VIDEO_TAB": "Impossible de se connecter à l'onglet vidéo.",
|
||||
"ERR_SELECT_VIDEO": "Veuillez d'abord sélectionner une vidéo !",
|
||||
"TOAST_INVITE_COPIED": "Lien d'invitation copié !",
|
||||
"TOAST_COPY_FAILED": "Échec de la copie dans le presse-papiers",
|
||||
"TOAST_LOBBY_SKIPPED": "Lobby d'épisode ignoré.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Échec de l'annulation du lobby.",
|
||||
"TOAST_LOGS_COPIED": "Copié !",
|
||||
"TOAST_PEER_JOINED": "{name} a rejoint le salon",
|
||||
"TOAST_PEER_LEFT": "{name} a quitté le salon",
|
||||
"TOAST_PEER_ACTION": "{name} a {action}",
|
||||
"STATUS_CONNECTED": "Connecté",
|
||||
"STATUS_RECONNECTING": "Reconnexion...",
|
||||
"STATUS_CONNECTING": "Connexion...",
|
||||
"STATUS_FAILED": "Échec",
|
||||
"STATUS_DISCONNECTED": "Déconnecté",
|
||||
"BTN_STATE_JOINING": "🚀 Connexion...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Reconnexion...",
|
||||
"BTN_STATE_PLAYING": "▶ Lecture...",
|
||||
"BTN_STATE_PAUSING": "⏸ Pause...",
|
||||
"BTN_STATE_SYNCING_GROUP": "Synchro au groupe ({time})...",
|
||||
"BTN_STATE_SYNCING": "Synchronisation...",
|
||||
"BTN_STATE_SYNCED": "✅ Synchronisé !",
|
||||
"NOTIF_PLAY": "lancé la lecture",
|
||||
"NOTIF_PAUSE": "mis la lecture en pause",
|
||||
"NOTIF_SEEK": "déplacé la position dans la vidéo",
|
||||
"NOTIF_FORCE_PREPARE": "lancé une synchronisation forcée",
|
||||
"NOTIF_FORCE_EXECUTE": "synchronisé tout le monde",
|
||||
"DEBUG_NO_TAB": "Aucun onglet cible sélectionné.",
|
||||
"DEBUG_COMM_FAIL": "Impossible de communiquer avec l'onglet vidéo.",
|
||||
"EMPTY_PEERS_TITLE": "Aucun membre pour l'instant",
|
||||
"EMPTY_PEERS_HINT": "Partagez votre lien d'invitation pour commencer",
|
||||
"EMPTY_HISTORY_TITLE": "Aucune activité pour l'instant",
|
||||
"EMPTY_HISTORY_HINT": "Lancez, mettez en pause ou déplacez la position pour voir l'historique",
|
||||
"EMPTY_LOGS_TITLE": "Aucun journal",
|
||||
"EMPTY_LOGS_HINT": "Les événements de connexion s'afficheront ici",
|
||||
"EMPTY_ROOMS_TITLE": "Aucun salon actif",
|
||||
"EMPTY_ROOMS_HINT": "Créez un salon ou actualisez pour trouver des salons publics",
|
||||
"LABEL_YOU": "Vous",
|
||||
"ONBOARDING_DONE": "Terminé !",
|
||||
"LABEL_LOBBY_PEER_READY": "Prêt",
|
||||
"LABEL_LOBBY_PEER_LOADING": "Chargement...",
|
||||
"LABEL_PASSWORD_PROTECTED": "Protégé par mot de passe",
|
||||
"LABEL_PEERS_COUNT": "{count} membres",
|
||||
"LABEL_CUSTOM_SERVER": "Serveur personnalisé",
|
||||
"BTN_STATE_CREATING": "🚀 Création de la salle...",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Échec de la synchronisation de l'épisode",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "Synchronisation automatique annulée : {reason}. Vous devrez peut-être synchroniser manuellement.",
|
||||
"LOBBY_CANCEL_TIMEOUT": "Délai d'attente dépassé",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Délai dépassé (récupéré)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Tous les autres membres sont partis",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Délai dépassé — tous les membres n'ont pas chargé l'épisode",
|
||||
"LOBBY_CANCEL_USER": "Annulé par l'utilisateur",
|
||||
"NOTIF_ERROR_TITLE": "Erreur KoalaSync"
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
# KoalaSync Browser Extension v2.0 - i18n Technical Implementation Plan
|
||||
|
||||
Welcome, future Antigravity AI agent! This document is placed directly in the codebase at `/extension/locales/i18n_plan.md` to serve as a comprehensive architectural handbook for the next steps in adding **full internationalization (i18n) support to the browser extension itself** while maintaining 100% video-sync and background communication safety.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Context & Audited Scope
|
||||
|
||||
KoalaSync is a lightweight, premium browser extension (Chrome & Firefox Manifest V3) for synchronized video playback. The landing pages are already compiled dynamically in 6 languages:
|
||||
* English (`en`)
|
||||
* German (`de`)
|
||||
* French (`fr`)
|
||||
* Spanish (`es`)
|
||||
* Portuguese (Brazil) (`pt-BR`)
|
||||
* Russian (`ru`)
|
||||
|
||||
Our goal is to build an identical, premium translation engine for the extension itself.
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ Architectural Choice: Custom JSON Dictionary Engine (Approach B)
|
||||
|
||||
We evaluated two paths:
|
||||
1. **Approach A (Native `chrome.i18n` API):** Uses `_locales/` directory. Too rigid—cannot support real-time dynamic switching inside the extension Settings dropdown without closing/re-opening the popup.
|
||||
2. **Approach B (Custom Unified JSON Engine):** Uses flat JSON files matching our website files (`"KEY": "Value"`). Dynamically merges the target dictionary with baseline English `en.json` at runtime, programmatically providing an **airtight English fallback** and **real-time DOM translations** without popup reload.
|
||||
|
||||
We chose **Approach B** for maximum compatibility, premium real-time toggling, and clean fallback safety.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Resolve, Load, & State Flow
|
||||
|
||||
1. **On launch:** Look for saved language in `chrome.storage.sync.get('locale')`.
|
||||
2. **Fallback Autodetect:** If no saved language, read `navigator.language` or `chrome.i18n.getUILanguage()`.
|
||||
* If the detected locale is supported, set as active.
|
||||
* If not supported, default to English (`en`).
|
||||
3. **Dictionary Resolution:**
|
||||
* Asynchronously load the English baseline dictionary (`/extension/locales/en.json`).
|
||||
* If the target language is different, load target JSON (e.g. `/extension/locales/de.json`) and execute `Object.assign({}, enDict, targetDict)`. This guarantees dynamic translation while cleanly falling back to English for any missing keys.
|
||||
4. **DOM Replacements:** Scan for `data-i18n`, `data-i18n-title`, and `data-i18n-placeholder` attributes, and translate them on the fly.
|
||||
5. **Persistence:** Save dynamic selection modifications from the dropdown into `chrome.storage.sync`. Trigger instant DOM re-translation on change.
|
||||
|
||||
---
|
||||
|
||||
## 📂 Proposed File Structure
|
||||
|
||||
```
|
||||
KoalaPlay/
|
||||
└── extension/
|
||||
├── locales/ # [NEW] Contains flat translation maps
|
||||
│ ├── i18n_plan.md # This roadmap file
|
||||
│ ├── en.json # Flat English baseline keys
|
||||
│ ├── de.json # German keys
|
||||
│ ├── fr.json # French keys
|
||||
│ ├── es.json # Spanish keys
|
||||
│ ├── pt-BR.json # Portuguese (Brasil) keys
|
||||
│ └── ru.json # Russian keys
|
||||
├── i18n.js # [NEW] ESM translation engine module
|
||||
├── popup.html # Modified with data-i18n attributes
|
||||
├── popup.js # Modified to initialize locales and update variables
|
||||
└── background.js # Modified to push localized notification alerts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Draft Code Snippets
|
||||
|
||||
### 1. `i18n.js` (Zero-Dependency Engine Module)
|
||||
```javascript
|
||||
// extension/i18n.js
|
||||
export const SUPPORTED_LANGUAGES = ['en', 'de', 'fr', 'es', 'pt-BR', 'ru'];
|
||||
export const DEFAULT_LANGUAGE = 'en';
|
||||
|
||||
let activeDictionary = {};
|
||||
|
||||
export async function loadLocale(langCode) {
|
||||
const resolvedLang = SUPPORTED_LANGUAGES.includes(langCode) ? langCode : DEFAULT_LANGUAGE;
|
||||
try {
|
||||
const enResponse = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
|
||||
const enDict = await enResponse.json();
|
||||
|
||||
if (resolvedLang === DEFAULT_LANGUAGE) {
|
||||
activeDictionary = enDict;
|
||||
return;
|
||||
}
|
||||
|
||||
const targetResponse = await fetch(chrome.runtime.getURL(`locales/${resolvedLang}.json`));
|
||||
const targetDict = await targetResponse.json();
|
||||
|
||||
activeDictionary = Object.assign({}, enDict, targetDict);
|
||||
} catch (err) {
|
||||
console.error('[i18n] Failed to load dictionary. Falling back to English:', err);
|
||||
const rescue = await fetch(chrome.runtime.getURL(`locales/${DEFAULT_LANGUAGE}.json`));
|
||||
activeDictionary = await rescue.json();
|
||||
}
|
||||
}
|
||||
|
||||
export function getMessage(key) {
|
||||
return activeDictionary[key] || key;
|
||||
}
|
||||
|
||||
export function translateDOM() {
|
||||
// Translate text nodes
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
const translated = getMessage(key);
|
||||
const img = el.querySelector('img');
|
||||
if (img) {
|
||||
el.innerHTML = '';
|
||||
el.appendChild(img);
|
||||
el.appendChild(document.createTextNode(' ' + translated));
|
||||
} else {
|
||||
el.textContent = translated;
|
||||
}
|
||||
});
|
||||
|
||||
// Translate tooltips
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
el.setAttribute('title', getMessage(key));
|
||||
});
|
||||
|
||||
// Translate placeholders
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
el.setAttribute('placeholder', getMessage(key));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Markup Changes (`popup.html`)
|
||||
* Annotate text elements: `<button class="tab-btn" data-tab="tab-settings" data-i18n="TAB_SETTINGS" data-i18n-title="TAB_SETTINGS_TOOLTIP">Settings</button>`
|
||||
* Add Language Dropdown in the Settings Panel:
|
||||
```html
|
||||
<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;" data-i18n="LABEL_LANGUAGE" title="Choose your preferred extension language">App Language</label>
|
||||
<select id="langSelector" style="width: 150px; padding: 6px 10px; font-size: 13px; cursor: pointer;">
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="pt-BR">Português (Brasil)</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 3. Dynamic Script Wiring (`popup.js`)
|
||||
* Import our engine: `import { loadLocale, translateDOM, getMessage } from './i18n.js';`
|
||||
* Initialize during startup:
|
||||
```javascript
|
||||
async function init() {
|
||||
const data = await chrome.storage.sync.get(['locale', ...]);
|
||||
let activeLang = data.locale;
|
||||
if (!activeLang) {
|
||||
const systemLang = (navigator.language || chrome.i18n.getUILanguage()).split('-')[0];
|
||||
activeLang = ['en', 'de', 'fr', 'es', 'pt', 'ru'].includes(systemLang) ? (systemLang === 'pt' ? 'pt-BR' : systemLang) : 'en';
|
||||
chrome.storage.sync.set({ locale: activeLang });
|
||||
}
|
||||
await loadLocale(activeLang);
|
||||
translateDOM();
|
||||
|
||||
// Select matching option in selector
|
||||
const langSelector = document.getElementById('langSelector');
|
||||
if (langSelector) langSelector.value = activeLang;
|
||||
|
||||
// rest of standard init...
|
||||
}
|
||||
```
|
||||
* Listen to Settings change event:
|
||||
```javascript
|
||||
const langSelector = document.getElementById('langSelector');
|
||||
if (langSelector) {
|
||||
langSelector.addEventListener('change', async () => {
|
||||
const selectedLang = langSelector.value;
|
||||
await chrome.storage.sync.set({ locale: selectedLang });
|
||||
await loadLocale(selectedLang);
|
||||
translateDOM();
|
||||
// Re-render empty elements and tab list using new dynamic strings
|
||||
refreshLogs();
|
||||
refreshHistory();
|
||||
populateTabs();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Background Notifications (`background.js`)
|
||||
* Notifications inside `showNotification` should fetch dynamic keys based on `chrome.storage.sync` language settings and build clean alerts dynamically using `chrome.storage.sync.get('locale')`.
|
||||
* Ensure that WebSocket event transmissions and video player scripts in `content.js` remain **completely untouched and unaware** of the localization engine.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Safety Guardrail
|
||||
|
||||
* **WebSocket Engine Integrity:** Under no circumstances should `background.js` Socket.IO handshakes, rate limits, or binary protocol headers be adjusted. i18n is strictly an interface rendering skin layer.
|
||||
* **Player Control Pipeline Integrity:** `content.js` expectedEvent suppression tables, HLS buffer calculations, and Media Session API listeners must remain untouched. Do not bind any i18n logic into the content script's active tracking loop.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Verification Tasks
|
||||
|
||||
1. **Autodetection Audit:** Force-load the extension in Chrome/Firefox in a non-English language container and ensure it starts in that language or English gracefully.
|
||||
2. **Integrity Validation Script:** Write a sanity node checker script `/scripts/test-locales.js` to ensure all key structures are uniform across all JSON files.
|
||||
3. **Real-Time Refresh Verification:** Change dynamic drop-down preferences and verify that every element and alert swaps languages cleanly with zero redraw glitches or page crashes.
|
||||
@@ -0,0 +1,180 @@
|
||||
{
|
||||
"LANG_CODE": "pt-BR",
|
||||
"HTML_CLASS": "lang-pt-br",
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "Sala",
|
||||
"TAB_ROOM_TOOLTIP": "Configurações da sala e conexão",
|
||||
"TAB_SYNC": "Sincro",
|
||||
"TAB_SYNC_TOOLTIP": "Controles de sincronia de vídeo e ações",
|
||||
"TAB_SETTINGS": "Opções",
|
||||
"TAB_SETTINGS_TOOLTIP": "Preferências da extensão",
|
||||
"TAB_STATUS": "Status",
|
||||
"TAB_STATUS_TOOLTIP": "Diagnósticos avançados e logs",
|
||||
"BTN_CREATE_ROOM": "+ Criar nova sala",
|
||||
"MANUAL_CONNECT_HEADER": "Conexão manual / Avançado",
|
||||
"LABEL_SERVER": "Servidor",
|
||||
"BTN_SERVER_OFFICIAL": "Oficial",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "Usar o servidor oficial confiável",
|
||||
"BTN_SERVER_CUSTOM": "Perso",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Conectar ao seu próprio servidor auto-hospedado",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://seu-servidor:3000",
|
||||
"LABEL_ROOM_ID": "ID da sala",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "O identificador exclusivo para sua sala de sincronização",
|
||||
"PLACEHOLDER_ROOM_ID": "Digite o ID da sala",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "O ID exclusivo da sala na qual você deseja entrar",
|
||||
"LABEL_PASSWORD": "Senha (Opcional)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Senha opcional para restringir o acesso à sala",
|
||||
"PLACEHOLDER_PASSWORD": "Senha da sala (opcional)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Senha para a sala (deixe em branco se não houver)",
|
||||
"BTN_JOIN_ROOM": "Entrar / Criar sala",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Conectar à sala",
|
||||
"LABEL_PUBLIC_ROOMS": "Salas públicas",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Lista de salas públicas disponíveis neste servidor",
|
||||
"BTN_REFRESH": "ATUALIZAR",
|
||||
"BTN_REFRESH_TOOLTIP": "Atualizar a lista de salas públicas",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Atualizando...",
|
||||
"LABEL_ACTIVE_ROOM": "Sala ativa",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "A sala à qual você está conectado no momento",
|
||||
"ACTIVE_ROOM_NONE": "NENHUMA",
|
||||
"ACTIVE_SERVER_OFFICIAL": "Servidor oficial",
|
||||
"LABEL_INVITE_LINK": "Link de convite",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Compartilhe este link com amigos para que eles entrem",
|
||||
"LABEL_PEERS_IN_ROOM": "Participantes na sala",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Outros usuários conectados atualmente a esta sala",
|
||||
"NO_PEERS_CONNECTED": "Sem participantes conectados",
|
||||
"BTN_LEAVE_ROOM": "Sair da sala",
|
||||
"LABEL_SELECT_VIDEO": "Selecionar vídeo",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Escolha a aba do navegador contendo o vídeo a ser sincronizado",
|
||||
"OPTION_SELECT_TAB": "-- Selecione uma aba --",
|
||||
"LABEL_REMOTE_CONTROL": "Controle remoto",
|
||||
"BTN_COPY_INVITE": "📋 Link de convite",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copiar link de convite",
|
||||
"BTN_PLAY": "▶ Reproduzir",
|
||||
"BTN_PLAY_TOOLTIP": "Enviar um comando de reproduzir para todos",
|
||||
"BTN_PAUSE": "⏸ Pausar",
|
||||
"BTN_PAUSE_TOOLTIP": "Enviar um comando de pausar para todos",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC_TOOLTIP": "Forçar todos os usuários a sincronizar",
|
||||
"OPTION_JUMP_TO_OTHERS": "Ir para os outros",
|
||||
"OPTION_JUMP_TO_ME": "Trazer para mim",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "Escolher o alvo de sincronização",
|
||||
"LABEL_LAST_ACTIVITY": "Última atividade",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Mostra o comando mais recente de reproduzir, pausar ou buscar",
|
||||
"NO_RECENT_COMMANDS": "Sem comandos recentes",
|
||||
"LOBBY_HEADER": "LOBBY DE EPISÓDIO",
|
||||
"LOBBY_WAITING_FOR": "🎬 Aguardando: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "Aguardando participantes...",
|
||||
"BTN_SKIP_PLAY": "Pular e reproduzir",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Cancelar o lobby e reproduzir assim mesmo",
|
||||
"LOBBY_CONNECT_FIRST": "Entre em uma sala primeiro",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "Você precisa entrar em uma sala através de um link de convite ou criar uma nova para sincronizar vídeos.",
|
||||
"BTN_CREATE_ROOM_ALT": "Criar nova sala",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Criar uma nova sala aleatória e entrar",
|
||||
"LABEL_USERNAME": "Seu nome de usuário",
|
||||
"LABEL_USERNAME_TOOLTIP": "O nome de usuário ajuda os outros a identificá-lo.",
|
||||
"PLACEHOLDER_USERNAME": "Coala anônimo",
|
||||
"LABEL_HIDE_CLUTTER": "Lista de abas limpa",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Filtra abas que não são de vídeo e domínios não relacionados para manter a lista organizada",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Sincro auto do próximo episódio",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Pausa automaticamente e aguarda todos ao mudar de episódio, depois inicia em sincronia.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Auto-copiar link",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Copia automaticamente o link de convite para a área de transferência ao criar uma nova sala.",
|
||||
"LABEL_NOTIFICATIONS": "Notificações do navegador",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Mostra notificações do sistema quando alguém entra/sai ou reproduz/pausa.",
|
||||
"LABEL_LANGUAGE": "Idioma do aplicativo",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Escolha o idioma de sua preferência para a extensão",
|
||||
"LABEL_TROUBLESHOOTING": "Resolução de problemas",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "Ferramentas para solucionar problemas de conexão",
|
||||
"BTN_REGEN_ID": "Regenerar ID de participante",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Regenerar seu ID interno e conectar novamente",
|
||||
"REGEN_ID_DESC": "Use esta opção se notar erros de 'Identidade duplicada'.",
|
||||
"LABEL_CONN_STATUS": "Status da conexão",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Status atual da conexão WebSocket",
|
||||
"CONN_STATUS_DISCONNECTED": "Desconectado",
|
||||
"BTN_RETRY": "REENTRAR",
|
||||
"BTN_RETRY_TOOLTIP": "Tentar reconectar ao servidor",
|
||||
"BTN_COPY_LOGS": "Copiar logs",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Copiar logs para a área de transferência para compartilhar",
|
||||
"LABEL_VIDEO_DEBUG": "Informações de depuração de vídeo",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Detalhes técnicos sobre o elemento de vídeo selecionado atualmente",
|
||||
"VIDEO_DEBUG_EMPTY": "Nenhuma aba selecionada ou vídeo detectado.",
|
||||
"LABEL_HISTORY": "Histórico completo",
|
||||
"LABEL_HISTORY_TOOLTIP": "Registro cronológico de todos os comandos de sincronização na sala",
|
||||
"HISTORY_EMPTY": "Sem atividade ainda",
|
||||
"LABEL_LOGS": "Logs (Últimos 50)",
|
||||
"LABEL_LOGS_TOOLTIP": "Logs técnicos de conexão para depuração",
|
||||
"BTN_CLEAR": "LIMPAR",
|
||||
"BTN_CLEAR_TOOLTIP": "Limpar a saída dos logs",
|
||||
"LABEL_GITHUB": "Repositório GitHub",
|
||||
"BTN_ONBOARDING_SKIP": "Pular",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "Pular o tutorial",
|
||||
"BTN_ONBOARDING_NEXT": "Próximo",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "Ir para o próximo passo",
|
||||
"ONBOARDING_1_TITLE": "Bem-vindo ao KoalaSync!",
|
||||
"ONBOARDING_1_TEXT": "Assista a vídeos juntos em perfeita sincronia, não importa onde estejam. Vamos fazer um tour rápido!",
|
||||
"ONBOARDING_2_TITLE": "1. Criar uma sala",
|
||||
"ONBOARDING_2_TEXT": "Comece aqui. Crie uma sala e compartilhe o link de convite com seus amigos.",
|
||||
"ONBOARDING_3_TITLE": "2. Selecionar vídeo",
|
||||
"ONBOARDING_3_TEXT": "Navegue aqui para selecionar o vídeo a ser sincronizado. Reproduza, pause e busque: todos permanecem sincronizados.",
|
||||
"ONBOARDING_4_TITLE": "3. Personalizar",
|
||||
"ONBOARDING_4_TEXT": "Escolha um nome de usuário divertido para que seus amigos saibam quem você é.",
|
||||
"ONBOARDING_5_TITLE": "Tudo pronto!",
|
||||
"ONBOARDING_5_TEXT": "Hora de pegar a pipoca. Divirta-se assistindo com seus amigos!",
|
||||
"ERR_CONN_TIMEOUT": "Tempo limite de conexão esgotado. Tente novamente.",
|
||||
"ERR_INVALID_SERVER_URL": "Formato de URL do servidor inválido.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identidade não carregada ainda. Aguarde um momento e tente novamente.",
|
||||
"ERR_NO_PEERS_TIME": "Nenhum outro participante com posição conhecida. Mude para 'Traerlos a mí' / 'Trazer para mim'.",
|
||||
"ERR_NO_VIDEO_TAB": "Não foi possível conectar à aba do vídeo.",
|
||||
"ERR_SELECT_VIDEO": "Selecione um vídeo primeiro!",
|
||||
"TOAST_INVITE_COPIED": "Link de convite copiado!",
|
||||
"TOAST_COPY_FAILED": "Falha ao copiar para a área de transferência",
|
||||
"TOAST_LOBBY_SKIPPED": "Lobby de episódio ignorado.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Falha ao ignorar o lobby.",
|
||||
"TOAST_LOGS_COPIED": "Copiado!",
|
||||
"TOAST_PEER_JOINED": "{name} entrou na sala",
|
||||
"TOAST_PEER_LEFT": "{name} saiu da sala",
|
||||
"TOAST_PEER_ACTION": "{name} {action}",
|
||||
"STATUS_CONNECTED": "Conectado",
|
||||
"STATUS_RECONNECTING": "Reconectando...",
|
||||
"STATUS_CONNECTING": "Conectando...",
|
||||
"STATUS_FAILED": "Falhou",
|
||||
"STATUS_DISCONNECTED": "Desconectado",
|
||||
"BTN_STATE_JOINING": "🚀 Entrando...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Reconectando...",
|
||||
"BTN_STATE_PLAYING": "▶ Reproduzindo...",
|
||||
"BTN_STATE_PAUSING": "⏸ Pausando...",
|
||||
"BTN_STATE_SYNCING_GROUP": "Sincronizando com o grupo ({time})...",
|
||||
"BTN_STATE_SYNCING": "Sincronizando...",
|
||||
"BTN_STATE_SYNCED": "✅ Sincronizado!",
|
||||
"NOTIF_PLAY": "iniciou a reprodução",
|
||||
"NOTIF_PAUSE": "pausou a reprodução",
|
||||
"NOTIF_SEEK": "mudou a posição do vídeo",
|
||||
"NOTIF_FORCE_PREPARE": "iniciou uma sincronização forçada",
|
||||
"NOTIF_FORCE_EXECUTE": "sincronizou todos",
|
||||
"DEBUG_NO_TAB": "Nenhuma aba alvo selecionada.",
|
||||
"DEBUG_COMM_FAIL": "Não foi possível comunicar com o vídeo da aba.",
|
||||
"EMPTY_PEERS_TITLE": "Nenhum participante ainda",
|
||||
"EMPTY_PEERS_HINT": "Compartilhe seu link de convite para começar",
|
||||
"EMPTY_HISTORY_TITLE": "Sem atividade ainda",
|
||||
"EMPTY_HISTORY_HINT": "Reproduza, pause ou busque para ver o histórico",
|
||||
"EMPTY_LOGS_TITLE": "Sem logs",
|
||||
"EMPTY_LOGS_HINT": "Os eventos de conexão aparecerão aqui",
|
||||
"EMPTY_ROOMS_TITLE": "Sem salas ativas",
|
||||
"EMPTY_ROOMS_HINT": "Crie uma sala ou atualize para buscar salas públicas",
|
||||
"LABEL_YOU": "Você",
|
||||
"ONBOARDING_DONE": "Concluído!",
|
||||
"LABEL_LOBBY_PEER_READY": "Pronto",
|
||||
"LABEL_LOBBY_PEER_LOADING": "Carregando...",
|
||||
"LABEL_PASSWORD_PROTECTED": "Protegido por senha",
|
||||
"LABEL_PEERS_COUNT": "{count} participantes",
|
||||
"LABEL_CUSTOM_SERVER": "Servidor personalizado",
|
||||
"BTN_STATE_CREATING": "🚀 Criando sala...",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Falha na sincronização do episódio",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "Sincronização automática cancelada: {reason}. Pode ser necessário sincronizar manualmente.",
|
||||
"LOBBY_CANCEL_TIMEOUT": "Tempo limite esgotado",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Tempo limite esgotado (recuperado)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Todos os outros participantes saíram",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Tempo limite esgotado — nem todos os participantes carregaram o episódio",
|
||||
"LOBBY_CANCEL_USER": "Cancelado pelo usuário",
|
||||
"NOTIF_ERROR_TITLE": "Erro do KoalaSync"
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
{
|
||||
"LANG_CODE": "ru",
|
||||
"HTML_CLASS": "lang-ru",
|
||||
"APP_TITLE": "KoalaSync",
|
||||
"TAB_ROOM": "Комната",
|
||||
"TAB_ROOM_TOOLTIP": "Настройки комнаты и подключение",
|
||||
"TAB_SYNC": "Синхро",
|
||||
"TAB_SYNC_TOOLTIP": "Управление синхронизацией видео и действия",
|
||||
"TAB_SETTINGS": "Опции",
|
||||
"TAB_SETTINGS_TOOLTIP": "Настройки расширения",
|
||||
"TAB_STATUS": "Статус",
|
||||
"TAB_STATUS_TOOLTIP": "Дополнительная диагностика и журналы",
|
||||
"BTN_CREATE_ROOM": "+ Создать комнату",
|
||||
"MANUAL_CONNECT_HEADER": "Ручное подключение / Дополнительно",
|
||||
"LABEL_SERVER": "Сервер",
|
||||
"BTN_SERVER_OFFICIAL": "Официальный",
|
||||
"BTN_SERVER_OFFICIAL_TOOLTIP": "Использовать официальный надежный сервер",
|
||||
"BTN_SERVER_CUSTOM": "Свой",
|
||||
"BTN_SERVER_CUSTOM_TOOLTIP": "Подключиться к своему выделенному серверу",
|
||||
"PLACEHOLDER_SERVER_URL": "wss://ваш-сервер:3000",
|
||||
"LABEL_ROOM_ID": "ID комнаты",
|
||||
"LABEL_ROOM_ID_TOOLTIP": "Уникальный идентификатор вашей синхронной комнаты",
|
||||
"PLACEHOLDER_ROOM_ID": "Введите ID комнаты",
|
||||
"PLACEHOLDER_ROOM_ID_TOOLTIP": "Уникальный ID комнаты, к которой хотите присоединиться",
|
||||
"LABEL_PASSWORD": "Пароль (Опционально)",
|
||||
"LABEL_PASSWORD_TOOLTIP": "Дополнительный пароль для ограничения доступа к комнате",
|
||||
"PLACEHOLDER_PASSWORD": "Пароль комнаты (опционально)",
|
||||
"PLACEHOLDER_PASSWORD_TOOLTIP": "Пароль от комнаты (оставьте пустым, если его нет)",
|
||||
"BTN_JOIN_ROOM": "Войти / Создать комнату",
|
||||
"BTN_JOIN_ROOM_TOOLTIP": "Подключиться к комнате",
|
||||
"LABEL_PUBLIC_ROOMS": "Публичные комнаты",
|
||||
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Список публично доступных комнат на этом сервере",
|
||||
"BTN_REFRESH": "ОБНОВИТЬ",
|
||||
"BTN_REFRESH_TOOLTIP": "Обновить список публичных комнат",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Обновление...",
|
||||
"LABEL_ACTIVE_ROOM": "Активная комната",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "Комната, к которой вы сейчас подключены",
|
||||
"ACTIVE_ROOM_NONE": "НЕТ",
|
||||
"ACTIVE_SERVER_OFFICIAL": "Официальный сервер",
|
||||
"LABEL_INVITE_LINK": "Ссылка-приглашение",
|
||||
"LABEL_INVITE_LINK_TOOLTIP": "Поделитесь этой ссылкой с друзьями, чтобы они вошли",
|
||||
"LABEL_PEERS_IN_ROOM": "Участники в комнате",
|
||||
"LABEL_PEERS_IN_ROOM_TOOLTIP": "Другие пользователи, подключенные к этой комнате",
|
||||
"NO_PEERS_CONNECTED": "Нет подключенных участников",
|
||||
"BTN_LEAVE_ROOM": "Выйти из комнаты",
|
||||
"LABEL_SELECT_VIDEO": "Выбрать видео",
|
||||
"LABEL_SELECT_VIDEO_TOOLTIP": "Выберите вкладку браузера, содержащую видео для синхронизации",
|
||||
"OPTION_SELECT_TAB": "-- Выберите вкладку --",
|
||||
"LABEL_REMOTE_CONTROL": "Дистанционное управление",
|
||||
"BTN_COPY_INVITE": "📋 Ссылка",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Копировать ссылку-приглашение",
|
||||
"BTN_PLAY": "▶ Запуск",
|
||||
"BTN_PLAY_TOOLTIP": "Отправить команду воспроизведения всем",
|
||||
"BTN_PAUSE": "⏸ Пауза",
|
||||
"BTN_PAUSE_TOOLTIP": "Отправить команду паузы всем",
|
||||
"BTN_SYNC": "⚡ СИНХРО",
|
||||
"BTN_SYNC_TOOLTIP": "Принудительно синхронизировать всех участников",
|
||||
"OPTION_JUMP_TO_OTHERS": "Перейти к другим",
|
||||
"OPTION_JUMP_TO_ME": "Синхронизировать по мне",
|
||||
"OPTION_JUMP_MODE_TOOLTIP": "Выбрать цель синхронизации",
|
||||
"LABEL_LAST_ACTIVITY": "Последняя активность",
|
||||
"LABEL_LAST_ACTIVITY_TOOLTIP": "Показывает самую последнюю команду воспроизведения, паузы или перемотки",
|
||||
"NO_RECENT_COMMANDS": "Нет последних команд",
|
||||
"LOBBY_HEADER": "ЛОББИ СЕРИИ",
|
||||
"LOBBY_WAITING_FOR": "🎬 Ожидание: \"{title}\"",
|
||||
"LOBBY_WAITING_PEERS": "Ожидание участников...",
|
||||
"BTN_SKIP_PLAY": "Пропустить и запустить",
|
||||
"BTN_SKIP_PLAY_TOOLTIP": "Закрыть лобби и воспроизвести в любом случае",
|
||||
"LOBBY_CONNECT_FIRST": "Сначала войдите в комнату",
|
||||
"LOBBY_CONNECT_FIRST_DESC": "Вам необходимо войти в комнату по ссылке-приглашению или создать новую, чтобы синхронизировать видео.",
|
||||
"BTN_CREATE_ROOM_ALT": "Создать новую комнату",
|
||||
"BTN_CREATE_ROOM_ALT_TOOLTIP": "Создать новую случайную комнату и войти",
|
||||
"LABEL_USERNAME": "Ваше имя пользователя",
|
||||
"LABEL_USERNAME_TOOLTIP": "Имя пользователя помогает другим узнать вас.",
|
||||
"PLACEHOLDER_USERNAME": "Анонимный Коала",
|
||||
"LABEL_HIDE_CLUTTER": "Чистый список вкладок",
|
||||
"LABEL_HIDE_CLUTTER_TOOLTIP": "Скрывает вкладки без видео и несвязанные домены для порядка",
|
||||
"LABEL_AUTO_SYNC_NEXT": "Автосинхрон следующей серии",
|
||||
"LABEL_AUTO_SYNC_NEXT_TOOLTIP": "Автоматически ставит на паузу и ждет всех при смене серии, затем запускает видео синхронно.",
|
||||
"LABEL_AUTO_COPY_INVITE": "Автокопирование ссылки",
|
||||
"LABEL_AUTO_COPY_INVITE_TOOLTIP": "Автоматически копирует ссылку-приглашение в буфер обмена при создании новой комнаты.",
|
||||
"LABEL_NOTIFICATIONS": "Уведомления браузера",
|
||||
"LABEL_NOTIFICATIONS_TOOLTIP": "Показывает системные уведомления, когда кто-то входит/выходит или запускает/приостанавливает воспроизведение.",
|
||||
"LABEL_LANGUAGE": "Язык приложения",
|
||||
"LABEL_LANGUAGE_TOOLTIP": "Выберите предпочтительный язык интерфейса расширения",
|
||||
"LABEL_TROUBLESHOOTING": "Устранение неполадок",
|
||||
"LABEL_TROUBLESHOOTING_TOOLTIP": "Инструменты для исправления проблем с подключением",
|
||||
"BTN_REGEN_ID": "Сбросить Peer ID",
|
||||
"BTN_REGEN_ID_TOOLTIP": "Сбросить ваш внутренний идентификатор и переподключиться",
|
||||
"REGEN_ID_DESC": "Используйте при появлении ошибок дублирования идентификации.",
|
||||
"LABEL_CONN_STATUS": "Статус подключения",
|
||||
"LABEL_CONN_STATUS_TOOLTIP": "Текущий статус WebSocket-соединения с сервером",
|
||||
"CONN_STATUS_DISCONNECTED": "Отключено",
|
||||
"BTN_RETRY": "ПОВТОРИТЬ",
|
||||
"BTN_RETRY_TOOLTIP": "Попробовать переподключиться к серверу",
|
||||
"BTN_COPY_LOGS": "Копировать логи",
|
||||
"BTN_COPY_LOGS_TOOLTIP": "Скопировать логи в буфер обмена для отправки",
|
||||
"LABEL_VIDEO_DEBUG": "Отладочные данные видео",
|
||||
"LABEL_VIDEO_DEBUG_TOOLTIP": "Технические сведения о выбранном видеоэлементе",
|
||||
"VIDEO_DEBUG_EMPTY": "Вкладка не выбрана или видео не обнаружено.",
|
||||
"LABEL_HISTORY": "История действий",
|
||||
"LABEL_HISTORY_TOOLTIP": "Хронологический список всех команд синхронизации в комнате",
|
||||
"HISTORY_EMPTY": "Нет недавней активности",
|
||||
"LABEL_LOGS": "Логи (Последние 50)",
|
||||
"LABEL_LOGS_TOOLTIP": "Технические системные сообщения подключения",
|
||||
"BTN_CLEAR": "ОЧИСТИТЬ",
|
||||
"BTN_CLEAR_TOOLTIP": "Очистить вывод логов",
|
||||
"LABEL_GITHUB": "Репозиторий GitHub",
|
||||
"BTN_ONBOARDING_SKIP": "Пропустить",
|
||||
"BTN_ONBOARDING_SKIP_TOOLTIP": "Пропустить руководство",
|
||||
"BTN_ONBOARDING_NEXT": "Далее",
|
||||
"BTN_ONBOARDING_NEXT_TOOLTIP": "Перейти к следующему шагу",
|
||||
"ONBOARDING_1_TITLE": "Добро пожаловать в KoalaSync!",
|
||||
"ONBOARDING_1_TEXT": "Смотрите видео в идеальной синхронизации вместе — где бы вы ни находились. Давайте проведем небольшую экскурсию!",
|
||||
"ONBOARDING_2_TITLE": "1. Создайте комнату",
|
||||
"ONBOARDING_2_TEXT": "Начните здесь. Создайте комнату и поделитесь ссылкой-приглашением со своими друзьями.",
|
||||
"ONBOARDING_3_TITLE": "2. Выберите видео",
|
||||
"ONBOARDING_3_TEXT": "Выберите вкладку с видео для синхронизации. Запуск, пауза, перемотка — воспроизведение синхронизируется у всех.",
|
||||
"ONBOARDING_4_TITLE": "3. Настройте профиль",
|
||||
"ONBOARDING_4_TEXT": "Введите имя пользователя, чтобы друзья знали, кто совершает действия.",
|
||||
"ONBOARDING_5_TITLE": "Всё готово!",
|
||||
"ONBOARDING_5_TEXT": "Время готовить попкорн. Приятного совместного просмотра!",
|
||||
"ERR_CONN_TIMEOUT": "Время ожидания подключения истекло. Пожалуйста, попробуйте снова.",
|
||||
"ERR_INVALID_SERVER_URL": "Неверный формат URL-адреса сервера.",
|
||||
"ERR_IDENTITY_NOT_LOADED": "Идентификатор еще не загружен. Подождите немного и повторите попытку.",
|
||||
"ERR_NO_PEERS_TIME": "Нет других участников с известной позицией плеера. Выберите 'Синхронизировать по мне'.",
|
||||
"ERR_NO_VIDEO_TAB": "Не удалось подключиться к вкладке с видео.",
|
||||
"ERR_SELECT_VIDEO": "Пожалуйста, сначала выберите вкладку с видео!",
|
||||
"TOAST_INVITE_COPIED": "Ссылка скопирована в буфер обмена!",
|
||||
"TOAST_COPY_FAILED": "Не удалось скопировать ссылку в буфер обмена",
|
||||
"TOAST_LOBBY_SKIPPED": "Ожидание серии отменено.",
|
||||
"TOAST_LOBBY_SKIP_FAILED": "Не удалось отменить ожидание серии.",
|
||||
"TOAST_LOGS_COPIED": "Скопировано!",
|
||||
"TOAST_PEER_JOINED": "{name} вошел в комнату",
|
||||
"TOAST_PEER_LEFT": "{name} вышел из комнаты",
|
||||
"TOAST_PEER_ACTION": "{name}: {action}",
|
||||
"STATUS_CONNECTED": "Подключено",
|
||||
"STATUS_RECONNECTING": "Переподключение...",
|
||||
"STATUS_CONNECTING": "Подключение...",
|
||||
"STATUS_FAILED": "Ошибка",
|
||||
"STATUS_DISCONNECTED": "Отключено",
|
||||
"BTN_STATE_JOINING": "🚀 Вход...",
|
||||
"BTN_STATE_RECONNECTING": "🔄 Переподключение...",
|
||||
"BTN_STATE_PLAYING": "▶ Запуск...",
|
||||
"BTN_STATE_PAUSING": "⏸ Пауза...",
|
||||
"BTN_STATE_SYNCING_GROUP": "Синхронизация по группе ({time})...",
|
||||
"BTN_STATE_SYNCING": "Синхронизация...",
|
||||
"BTN_STATE_SYNCED": "✅ Синхронно!",
|
||||
"NOTIF_PLAY": "запустил воспроизведение",
|
||||
"NOTIF_PAUSE": "приостановил воспроизведение",
|
||||
"NOTIF_SEEK": "перемотал видео",
|
||||
"NOTIF_FORCE_PREPARE": "запустил принудительный синхрон",
|
||||
"NOTIF_FORCE_EXECUTE": "синхронизировал воспроизведение у всех",
|
||||
"DEBUG_NO_TAB": "Целевая вкладка не выбрана.",
|
||||
"DEBUG_COMM_FAIL": "Не удалось связаться с плеером на вкладке.",
|
||||
"EMPTY_PEERS_TITLE": "Участников пока нет",
|
||||
"EMPTY_PEERS_HINT": "Поделитесь ссылкой-приглашением, чтобы начать",
|
||||
"EMPTY_HISTORY_TITLE": "История действий пуста",
|
||||
"EMPTY_HISTORY_HINT": "Запускайте, останавливайте или перематывайте видео, чтобы увидеть лог",
|
||||
"EMPTY_LOGS_TITLE": "Журнал пуст",
|
||||
"EMPTY_LOGS_HINT": "События подключения будут отображаться здесь",
|
||||
"EMPTY_ROOMS_TITLE": "Нет активных комнат",
|
||||
"EMPTY_ROOMS_HINT": "Создайте комнату или обновите список, чтобы найти публичные",
|
||||
"LABEL_YOU": "Вы",
|
||||
"ONBOARDING_DONE": "Готово!",
|
||||
"LABEL_LOBBY_PEER_READY": "Готов",
|
||||
"LABEL_LOBBY_PEER_LOADING": "Загрузка...",
|
||||
"LABEL_PASSWORD_PROTECTED": "Защищено паролем",
|
||||
"LABEL_PEERS_COUNT": "{count} участников",
|
||||
"LABEL_CUSTOM_SERVER": "Свой сервер",
|
||||
"BTN_STATE_CREATING": "🚀 Создание комнаты...",
|
||||
"NOTIF_LOBBY_CANCEL_TITLE": "KoalaSync — Ошибка синхронизации серии",
|
||||
"NOTIF_LOBBY_CANCEL_MSG": "Автосинхронизация отменена: {reason}. Возможно, вам придется выполнить синхронизацию вручную.",
|
||||
"LOBBY_CANCEL_TIMEOUT": "Время ожидания истекло",
|
||||
"LOBBY_CANCEL_TIMEOUT_RECOVERED": "Время ожидания истекло (восстановлено)",
|
||||
"LOBBY_CANCEL_PEERS_LEFT": "Все остальные участники вышли",
|
||||
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Время ожидания истекло — не все участники загрузили серию",
|
||||
"LOBBY_CANCEL_USER": "Отменено пользователем",
|
||||
"NOTIF_ERROR_TITLE": "Ошибка KoalaSync"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "KoalaSync",
|
||||
"version": "2.0.2",
|
||||
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
"tabs",
|
||||
"scripting",
|
||||
"alarms",
|
||||
"activeTab",
|
||||
"notifications"
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
"48": "icons/icon48.png",
|
||||
"96": "icons/icon96.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"https://sync.koalastuff.net/*"
|
||||
],
|
||||
"js": [
|
||||
"bridge.js"
|
||||
],
|
||||
"run_at": "document_start"
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
"48": "icons/icon48.png",
|
||||
"96": "icons/icon96.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "KoalaSync",
|
||||
"version": "1.1.4",
|
||||
"description": "Synchronize video playback across different tabs and users.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
"tabs",
|
||||
"scripting",
|
||||
"alarms",
|
||||
"activeTab",
|
||||
"notifications"
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon128.png",
|
||||
"48": "icons/icon128.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["https://koalasync.shik3i.net/*"],
|
||||
"js": ["bridge.js"],
|
||||
"run_at": "document_start"
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "icons/icon128.png",
|
||||
"48": "icons/icon128.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
}
|
||||
@@ -27,13 +27,49 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 16px 0;
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
text-align: center;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
h1 img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.popup-version {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s, color 0.2s;
|
||||
}
|
||||
.popup-version:hover {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
.popup-version svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
@@ -189,16 +225,110 @@
|
||||
}
|
||||
.invite-box input { flex: 1; font-size: 11px; }
|
||||
.invite-box button { width: 40px; padding: 0; }
|
||||
|
||||
/* Toast Notifications */
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
max-width: 280px;
|
||||
text-align: center;
|
||||
animation: toastSlideIn 0.3s ease-out, toastFadeOut 0.3s ease-in 2.7s forwards;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
.toast-success { background: var(--success); color: white; }
|
||||
.toast-error { background: var(--error); color: white; }
|
||||
.toast-info { background: var(--accent); color: white; }
|
||||
.toast-warning { background: #f59e0b; color: white; }
|
||||
|
||||
@keyframes toastSlideIn {
|
||||
from { opacity: 0; transform: translateY(-20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes toastFadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
@keyframes fadeSlideIn {
|
||||
from { opacity: 0; transform: translateX(5px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
.tab-active-animate {
|
||||
animation: fadeSlideIn 0.15s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Toggle Switch CSS */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
}
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: #334155;
|
||||
transition: .3s;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: #94a3b8;
|
||||
transition: .3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
input:checked + .slider {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(16px);
|
||||
background-color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>KoalaSync</h1>
|
||||
<div id="toast-container"></div>
|
||||
<div class="header-row">
|
||||
<h1 data-i18n="APP_TITLE"><img src="icons/icon128.png" alt="KoalaSync Logo">KoalaSync</h1>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" class="popup-version">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
<span id="popupVersion">v0.0.0</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="tab-room">Room</button>
|
||||
<button class="tab-btn" data-tab="tab-sync">Sync</button>
|
||||
<button class="tab-btn" data-tab="tab-settings">Settings</button>
|
||||
<button class="tab-btn" data-tab="tab-dev">Dev</button>
|
||||
<button class="tab-btn active" data-tab="tab-room" data-i18n="TAB_ROOM" data-i18n-title="TAB_ROOM_TOOLTIP" title="Room settings and connection">Room</button>
|
||||
<button class="tab-btn" data-tab="tab-sync" data-i18n="TAB_SYNC" data-i18n-title="TAB_SYNC_TOOLTIP" title="Video sync controls and remote actions">Sync</button>
|
||||
<button class="tab-btn" data-tab="tab-settings" data-i18n="TAB_SETTINGS" data-i18n-title="TAB_SETTINGS_TOOLTIP" title="Extension preferences">Settings</button>
|
||||
<button class="tab-btn" data-tab="tab-dev" data-i18n="TAB_STATUS" data-i18n-title="TAB_STATUS_TOOLTIP" title="Advanced Diagnostics & Logs">Status</button>
|
||||
</div>
|
||||
|
||||
<!-- Room Tab -->
|
||||
@@ -206,47 +336,52 @@
|
||||
|
||||
<!-- JOIN SECTION: Visible when not in a room -->
|
||||
<div id="section-join">
|
||||
<button id="createRoomBtn" class="primary" style="margin-bottom: 16px; padding: 14px; font-size: 15px; background: linear-gradient(135deg, #6366f1, #a855f7); box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);" data-i18n="BTN_CREATE_ROOM">+ 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;" data-i18n="MANUAL_CONNECT_HEADER">Manual Connect / Advanced</summary>
|
||||
<div style="margin-top: 12px;">
|
||||
<div class="form-group">
|
||||
<label>Server</label>
|
||||
<label title="Select which KoalaSync server to use" data-i18n="LABEL_SERVER">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;" data-i18n="BTN_SERVER_OFFICIAL" data-i18n-title="BTN_SERVER_OFFICIAL_TOOLTIP" title="Use the official reliable server">Official</button>
|
||||
<button id="serverCustom" class="tab-btn" style="flex:1; padding:6px; font-size:11px;" data-i18n="BTN_SERVER_CUSTOM" data-i18n-title="BTN_SERVER_CUSTOM_TOOLTIP" title="Connect to your own self-hosted server">Custom</button>
|
||||
</div>
|
||||
<input type="text" id="serverUrl" placeholder="wss://your-server:3000" style="display:none;">
|
||||
<input type="text" id="serverUrl" data-i18n-placeholder="PLACEHOLDER_SERVER_URL" 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" data-i18n="LABEL_ROOM_ID" data-i18n-title="LABEL_ROOM_ID_TOOLTIP">Room ID</label>
|
||||
<input type="text" id="roomId" data-i18n-placeholder="PLACEHOLDER_ROOM_ID" data-i18n-title="PLACEHOLDER_ROOM_ID_TOOLTIP" 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" data-i18n="LABEL_PASSWORD" data-i18n-title="LABEL_PASSWORD_TOOLTIP">Password (Optional)</label>
|
||||
<input type="password" id="password" data-i18n-placeholder="PLACEHOLDER_PASSWORD" data-i18n-title="PLACEHOLDER_PASSWORD_TOOLTIP" 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" data-i18n="BTN_JOIN_ROOM" data-i18n-title="BTN_JOIN_ROOM_TOOLTIP" 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" data-i18n="LABEL_PUBLIC_ROOMS" data-i18n-title="LABEL_PUBLIC_ROOMS_TOOLTIP">Public Rooms</label>
|
||||
<button id="refreshRooms" class="secondary" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;" data-i18n="BTN_REFRESH" data-i18n-title="BTN_REFRESH_TOOLTIP" 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 style="text-align:center; color: var(--text-muted); font-size: 11px; padding: 10px;" data-i18n="PUBLIC_ROOMS_REFRESHING">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>
|
||||
<div id="activeRoomId" style="font-weight: 700; color: var(--accent); font-size: 16px; letter-spacing: 1px;">NONE</div>
|
||||
<label style="margin-bottom: 0;" title="The room you are currently connected to" data-i18n="LABEL_ACTIVE_ROOM" data-i18n-title="LABEL_ACTIVE_ROOM_TOOLTIP">Active Room</label>
|
||||
<div id="activeRoomId" style="font-weight: 700; color: var(--accent); font-size: 16px; letter-spacing: 1px;" data-i18n="ACTIVE_ROOM_NONE">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 id="activeServer" style="font-size: 10px; color: var(--text-muted); text-align: right; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" data-i18n="ACTIVE_SERVER_OFFICIAL">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" data-i18n="LABEL_INVITE_LINK" data-i18n-title="LABEL_INVITE_LINK_TOOLTIP">Invite Link</label>
|
||||
<div class="invite-box">
|
||||
<input type="text" id="inviteLink" readonly>
|
||||
<button id="copyInvite" class="secondary">📋</button>
|
||||
@@ -254,100 +389,179 @@
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label>Peers in Room</label>
|
||||
<label title="Other users currently connected to this room" data-i18n="LABEL_PEERS_IN_ROOM" data-i18n-title="LABEL_PEERS_IN_ROOM_TOOLTIP">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 style="text-align:center; color: var(--text-muted); font-size: 12px;" data-i18n="NO_PEERS_CONNECTED">No peers connected</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="leaveBtn" class="secondary" style="color: var(--error); border-color: var(--error);">Leave Room</button>
|
||||
<button id="leaveBtn" class="secondary" style="color: var(--error); border-color: var(--error);" data-i18n="BTN_LEAVE_ROOM">Leave Room</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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" data-i18n="LABEL_SELECT_VIDEO" data-i18n-title="LABEL_SELECT_VIDEO_TOOLTIP">Select Video</label>
|
||||
<select id="targetTab">
|
||||
<option value="">-- Select a Tab --</option>
|
||||
<option value="" data-i18n="OPTION_SELECT_TAB">-- 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;" data-i18n="LABEL_REMOTE_CONTROL">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;" data-i18n="BTN_COPY_INVITE" data-i18n-title="BTN_COPY_INVITE_TOOLTIP">📋 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" data-i18n="BTN_PLAY" data-i18n-title="BTN_PLAY_TOOLTIP">▶ Play</button>
|
||||
<button id="pauseBtn" class="primary" style="flex:1; background: var(--error);" title="Send a Pause command to everyone" data-i18n="BTN_PAUSE" data-i18n-title="BTN_PAUSE_TOOLTIP">⏸ 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" data-i18n="BTN_SYNC" data-i18n-title="BTN_SYNC_TOOLTIP">⚡ 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" data-i18n-title="OPTION_JUMP_MODE_TOOLTIP">
|
||||
<option value="jump-to-others" data-i18n="OPTION_JUMP_TO_OTHERS">Jump to Others</option>
|
||||
<option value="jump-to-me" data-i18n="OPTION_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" data-i18n="LABEL_LAST_ACTIVITY" data-i18n-title="LABEL_LAST_ACTIVITY_TOOLTIP">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;" data-i18n="NO_RECENT_COMMANDS">No recent commands</div>
|
||||
</div>
|
||||
|
||||
<!-- Episode Auto-Sync Lobby Status -->
|
||||
<div id="episodeLobbyCard" class="info-card" style="display:none; margin-bottom: 15px; border-left: 4px solid var(--star); animation: fadeIn 0.3s ease-out;">
|
||||
<div style="display:flex; align-items:center; gap: 6px; margin-bottom: 6px;">
|
||||
<span style="font-size: 16px;">⏳</span>
|
||||
<span style="font-weight: 700; color: var(--star); font-size: 12px;" data-i18n="LOBBY_HEADER">EPISODE LOBBY</span>
|
||||
</div>
|
||||
<div id="lobbyTitle" style="font-size: 11px; color: var(--text); margin-bottom: 6px; font-weight: 600;"></div>
|
||||
<div id="lobbyPeerStatus" style="font-size: 10px; color: var(--text-muted); margin-bottom: 8px;"></div>
|
||||
<button id="cancelLobbyBtn" class="secondary" style="margin-top: 4px; padding: 6px 10px; font-size: 11px; width: auto; display: block;" title="Cancel lobby and play anyway" data-i18n="BTN_SKIP_PLAY" data-i18n-title="BTN_SKIP_PLAY_TOOLTIP">Skip & Play anyway</button>
|
||||
</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;" data-i18n="LOBBY_CONNECT_FIRST">Connect to a room first</h3>
|
||||
<p style="color: var(--text-muted); font-size: 12px; margin-bottom: 20px;" data-i18n="LOBBY_CONNECT_FIRST_DESC">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" data-i18n="BTN_CREATE_ROOM_ALT" data-i18n-title="BTN_CREATE_ROOM_ALT_TOOLTIP">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." data-i18n="LABEL_USERNAME" data-i18n-title="LABEL_USERNAME_TOOLTIP">Your Username</label>
|
||||
<input type="text" id="username" data-i18n-placeholder="PLACEHOLDER_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" data-i18n="LABEL_HIDE_CLUTTER" data-i18n-title="LABEL_HIDE_CLUTTER_TOOLTIP">Hide Clutter Tabs</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="filterNoise" checked>
|
||||
<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>
|
||||
|
||||
<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="Pauses automatically and waits for all peers when an episode changes, then sync-starts together." data-i18n="LABEL_AUTO_SYNC_NEXT" data-i18n-title="LABEL_AUTO_SYNC_NEXT_TOOLTIP">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." data-i18n="LABEL_AUTO_COPY_INVITE" data-i18n-title="LABEL_AUTO_COPY_INVITE_TOOLTIP">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." data-i18n="LABEL_NOTIFICATIONS" data-i18n-title="LABEL_NOTIFICATIONS_TOOLTIP">Browser Notifications</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="browserNotifications">
|
||||
<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;" data-i18n="LABEL_LANGUAGE" data-i18n-title="LABEL_LANGUAGE_TOOLTIP" title="Choose your preferred extension language">App Language</label>
|
||||
<select id="langSelector" style="width: 150px; background: var(--bg); border: 1px solid #334155; color: white; padding: 6px 10px; border-radius: 8px; font-size: 13px; cursor: pointer; outline: none; font-family: inherit;">
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="pt-BR">Português (Brasil)</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
</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>
|
||||
<p style="font-size: 9px; color: var(--text-muted); margin-top: 5px; text-align: center;">Use this if you see "Duplicate Identity" errors.</p>
|
||||
<label title="Tools for fixing connection issues" data-i18n="LABEL_TROUBLESHOOTING" data-i18n-title="LABEL_TROUBLESHOOTING_TOOLTIP">Troubleshooting</label>
|
||||
<button id="regenId" class="secondary" style="width: 100%; font-size: 11px;" title="Regenerate your internal ID and reconnect" data-i18n="BTN_REGEN_ID" data-i18n-title="BTN_REGEN_ID_TOOLTIP">Regenerate Peer ID</button>
|
||||
<p style="font-size: 9px; color: var(--text-muted); margin-top: 5px; text-align: center;" data-i18n="REGEN_ID_DESC">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" data-i18n="LABEL_CONN_STATUS" data-i18n-title="LABEL_CONN_STATUS_TOOLTIP">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>
|
||||
<span id="connText" style="flex:1;" data-i18n="CONN_STATUS_DISCONNECTED">Disconnected</span>
|
||||
<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" data-i18n="BTN_RETRY" data-i18n-title="BTN_RETRY_TOOLTIP">RETRY</button>
|
||||
<button id="copyLogs" class="btn secondary" style="width: auto; padding: 4px 10px; font-size: 11px;" title="Copy logs to clipboard for sharing" data-i18n="BTN_COPY_LOGS" data-i18n-title="BTN_COPY_LOGS_TOOLTIP">Copy Logs</button>
|
||||
</div>
|
||||
|
||||
<label>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;">
|
||||
<label title="Technical details about the currently selected video element" data-i18n="LABEL_VIDEO_DEBUG" data-i18n-title="LABEL_VIDEO_DEBUG_TOOLTIP">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;" data-i18n="VIDEO_DEBUG_EMPTY">
|
||||
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" data-i18n="LABEL_HISTORY" data-i18n-title="LABEL_HISTORY_TOOLTIP">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 style="text-align:center; color: var(--text-muted); font-size: 11px;" data-i18n="HISTORY_EMPTY">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" data-i18n="LABEL_LOGS" data-i18n-title="LABEL_LOGS_TOOLTIP">Logs (Last 50)</label>
|
||||
<button id="clearLogs" style="background:transparent; border:none; color:var(--accent); font-size:10px; cursor:pointer;" title="Clear log output" data-i18n="BTN_CLEAR" data-i18n-title="BTN_CLEAR_TOOLTIP">CLEAR</button>
|
||||
</div>
|
||||
<div id="logList"></div>
|
||||
|
||||
<div style="margin-top: 20px; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 10px;">
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="color: var(--text-muted); text-decoration: none; font-size: 10px; opacity: 0.6; display: block;">GitHub Repository</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="color: var(--text-muted); text-decoration: none; font-size: 10px; opacity: 0.6; display: block;" data-i18n="LABEL_GITHUB">GitHub Repository</a>
|
||||
<div id="appVersion" style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;">v0.0.0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="popup.js" type="module"></script>
|
||||
|
||||
<!-- 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;">👋</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" data-i18n="BTN_ONBOARDING_SKIP" data-i18n-title="BTN_ONBOARDING_SKIP_TOOLTIP">Skip</button>
|
||||
<button id="onboarding-next" class="primary" style="width:auto; padding:8px 16px;" title="Go to next step" data-i18n="BTN_ONBOARDING_NEXT" data-i18n-title="BTN_ONBOARDING_NEXT_TOOLTIP">Next</button>
|
||||
</div>
|
||||
<div id="onboarding-dots" style="margin-top:12px; display:flex; gap:6px; justify-content:center;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# ⚠️ READ BEFORE EDITING
|
||||
|
||||
This directory is a **MIRROR** of the root `/shared` folder.
|
||||
|
||||
**DO NOT edit these files directly.** Any changes made here will be overwritten the next time the synchronization script is run.
|
||||
|
||||
### Proper Workflow:
|
||||
1. **Edit** the source files in the root `[repo_root]/shared/` directory.
|
||||
2. **Run** the synchronization script:
|
||||
- **Windows**: `[repo_root]\scripts\sync-constants.bat`
|
||||
- **Linux/macOS**: `[repo_root]/scripts/sync-constants.sh`
|
||||
3. **Verify** that the changes have propagated to this folder.
|
||||
|
||||
Failure to follow this protocol will result in out-of-sync components and broken protocol logic.
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "2.0.2",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:extension": "node scripts/build-extension.js",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^10.4.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"sharp": "^0.34.5",
|
||||
"svgo": "^4.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# Development Scripts
|
||||
|
||||
This directory contains utility scripts for the KoalaSync development workflow.
|
||||
|
||||
## build-extension.js
|
||||
|
||||
The primary build tool for KoalaSync. This Node.js script automates two critical tasks:
|
||||
|
||||
1. **Protocol Synchronization**: Copies the "Single Source of Truth" constants (`shared/constants.js`) and the domain blacklist (`shared/blacklist.js`) from the root `/shared` directory into the `extension/shared/` directory.
|
||||
2. **Content Script Injection**: Injects protocol constants directly into `content.js` using marker-based replacement. This is necessary because `content.js` executes synchronously and cannot use ES module imports.
|
||||
3. **Artifact Generation**: Compiles the extension into browser-specific bundles for Chrome and Firefox, located in the `dist/` directory.
|
||||
|
||||
### Usage
|
||||
|
||||
From the **repository root**, run:
|
||||
|
||||
```bash
|
||||
node scripts/build-extension.js
|
||||
```
|
||||
|
||||
### Why this script exists
|
||||
KoalaSync uses **Vanilla JS** in the extension to maintain zero runtime dependencies and maximum privacy. Since we don't use a bundler (like Webpack or Vite) inside the extension, this script serves as our lightweight "pre-build" step to ensure that the protocol constants remain synchronized between the extension and the relay server.
|
||||
|
||||
### Content Injection Markers
|
||||
|
||||
The build script uses marker comments in `content.js` to locate and replace constant blocks:
|
||||
|
||||
| Marker Pair | Injected Value | Source |
|
||||
|:---|:---|:---|
|
||||
| `SHARED_EVENTS_INJECT_START` / `END` | The full `EVENTS` object | `shared/constants.js` |
|
||||
| `SHARED_HEARTBEAT_INJECT_START` / `END` | `HEARTBEAT_INTERVAL` value | `shared/constants.js` |
|
||||
|
||||
> **⚠️ Do NOT remove or modify these marker comments in `content.js`.** They are required for the build script to function. If the markers are missing, the build will fail with a clear error message.
|
||||
@@ -0,0 +1,180 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const archiver = require('archiver');
|
||||
|
||||
const rootDir = path.join(__dirname, '..');
|
||||
const extDir = path.join(rootDir, 'extension');
|
||||
const distDir = path.join(rootDir, 'dist');
|
||||
const baseManifestPath = path.join(extDir, 'manifest.base.json');
|
||||
|
||||
// Ensure dist directory exists
|
||||
if (fs.existsSync(distDir)) {
|
||||
fs.rmSync(distDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
|
||||
// Sync shared constants from root /shared to /extension/shared
|
||||
console.log('Syncing protocol constants...');
|
||||
const masterSharedDir = path.join(rootDir, 'shared');
|
||||
const extSharedDir = path.join(extDir, 'shared');
|
||||
|
||||
if (!fs.existsSync(extSharedDir)) {
|
||||
fs.mkdirSync(extSharedDir, { recursive: true });
|
||||
}
|
||||
|
||||
const sharedFiles = ['constants.js', 'blacklist.js', 'names.js', 'README.md'];
|
||||
for (const file of sharedFiles) {
|
||||
const src = path.join(masterSharedDir, file);
|
||||
const dest = path.join(extSharedDir, file);
|
||||
if (!fs.existsSync(src)) {
|
||||
throw new Error(`CRITICAL: Source shared file missing: ${src}. Aborting build to prevent broken artifacts.`);
|
||||
}
|
||||
fs.copyFileSync(src, dest);
|
||||
}
|
||||
console.log('✓ constants.js, blacklist.js, names.js, and README.md synced to extension/shared/');
|
||||
|
||||
// Read the base manifest
|
||||
const baseManifest = JSON.parse(fs.readFileSync(baseManifestPath, 'utf8'));
|
||||
|
||||
// Helper to copy files, ignoring manifest.json and manifest.base.json
|
||||
// Also injects shared constants into content.js
|
||||
function copyExtensionFiles(targetDir) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
// Read master constants for injection
|
||||
const masterConstantsPath = path.join(rootDir, 'shared', 'constants.js');
|
||||
const constantsContent = fs.readFileSync(masterConstantsPath, 'utf8');
|
||||
|
||||
// Robust Extraction using flexible regex
|
||||
const eventsMatch = constantsContent.match(/export const EVENTS\s*=\s*({[\s\S]+?});/);
|
||||
const heartbeatMatch = constantsContent.match(/export const HEARTBEAT_INTERVAL\s*=\s*(\d+);/);
|
||||
|
||||
if (!eventsMatch) {
|
||||
throw new Error('CRITICAL: Could not find EVENTS object in shared/constants.js');
|
||||
}
|
||||
if (!heartbeatMatch) {
|
||||
throw new Error('CRITICAL: Could not find HEARTBEAT_INTERVAL in shared/constants.js');
|
||||
}
|
||||
|
||||
const eventsObject = eventsMatch[1];
|
||||
const heartbeatVal = heartbeatMatch[1];
|
||||
|
||||
const items = fs.readdirSync(extDir);
|
||||
for (const item of items) {
|
||||
if (item === 'manifest.json' || item === 'manifest.base.json') continue;
|
||||
|
||||
const srcPath = path.join(extDir, item);
|
||||
const destPath = path.join(targetDir, item);
|
||||
|
||||
if (fs.lstatSync(srcPath).isDirectory()) {
|
||||
fs.cpSync(srcPath, destPath, { recursive: true });
|
||||
} else {
|
||||
if (item === 'content.js') {
|
||||
// Perform injection
|
||||
let content = fs.readFileSync(srcPath, 'utf8');
|
||||
|
||||
// 1. Inject Events
|
||||
const eStart = '// --- SHARED_EVENTS_INJECT_START ---';
|
||||
const eEnd = '// --- SHARED_EVENTS_INJECT_END ---';
|
||||
const ePattern = new RegExp(`${eStart}[\\s\\S]+?${eEnd}`);
|
||||
const eRep = `${eStart}\n // This block is automatically updated by /scripts/build-extension.js\n const EVENTS = ${eventsObject};\n ${eEnd}`;
|
||||
|
||||
if (ePattern.test(content)) {
|
||||
content = content.replace(ePattern, eRep);
|
||||
} else {
|
||||
console.warn('⚠️ WARNING: Event markers not found in content.js');
|
||||
}
|
||||
|
||||
// 2. Inject Heartbeat
|
||||
const hStart = '// --- SHARED_HEARTBEAT_INJECT_START ---';
|
||||
const hEnd = '// --- SHARED_HEARTBEAT_INJECT_END ---';
|
||||
const hPattern = new RegExp(`${hStart}[\\s\\S]+?${hEnd}`);
|
||||
const hRep = `${hStart}\n const HEARTBEAT_INTERVAL_VAL = ${heartbeatVal};\n ${hEnd}`;
|
||||
|
||||
if (hPattern.test(content)) {
|
||||
content = content.replace(hPattern, hRep);
|
||||
} else {
|
||||
console.warn('⚠️ WARNING: Heartbeat markers not found in content.js');
|
||||
}
|
||||
|
||||
fs.writeFileSync(destPath, content);
|
||||
console.log('✓ Injected shared constants into content.js');
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to zip a directory
|
||||
function zipDirectory(sourceDir, outPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
const stream = fs.createWriteStream(outPath);
|
||||
|
||||
archive
|
||||
.directory(sourceDir, false)
|
||||
.on('error', err => reject(err))
|
||||
.pipe(stream);
|
||||
|
||||
stream.on('close', () => resolve());
|
||||
archive.finalize();
|
||||
});
|
||||
}
|
||||
|
||||
async function buildBrowser(browserName, manifestModifier) {
|
||||
console.log(`Building for ${browserName}...`);
|
||||
const browserDistDir = path.join(distDir, browserName);
|
||||
|
||||
// 1. Copy files
|
||||
copyExtensionFiles(browserDistDir);
|
||||
|
||||
// 2. Modify and write manifest
|
||||
const browserManifest = manifestModifier(JSON.parse(JSON.stringify(baseManifest)));
|
||||
fs.writeFileSync(
|
||||
path.join(browserDistDir, 'manifest.json'),
|
||||
JSON.stringify(browserManifest, null, 2)
|
||||
);
|
||||
|
||||
// 3. Zip it
|
||||
const zipPath = path.join(distDir, `koalasync-${browserName}.zip`);
|
||||
await zipDirectory(browserDistDir, zipPath);
|
||||
console.log(`Successfully built and zipped ${browserName} -> ${zipPath}`);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
// Build Chrome
|
||||
await buildBrowser('chrome', (manifest) => {
|
||||
manifest.background = {
|
||||
service_worker: "background.js",
|
||||
type: "module"
|
||||
};
|
||||
return manifest;
|
||||
});
|
||||
|
||||
// Build Firefox
|
||||
await buildBrowser('firefox', (manifest) => {
|
||||
manifest.background = {
|
||||
scripts: ["background.js"],
|
||||
type: "module"
|
||||
};
|
||||
manifest.browser_specific_settings = {
|
||||
gecko: {
|
||||
id: "koalasync@koalastuff.net",
|
||||
data_collection_permissions: {
|
||||
required: ["none"]
|
||||
}
|
||||
}
|
||||
};
|
||||
return manifest;
|
||||
});
|
||||
|
||||
console.log('Build complete!');
|
||||
} catch (error) {
|
||||
console.error('Build failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -1,12 +0,0 @@
|
||||
@echo off
|
||||
REM KoalaSync - Protocol Synchronization Script (Windows)
|
||||
REM
|
||||
REM This script copies the master constants.js file from the shared directory
|
||||
REM to the extension directory. Since Chrome Extensions cannot load files
|
||||
REM outside their root, this manual sync is required after any changes to
|
||||
REM the shared protocol.
|
||||
|
||||
if not exist extension\shared mkdir extension\shared
|
||||
copy /y shared\constants.js extension\shared\constants.js
|
||||
copy /y shared\blacklist.js extension\shared\blacklist.js
|
||||
echo ✓ constants.js and blacklist.js synced to extension\shared\
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/bin/sh
|
||||
# KoalaSync - Protocol Synchronization Script (Linux/macOS)
|
||||
#
|
||||
# This script copies the master constants.js file from the shared directory
|
||||
# to the extension directory. Since Chrome Extensions cannot load files
|
||||
# outside their root, this manual sync is required after any changes to
|
||||
# the shared protocol.
|
||||
|
||||
mkdir -p extension/shared
|
||||
cp shared/constants.js extension/shared/constants.js
|
||||
cp shared/blacklist.js extension/shared/blacklist.js
|
||||
echo "✓ constants.js and blacklist.js synced to extension/shared/"
|
||||
@@ -0,0 +1,64 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const contentPath = path.join(__dirname, '..', 'extension', 'content.js');
|
||||
const source = fs.readFileSync(contentPath, 'utf8');
|
||||
|
||||
function extractFunction(name, text) {
|
||||
const start = text.indexOf(`function ${name}`);
|
||||
assert.notStrictEqual(start, -1, `${name} not found`);
|
||||
|
||||
const bodyStart = text.indexOf('{', start);
|
||||
let depth = 0;
|
||||
for (let i = bodyStart; i < text.length; i++) {
|
||||
if (text[i] === '{') depth++;
|
||||
if (text[i] === '}') depth--;
|
||||
if (depth === 0) return text.slice(start, i + 1);
|
||||
}
|
||||
throw new Error(`${name} body did not terminate`);
|
||||
}
|
||||
|
||||
function makeVideo(name, width, height, options = {}) {
|
||||
return {
|
||||
name,
|
||||
tagName: 'VIDEO',
|
||||
videoWidth: width,
|
||||
videoHeight: height,
|
||||
offsetWidth: width,
|
||||
offsetHeight: height,
|
||||
muted: options.muted ?? true,
|
||||
duration: options.duration ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
const lightPreview = makeVideo('light-preview', 160, 90, { muted: false, duration: 30 });
|
||||
const shadowPlayer = makeVideo('shadow-player', 1920, 1080, { muted: false, duration: 3600 });
|
||||
|
||||
const shadowRoot = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'video') return [shadowPlayer];
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const shadowHost = { shadowRoot };
|
||||
|
||||
const fakeDocument = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'video') return [lightPreview];
|
||||
return [shadowHost];
|
||||
}
|
||||
};
|
||||
|
||||
const fnSource = extractFunction('findVideo', source);
|
||||
const findVideo = Function('document', `${fnSource}; return findVideo;`)(fakeDocument);
|
||||
|
||||
const selected = findVideo(fakeDocument);
|
||||
assert.strictEqual(
|
||||
selected,
|
||||
shadowPlayer,
|
||||
'findVideo should score Shadow DOM videos together with light DOM videos'
|
||||
);
|
||||
|
||||
console.log('content video finder tests passed');
|
||||
@@ -0,0 +1,55 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const localesDir = path.join(__dirname, '..', 'extension', 'locales');
|
||||
const enPath = path.join(localesDir, 'en.json');
|
||||
|
||||
if (!fs.existsSync(enPath)) {
|
||||
console.error('CRITICAL: en.json is missing!');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const enDict = JSON.parse(fs.readFileSync(enPath, 'utf8'));
|
||||
const enKeys = Object.keys(enDict);
|
||||
|
||||
const localeFiles = fs.readdirSync(localesDir).filter(file => file.endsWith('.json') && file !== 'en.json');
|
||||
|
||||
let hasError = false;
|
||||
|
||||
console.log(`Auditing i18n locales using ${enKeys.length} baseline keys from en.json...\n`);
|
||||
|
||||
for (const file of localeFiles) {
|
||||
const filePath = path.join(localesDir, file);
|
||||
try {
|
||||
const dict = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
const keys = Object.keys(dict);
|
||||
|
||||
const missingKeys = enKeys.filter(k => !keys.includes(k));
|
||||
const extraKeys = keys.filter(k => !enKeys.includes(k));
|
||||
|
||||
if (missingKeys.length > 0 || extraKeys.length > 0) {
|
||||
hasError = true;
|
||||
console.error(`❌ ${file} has inconsistencies:`);
|
||||
if (missingKeys.length > 0) {
|
||||
console.error(` Missing keys (${missingKeys.length}):`, missingKeys);
|
||||
}
|
||||
if (extraKeys.length > 0) {
|
||||
console.error(` Extra keys (${extraKeys.length}):`, extraKeys);
|
||||
}
|
||||
} else {
|
||||
console.log(`✓ ${file} is fully consistent (matches all keys).`);
|
||||
}
|
||||
} catch (err) {
|
||||
hasError = true;
|
||||
console.error(`❌ Failed to parse ${file}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (hasError) {
|
||||
console.error('❌ Locale consistency check failed! Please fix the errors listed above.');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('🎉 All locale files are perfectly synchronized and consistent!');
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { cwd } from 'node:process';
|
||||
|
||||
const popupPath = path.join(cwd(), 'extension', 'popup.js');
|
||||
const source = fs.readFileSync(popupPath, 'utf8');
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/const ROOM_LIST_REFRESH_COOLDOWN_MS\s*=\s*11000;/,
|
||||
'popup should define an 11 second room-list refresh cooldown'
|
||||
);
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/elements\.refreshRooms\.disabled\s*=\s*true;/,
|
||||
'refresh button should be disabled while cooldown is active'
|
||||
);
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/setTimeout\(\(\)\s*=>\s*{\s*elements\.refreshRooms\.disabled\s*=\s*false;/s,
|
||||
'refresh button should be re-enabled after the cooldown'
|
||||
);
|
||||
|
||||
console.log('popup refresh cooldown tests passed');
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildHealthPayload,
|
||||
checkCooldown,
|
||||
isAdminMetricsAuthorized
|
||||
} from '../server/ops.js';
|
||||
|
||||
const missingAuth = isAdminMetricsAuthorized(undefined, 'secret-token');
|
||||
assert.equal(missingAuth, false, 'missing Authorization header must not authorize metrics');
|
||||
|
||||
const wrongAuth = isAdminMetricsAuthorized('Bearer wrong-token', 'secret-token');
|
||||
assert.equal(wrongAuth, false, 'wrong bearer token must not authorize metrics');
|
||||
|
||||
const correctAuth = isAdminMetricsAuthorized('Bearer secret-token', 'secret-token');
|
||||
assert.equal(correctAuth, true, 'correct bearer token should authorize metrics');
|
||||
|
||||
const disabledAuth = isAdminMetricsAuthorized('Bearer secret-token', '');
|
||||
assert.equal(disabledAuth, false, 'empty admin token disables admin metrics');
|
||||
|
||||
const cooldowns = new Map();
|
||||
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 100_000), true, 'first cooldown check passes');
|
||||
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 105_000), false, 'second cooldown check inside window fails');
|
||||
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 110_000), true, 'cooldown check after window passes');
|
||||
|
||||
const roomA = { peers: new Set(['a', 'b']), activeLobby: null };
|
||||
const roomB = { peers: new Set(['c', 'd', 'e']), activeLobby: { expectedTitle: 'Episode 2' } };
|
||||
const rooms = new Map([['room-a', roomA], ['room-b', roomB]]);
|
||||
|
||||
const basicHealth = buildHealthPayload({
|
||||
rooms,
|
||||
connections: 5,
|
||||
includeMetrics: false,
|
||||
now: 1234,
|
||||
uptime: 99,
|
||||
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
|
||||
rateLimitSizes: { connections: 1, events: 2, health: 3, authFailures: 4, roomList: 5 }
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
Object.keys(basicHealth).sort(),
|
||||
['connections', 'rooms', 'status', 'timestamp', 'uptime'].sort(),
|
||||
'basic health should not expose extended metrics'
|
||||
);
|
||||
|
||||
const adminHealth = buildHealthPayload({
|
||||
rooms,
|
||||
connections: 5,
|
||||
includeMetrics: true,
|
||||
now: 1234,
|
||||
uptime: 99,
|
||||
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
|
||||
rateLimitSizes: { connections: 1, events: 2, health: 3, authFailures: 4, roomList: 5 }
|
||||
});
|
||||
|
||||
assert.equal(adminHealth.peers, 5, 'admin metrics should include aggregate peer count');
|
||||
assert.equal(adminHealth.roomsWithLobby, 1, 'admin metrics should count active lobbies');
|
||||
assert.equal(adminHealth.avgPeersPerRoom, 2.5, 'admin metrics should include average room size');
|
||||
assert.equal(adminHealth.maxPeersInRoom, 3, 'admin metrics should include max room size');
|
||||
assert.deepEqual(adminHealth.memory, { rss: 10, heapUsed: 5, heapTotal: 8 }, 'admin metrics should expose process memory');
|
||||
assert.deepEqual(
|
||||
adminHealth.rateLimitEntries,
|
||||
{ connections: 1, events: 2, health: 3, authFailures: 4, roomList: 5 },
|
||||
'admin metrics should expose aggregate rate-limit map sizes'
|
||||
);
|
||||
|
||||
console.log('server ops tests passed');
|
||||
@@ -1,4 +1,6 @@
|
||||
PORT=3000
|
||||
MIN_VERSION=1.0.0
|
||||
MAX_ROOMS=1000
|
||||
MAX_PEERS_PER_ROOM=50
|
||||
MAX_PEERS_PER_ROOM=25
|
||||
# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer <token>
|
||||
ADMIN_METRICS_TOKEN=
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# KoalaSync Relay Server
|
||||
|
||||
A high-performance Node.js relay server for synchronized video playback.
|
||||
A Node.js relay server for synchronized video playback.
|
||||
|
||||
## Key Features
|
||||
- **Zero-Persistence**: No database. All state is held in RAM.
|
||||
- **Privacy First**: No tracking, no logging of user data.
|
||||
- **WebSocket Only**: High performance with minimal overhead.
|
||||
- **Privacy First**: No tracking, no persistent logging of user data.
|
||||
- **WebSocket Only**: Minimal overhead with efficient transport.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -13,17 +13,28 @@ A high-performance Node.js relay server for synchronized video playback.
|
||||
Copy `.env.example` to `.env` and configure your settings.
|
||||
```bash
|
||||
PORT=3000
|
||||
MAX_ROOMS=100
|
||||
MAX_PEERS_PER_ROOM=20
|
||||
MAX_ROOMS=1000
|
||||
MAX_PEERS_PER_ROOM=25
|
||||
MIN_VERSION=1.0.0
|
||||
# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer <token>
|
||||
ADMIN_METRICS_TOKEN=
|
||||
```
|
||||
|
||||
### Health & Metrics
|
||||
`GET /` and `GET /health` are IP-rate-limited. By default `/health` returns only basic service status, uptime, room count, connection count, and a timestamp.
|
||||
|
||||
If `ADMIN_METRICS_TOKEN` is set, requests with `Authorization: Bearer <token>` receive additional aggregate metrics such as total peers, average peers per room, max room size, active lobby count, rate-limit map sizes, and process memory usage. The metrics response does not include room IDs, peer IDs, usernames, IP addresses, media titles, or other user-level data.
|
||||
|
||||
### Docker (Recommended)
|
||||
The server is designed to run in a Docker container.
|
||||
The server is available as a pre-built image on GHCR.
|
||||
```bash
|
||||
# Build from the repository root
|
||||
# Pull from GHCR
|
||||
docker pull ghcr.io/shik3i/koalasync:latest
|
||||
|
||||
# Or build from the repository root
|
||||
docker build -t koala-sync-server -f server/Dockerfile .
|
||||
```
|
||||
See [Docker network compose](../docker-compose.caddy.example.yml) or [Static IP compose](../docker-compose.ip.example.yml) in the root directory for ready-to-use Docker Compose files.
|
||||
|
||||
### Manual Setup
|
||||
```bash
|
||||
@@ -34,5 +45,7 @@ npm start
|
||||
|
||||
## Security
|
||||
- **Rate Limiting**: IP-based connection limits and socket-based event limits.
|
||||
- **Token Handshake**: Requires a valid token defined in `shared/constants.js`.
|
||||
- **Room Discovery Throttle**: Room-list refreshes are rate-limited server-side to one request every 10 seconds per socket.
|
||||
- **Token Handshake**: Requires a valid token defined in the root `shared/constants.js`.
|
||||
- **Single Source of Truth**: The server imports constants directly from the root `shared/` directory.
|
||||
- **In-Memory**: Rooms are automatically pruned after 2 hours of inactivity.
|
||||
|
||||
@@ -1,32 +1,75 @@
|
||||
import express from 'express';
|
||||
import { createServer } from 'http';
|
||||
import { Server } from 'socket.io';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import dotenv from 'dotenv';
|
||||
import { EVENTS, OFFICIAL_SERVER_TOKEN } from '../shared/constants.js';
|
||||
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION } from '../shared/constants.js';
|
||||
import { buildHealthPayload, checkCooldown, isAdminMetricsAuthorized } from './ops.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
function hashPassword(password) {
|
||||
if (!password) return null;
|
||||
const salt = process.env.SERVER_SALT || 'koalasync_salt_3i';
|
||||
return crypto.createHmac('sha256', salt).update(password).digest('hex');
|
||||
}
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const MAX_ROOMS = parseInt(process.env.MAX_ROOMS) || 1000;
|
||||
const MAX_PEERS_PER_ROOM = parseInt(process.env.MAX_PEERS_PER_ROOM) || 50;
|
||||
const MAX_PEERS_PER_ROOM = parseInt(process.env.MAX_PEERS_PER_ROOM) || 25;
|
||||
const MIN_VERSION = process.env.MIN_VERSION || '1.0.0';
|
||||
const ADMIN_METRICS_TOKEN = process.env.ADMIN_METRICS_TOKEN || '';
|
||||
const ROOM_LIST_COOLDOWN_MS = 10000;
|
||||
|
||||
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' });
|
||||
}
|
||||
const includeMetrics = isAdminMetricsAuthorized(req.get('authorization'), ADMIN_METRICS_TOKEN);
|
||||
res.json(buildHealthPayload({
|
||||
rooms,
|
||||
connections: io.engine?.clientsCount ?? 0,
|
||||
includeMetrics,
|
||||
uptime: process.uptime(),
|
||||
rateLimitSizes: {
|
||||
connections: connectionCounts.size,
|
||||
events: eventCounts.size,
|
||||
health: healthCounts.size,
|
||||
authFailures: failedAuthAttempts.size,
|
||||
roomList: roomListCooldowns.size
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
const httpServer = createServer(app);
|
||||
|
||||
// Socket.IO setup with security constraints
|
||||
const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: ["https://koalasync.shik3i.net"],
|
||||
origin: (origin, callback) => {
|
||||
if (!origin || origin === 'https://sync.koalastuff.net' || origin.startsWith('chrome-extension://') || 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 +80,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 +110,26 @@ function checkAuthRate(ip, roomId) {
|
||||
}
|
||||
|
||||
function recordAuthFailure(ip, roomId) {
|
||||
if (failedAuthAttempts.size > 50000) {
|
||||
const now = Date.now();
|
||||
// 1. Clear expired entries (> 15 mins)
|
||||
for (const [key, record] of failedAuthAttempts.entries()) {
|
||||
if (now - record.lastAttempt > 15 * 60 * 1000) {
|
||||
failedAuthAttempts.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If still over 50k, perform LRU-style eviction on the oldest 10,000 entries
|
||||
if (failedAuthAttempts.size > 50000) {
|
||||
log('SECURITY', 'failedAuthAttempts size exceeded 50000. Performing LRU-style eviction.');
|
||||
const sortedEntries = Array.from(failedAuthAttempts.entries())
|
||||
.sort((a, b) => a[1].lastAttempt - b[1].lastAttempt);
|
||||
|
||||
for (let i = 0; i < 10000 && i < sortedEntries.length; i++) {
|
||||
failedAuthAttempts.delete(sortedEntries[i][0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
const key = `${ip}:${roomId}`;
|
||||
const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 };
|
||||
record.count++;
|
||||
@@ -73,19 +137,21 @@ 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 }
|
||||
const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
|
||||
|
||||
// Clean up connection counts to prevent memory leak
|
||||
// Clean up connection counts and event counts to prevent memory leak
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of connectionCounts.entries()) {
|
||||
@@ -93,6 +159,21 @@ setInterval(() => {
|
||||
connectionCounts.delete(ip);
|
||||
}
|
||||
}
|
||||
for (const [socketId, entry] of eventCounts.entries()) {
|
||||
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
|
||||
eventCounts.delete(socketId);
|
||||
}
|
||||
}
|
||||
for (const [ip, entry] of healthCounts.entries()) {
|
||||
if (now > entry.resetTime) {
|
||||
healthCounts.delete(ip);
|
||||
}
|
||||
}
|
||||
for (const [socketId] of roomListCooldowns.entries()) {
|
||||
if (!io.sockets.sockets.has(socketId)) {
|
||||
roomListCooldowns.delete(socketId);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
function checkConnectionRate(ip) {
|
||||
@@ -113,6 +194,15 @@ function checkEventRate(socketId) {
|
||||
return entry.count <= 30;
|
||||
}
|
||||
|
||||
function checkHealthRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
|
||||
entry.count++;
|
||||
healthCounts.set(ip, entry);
|
||||
return entry.count <= 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central peer teardown. Removes a socket from all room state and notifies
|
||||
* remaining peers. Call this from every disconnect/leave/reaper/dedupe path.
|
||||
@@ -120,12 +210,8 @@ function checkEventRate(socketId) {
|
||||
* @param {string} socketId - The socket.id being removed.
|
||||
* @param {string} roomId - The room it belongs to.
|
||||
* @param {string} reason - Log label ('disconnect', 'leave', 'reaper', 'dedupe', 'room-switch').
|
||||
* @param {boolean} [emitLeave=true] - Set false when the socket.io room leave
|
||||
* is handled by the caller (e.g. reaper calls
|
||||
* socket.leave() before us, or dedupe calls
|
||||
* oldSocket.leave() before disconnecting).
|
||||
*/
|
||||
function removePeerFromRoom(socketId, roomId, reason, emitLeave = true) {
|
||||
function removePeerFromRoom(socketId, roomId, reason) {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return;
|
||||
|
||||
@@ -141,13 +227,25 @@ 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);
|
||||
}
|
||||
|
||||
// 3. Notify remaining peers (use io.to so the removed socket itself
|
||||
// doesn't receive it — it has already left or is disconnecting)
|
||||
io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
|
||||
const isPeerStillConnected = Array.from(room.peerData.values()).some(data => data.peerId === peerId);
|
||||
if (!isPeerStillConnected) {
|
||||
io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
|
||||
}
|
||||
|
||||
// 3.5. Clean up active lobby if a peer leaves
|
||||
if (room.activeLobby) {
|
||||
room.activeLobby.readyPeers = room.activeLobby.readyPeers.filter(id => id !== peerId);
|
||||
if (room.activeLobby.readyPeers.length <= 1 || room.activeLobby.initiatorPeerId === peerId) {
|
||||
room.activeLobby = null; // Dissolve lobby
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Delete empty room
|
||||
if (room.peers.size === 0) {
|
||||
@@ -159,7 +257,10 @@ function removePeerFromRoom(socketId, roomId, reason, emitLeave = true) {
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const clientIp = socket.handshake.address;
|
||||
// Get real client IP behind proxy/CDN
|
||||
const forwardedFor = socket.handshake.headers['x-forwarded-for'];
|
||||
const clientIp = forwardedFor ? forwardedFor.split(',')[0].trim() : socket.handshake.address;
|
||||
socket._clientIp = clientIp;
|
||||
|
||||
// 1. Connection Rate Limit
|
||||
if (!checkConnectionRate(clientIp)) {
|
||||
@@ -180,8 +281,21 @@ 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}`);
|
||||
socket.emit(EVENTS.ERROR, { message: 'Invalid version format' });
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
const tooOld = cMaj < mMaj || (cMaj === mMaj && cMin < mMin) || (cMaj === mMaj && cMin === mMin && cPatch < mPatch);
|
||||
if (tooOld) {
|
||||
log('AUTH', `Version too old (${clientVersion}) from ${clientIp}`);
|
||||
@@ -200,19 +314,21 @@ io.on('connection', (socket) => {
|
||||
return;
|
||||
}
|
||||
if (!payload || typeof payload.roomId !== 'string') return;
|
||||
const { password, peerId, protocolVersion } = payload;
|
||||
|
||||
// --- M-2: Sanitize and clamp all string fields ---
|
||||
const roomId = String(payload.roomId || '').substring(0, 64);
|
||||
// --- S-1 & S-5: Sanitize and clamp all incoming fields ---
|
||||
const password = typeof payload.password === 'string' ? payload.password.substring(0, 128) : null;
|
||||
const peerId = typeof payload.peerId === 'string' ? payload.peerId.substring(0, 16) : null;
|
||||
const protocolVersion = typeof payload.protocolVersion === 'string' ? payload.protocolVersion.substring(0, 16) : null;
|
||||
const roomId = String(payload.roomId || '').replace(/[^a-zA-Z0-9\-]/g, '').substring(0, 64);
|
||||
const username = typeof payload.username === 'string' ? payload.username.substring(0, 30) : null;
|
||||
const tabTitle = typeof payload.tabTitle === 'string' ? payload.tabTitle.substring(0, 100) : null;
|
||||
const mediaTitle = typeof payload.mediaTitle === 'string' ? payload.mediaTitle.substring(0, 100) : null;
|
||||
|
||||
if (!roomId) return; // Guard: empty after sanitization
|
||||
if (!roomId || !peerId) return; // Guard: empty or invalid after sanitization
|
||||
|
||||
try {
|
||||
// Protocol check
|
||||
if (protocolVersion !== '1.0.0') {
|
||||
if (protocolVersion !== PROTOCOL_VERSION) {
|
||||
log('AUTH', `Protocol mismatch from ${peerId}: ${protocolVersion}`);
|
||||
socket.emit(EVENTS.ERROR, { message: 'Incompatible protocol version' });
|
||||
return;
|
||||
@@ -220,64 +336,102 @@ io.on('connection', (socket) => {
|
||||
|
||||
// Cleanup old room if re-joining
|
||||
const oldMapping = socketToRoom.get(socket.id);
|
||||
if (oldMapping && oldMapping.roomId === roomId) {
|
||||
return; // Already in this room, ignore to prevent spam
|
||||
if (oldMapping && oldMapping.roomId === roomId && oldMapping.peerId === peerId) {
|
||||
return; // Already in this room with same peerId, ignore to prevent spam
|
||||
}
|
||||
if (oldMapping && oldMapping.roomId !== roomId) {
|
||||
socket.leave(oldMapping.roomId);
|
||||
removePeerFromRoom(socket.id, oldMapping.roomId, 'room-switch');
|
||||
}
|
||||
|
||||
const ip = socket.handshake.address;
|
||||
const ip = socket._clientIp || socket.handshake.address;
|
||||
if (!checkAuthRate(ip, roomId)) {
|
||||
log('AUTH', `Auth rate limit blocked ${ip} from room ${roomId.substring(0, 3)}***`);
|
||||
socket.emit(EVENTS.ERROR, { message: "Too many failed attempts. Try again later." });
|
||||
return;
|
||||
}
|
||||
|
||||
let room = rooms.get(roomId);
|
||||
let createdByMe = false;
|
||||
|
||||
if (!room) {
|
||||
if (rooms.size >= MAX_ROOMS) {
|
||||
socket.emit(EVENTS.ERROR, { message: "Server capacity reached" });
|
||||
return;
|
||||
// Acquire per-room creation lock to prevent race conditions
|
||||
let lockPromise = roomCreationLocks.get(roomId);
|
||||
if (lockPromise) {
|
||||
await lockPromise;
|
||||
room = rooms.get(roomId);
|
||||
}
|
||||
if (!room) {
|
||||
// Create and store lock before async boundary
|
||||
let resolveLock;
|
||||
lockPromise = new Promise(resolve => { resolveLock = resolve; });
|
||||
roomCreationLocks.set(roomId, lockPromise);
|
||||
try {
|
||||
if (rooms.size >= MAX_ROOMS) {
|
||||
log('ROOM', `Server at capacity: ${rooms.size}/${MAX_ROOMS} rooms — rejecting join`);
|
||||
socket.emit(EVENTS.ERROR, { message: "Server capacity reached" });
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = password ? await bcrypt.hash(password, 10) : null;
|
||||
room = {
|
||||
passwordHash,
|
||||
peers: new Set(),
|
||||
peerIds: new Map(),
|
||||
peerData: new Map(), // socketId -> { peerId, tabTitle }
|
||||
lastActivity: Date.now()
|
||||
};
|
||||
rooms.set(roomId, room);
|
||||
log('ROOM', `Created room: ${roomId.substring(0, 3)}***`);
|
||||
} else {
|
||||
const passwordHash = hashPassword(password);
|
||||
room = {
|
||||
passwordHash,
|
||||
peers: new Set(),
|
||||
peerIds: new Map(),
|
||||
peerData: new Map(),
|
||||
lastActivity: Date.now()
|
||||
};
|
||||
rooms.set(roomId, room);
|
||||
createdByMe = true;
|
||||
log('ROOM', `Created room: ${roomId.substring(0, 3)}***`);
|
||||
} finally {
|
||||
roomCreationLocks.delete(roomId);
|
||||
resolveLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!room) {
|
||||
socket.emit(EVENTS.ERROR, { message: "Join error" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!createdByMe) {
|
||||
if (room.passwordHash) {
|
||||
if (!password || !(await bcrypt.compare(password, room.passwordHash))) {
|
||||
if (!password || hashPassword(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);
|
||||
@@ -296,12 +450,15 @@ io.on('connection', (socket) => {
|
||||
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, username: username || null, tabTitle: tabTitle || null, mediaTitle: mediaTitle || null, status: 'joined' });
|
||||
socket.emit(EVENTS.ROOM_DATA, {
|
||||
roomId,
|
||||
peers: Array.from(room.peers).map(sid => room.peerData.get(sid))
|
||||
peers: Array.from(room.peers).map(sid => room.peerData.get(sid)),
|
||||
activeLobby: room.activeLobby || null
|
||||
});
|
||||
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" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -309,49 +466,101 @@ io.on('connection', (socket) => {
|
||||
const relayEvents = [
|
||||
EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK,
|
||||
EVENTS.PEER_STATUS, EVENTS.FORCE_SYNC_PREPARE,
|
||||
EVENTS.FORCE_SYNC_ACK, EVENTS.FORCE_SYNC_EXECUTE
|
||||
EVENTS.FORCE_SYNC_ACK, EVENTS.FORCE_SYNC_EXECUTE,
|
||||
EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_READY,
|
||||
EVENTS.EPISODE_LOBBY_CANCEL
|
||||
];
|
||||
|
||||
relayEvents.forEach(eventName => {
|
||||
socket.on(eventName, (data) => {
|
||||
if (!checkEventRate(socket.id)) {
|
||||
log('SECURITY', `Event rate limit exceeded for socket: ${socket.id}`);
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!checkEventRate(socket.id)) {
|
||||
log('SECURITY', `Event rate limit exceeded for socket: ${socket.id}`);
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data || typeof data !== 'object') return; // Prevent null/invalid payload crash
|
||||
if (!data || typeof data !== 'object') return;
|
||||
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (mapping) {
|
||||
const room = rooms.get(mapping.roomId);
|
||||
if (room) {
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (mapping) {
|
||||
const room = rooms.get(mapping.roomId);
|
||||
if (room) {
|
||||
room.lastActivity = Date.now();
|
||||
|
||||
// Update peer metadata and lastSeen
|
||||
// Sanitize mutable string fields to enforce the same length
|
||||
// limits as JOIN_ROOM — the relay path is otherwise unbounded.
|
||||
const clamp = (val, max) => typeof val === 'string' ? val.substring(0, max) : val;
|
||||
// --- S-2 & S-3: Sanitize ALL relay fields (strings, numbers, booleans) ---
|
||||
const clamp = (val, max) => typeof val === 'string' ? val.substring(0, max) : undefined;
|
||||
const clampNum = (val, min, max) => typeof val === 'number' && Number.isFinite(val) ? Math.max(min, Math.min(max, val)) : undefined;
|
||||
const validState = (val) => (val === 'playing' || val === 'paused') ? val : undefined;
|
||||
const validBool = (val) => typeof val === 'boolean' ? val : undefined;
|
||||
|
||||
const existing = room.peerData.get(socket.id) || { peerId: mapping.peerId };
|
||||
room.peerData.set(socket.id, {
|
||||
...existing,
|
||||
username: data.username !== undefined ? clamp(data.username, 30) : existing.username,
|
||||
tabTitle: data.tabTitle !== undefined ? clamp(data.tabTitle, 100) : existing.tabTitle,
|
||||
mediaTitle: data.mediaTitle !== undefined ? clamp(data.mediaTitle, 100) : existing.mediaTitle,
|
||||
playbackState: data.playbackState !== undefined ? data.playbackState : existing.playbackState,
|
||||
currentTime: data.currentTime !== undefined ? data.currentTime : existing.currentTime,
|
||||
volume: data.volume !== undefined ? data.volume : existing.volume,
|
||||
muted: data.muted !== undefined ? data.muted : existing.muted,
|
||||
username: data.username !== undefined ? (clamp(data.username, 30) ?? existing.username) : existing.username,
|
||||
tabTitle: data.tabTitle !== undefined ? (clamp(data.tabTitle, 100) ?? existing.tabTitle) : existing.tabTitle,
|
||||
mediaTitle: data.mediaTitle !== undefined ? (clamp(data.mediaTitle, 100) ?? existing.mediaTitle) : existing.mediaTitle,
|
||||
playbackState: data.playbackState !== undefined ? (validState(data.playbackState) ?? existing.playbackState) : existing.playbackState,
|
||||
currentTime: data.currentTime !== undefined ? (clampNum(data.currentTime, 0, 86400) ?? existing.currentTime) : existing.currentTime,
|
||||
volume: data.volume !== undefined ? (clampNum(data.volume, 0, 1) ?? existing.volume) : existing.volume,
|
||||
muted: data.muted !== undefined ? (validBool(data.muted) ?? existing.muted) : existing.muted,
|
||||
lastSeen: Date.now()
|
||||
});
|
||||
|
||||
socket.to(mapping.roomId).emit(eventName, { ...data, senderId: mapping.peerId });
|
||||
// --- S-3: Construct clean relay payload — never forward raw client data ---
|
||||
const relayPayload = {
|
||||
senderId: mapping.peerId,
|
||||
seq: clampNum(data.seq, 0, Number.MAX_SAFE_INTEGER),
|
||||
currentTime: clampNum(data.currentTime, 0, 86400),
|
||||
targetTime: clampNum(data.targetTime, 0, 86400),
|
||||
playbackState: validState(data.playbackState),
|
||||
username: clamp(data.username, 30),
|
||||
tabTitle: clamp(data.tabTitle, 100),
|
||||
mediaTitle: clamp(data.mediaTitle, 100),
|
||||
volume: clampNum(data.volume, 0, 1),
|
||||
muted: validBool(data.muted),
|
||||
peerId: mapping.peerId,
|
||||
status: typeof data.status === 'string' ? data.status.substring(0, 16) : undefined,
|
||||
expectedTitle: clamp(data.expectedTitle, 100),
|
||||
title: clamp(data.title, 100),
|
||||
actionTimestamp: clampNum(data.actionTimestamp, 0, Number.MAX_SAFE_INTEGER),
|
||||
};
|
||||
// Strip undefined keys for clean wire format
|
||||
Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]);
|
||||
socket.to(mapping.roomId).emit(eventName, relayPayload);
|
||||
|
||||
// --- Side-effects: Server-side Episode Lobby Tracking ---
|
||||
if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle) {
|
||||
room.activeLobby = {
|
||||
expectedTitle: relayPayload.expectedTitle,
|
||||
initiatorPeerId: mapping.peerId,
|
||||
readyPeers: [mapping.peerId]
|
||||
};
|
||||
} else if (eventName === EVENTS.EPISODE_READY && room.activeLobby) {
|
||||
if (!room.activeLobby.readyPeers.includes(mapping.peerId)) {
|
||||
room.activeLobby.readyPeers.push(mapping.peerId);
|
||||
}
|
||||
} else if ((eventName === EVENTS.FORCE_SYNC_PREPARE || eventName === EVENTS.FORCE_SYNC_EXECUTE || eventName === EVENTS.EPISODE_LOBBY_CANCEL) && room.activeLobby) {
|
||||
room.activeLobby = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
if (!checkCooldown(roomListCooldowns, socket.id, ROOM_LIST_COOLDOWN_MS)) {
|
||||
socket.emit(EVENTS.ERROR, { message: 'Room list refresh is rate limited. Try again in a few seconds.' });
|
||||
return;
|
||||
}
|
||||
const list = Array.from(rooms.entries()).map(([id, r]) => ({
|
||||
id,
|
||||
peerCount: r.peers.size,
|
||||
@@ -368,26 +577,34 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(EVENTS.EVENT_ACK, (data) => {
|
||||
if (!data.targetId) return;
|
||||
|
||||
const senderMapping = socketToRoom.get(socket.id);
|
||||
const targetSocketId = peerToSocket.get(data.targetId);
|
||||
const targetMapping = targetSocketId ? socketToRoom.get(targetSocketId) : null;
|
||||
socket.on(EVENTS.EVENT_ACK, (data) => {
|
||||
if (!checkEventRate(socket.id)) {
|
||||
log('SECURITY', `Event rate limit exceeded for socket (ACK): ${socket.id}`);
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
if (!data || typeof data !== 'object') return;
|
||||
if (typeof data.targetId !== 'string') return;
|
||||
if (data.actionTimestamp !== undefined && (typeof data.actionTimestamp !== 'number' || !Number.isFinite(data.actionTimestamp))) return;
|
||||
|
||||
const senderMapping = socketToRoom.get(socket.id);
|
||||
const targetSocketId = peerToSocket.get(data.targetId);
|
||||
const targetMapping = targetSocketId ? socketToRoom.get(targetSocketId) : null;
|
||||
|
||||
// Security: Only relay ACK if both peers are in the same room
|
||||
if (senderMapping && targetMapping && senderMapping.roomId === targetMapping.roomId) {
|
||||
io.to(targetSocketId).emit(EVENTS.EVENT_ACK, {
|
||||
senderId: senderMapping.peerId,
|
||||
actionTimestamp: data.actionTimestamp
|
||||
});
|
||||
} else {
|
||||
log('SECURITY', `Blocked cross-room ACK attempt from ${socket.id} to ${data.targetId}`);
|
||||
}
|
||||
});
|
||||
// Security: Only relay ACK if both peers are in the same room
|
||||
if (senderMapping && targetMapping && senderMapping.roomId === targetMapping.roomId) {
|
||||
io.to(targetSocketId).emit(EVENTS.EVENT_ACK, {
|
||||
senderId: senderMapping.peerId,
|
||||
actionTimestamp: data.actionTimestamp
|
||||
});
|
||||
} else {
|
||||
log('SECURITY', `Blocked cross-room ACK attempt from ${socket.id} to ${data.targetId}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
eventCounts.delete(socket.id);
|
||||
roomListCooldowns.delete(socket.id);
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (mapping) {
|
||||
// Socket is already disconnected — no need to call socket.leave().
|
||||
@@ -404,7 +621,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 = [];
|
||||
@@ -416,14 +637,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)`);
|
||||
@@ -454,3 +676,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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
export function checkCooldown(cooldowns, key, windowMs, now = Date.now()) {
|
||||
const lastAllowedAt = cooldowns.get(key) || 0;
|
||||
if (now - lastAllowedAt < windowMs) {
|
||||
return false;
|
||||
}
|
||||
cooldowns.set(key, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isAdminMetricsAuthorized(authHeader, adminToken) {
|
||||
if (!adminToken || typeof adminToken !== 'string') return false;
|
||||
if (!authHeader || typeof authHeader !== 'string') return false;
|
||||
|
||||
const prefix = 'Bearer ';
|
||||
if (!authHeader.startsWith(prefix)) return false;
|
||||
|
||||
const provided = authHeader.slice(prefix.length);
|
||||
if (!provided) return false;
|
||||
|
||||
const expectedBuffer = Buffer.from(adminToken);
|
||||
const providedBuffer = Buffer.from(provided);
|
||||
|
||||
if (expectedBuffer.length !== providedBuffer.length) return false;
|
||||
return crypto.timingSafeEqual(expectedBuffer, providedBuffer);
|
||||
}
|
||||
|
||||
export function buildHealthPayload({
|
||||
rooms,
|
||||
connections,
|
||||
includeMetrics = false,
|
||||
now = Date.now(),
|
||||
uptime = 0,
|
||||
memoryUsage = () => process.memoryUsage(),
|
||||
rateLimitSizes = {}
|
||||
}) {
|
||||
const payload = {
|
||||
status: 'ok',
|
||||
uptime,
|
||||
rooms: rooms.size,
|
||||
connections,
|
||||
timestamp: now
|
||||
};
|
||||
|
||||
if (!includeMetrics) return payload;
|
||||
|
||||
const roomValues = Array.from(rooms.values());
|
||||
const roomSizes = roomValues.map(room => room.peers?.size || 0);
|
||||
const peers = roomSizes.reduce((sum, size) => sum + size, 0);
|
||||
const maxPeersInRoom = roomSizes.length > 0 ? Math.max(...roomSizes) : 0;
|
||||
const avgPeersPerRoom = roomSizes.length > 0
|
||||
? Math.round((peers / roomSizes.length) * 100) / 100
|
||||
: 0;
|
||||
const mem = memoryUsage();
|
||||
|
||||
return {
|
||||
...payload,
|
||||
peers,
|
||||
roomsWithLobby: roomValues.filter(room => !!room.activeLobby).length,
|
||||
avgPeersPerRoom,
|
||||
maxPeersInRoom,
|
||||
rateLimitEntries: {
|
||||
connections: rateLimitSizes.connections || 0,
|
||||
events: rateLimitSizes.events || 0,
|
||||
health: rateLimitSizes.health || 0,
|
||||
authFailures: rateLimitSizes.authFailures || 0,
|
||||
roomList: rateLimitSizes.roomList || 0
|
||||
},
|
||||
memory: {
|
||||
rss: mem.rss,
|
||||
heapUsed: mem.heapUsed,
|
||||
heapTotal: mem.heapTotal
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"socket.io": "^4.8.3"
|
||||
@@ -70,15 +69,6 @@
|
||||
"node": "^4.5.0 || >= 5.9"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
@@ -266,9 +256,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io": {
|
||||
"version": "6.6.6",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz",
|
||||
"integrity": "sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==",
|
||||
"version": "6.6.8",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz",
|
||||
"integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cors": "^2.8.12",
|
||||
@@ -280,7 +270,7 @@
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.18.3"
|
||||
"ws": "~8.20.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
@@ -749,9 +739,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
@@ -951,13 +941,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
|
||||
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
|
||||
"version": "2.5.7",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz",
|
||||
"integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "~4.4.1",
|
||||
"ws": "~8.18.3"
|
||||
"ws": "~8.20.1"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
@@ -1079,9 +1069,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"socket.io": "^4.8.3"
|
||||
|
||||
@@ -4,20 +4,30 @@ This directory contains constants and protocol definitions used by both the exte
|
||||
|
||||
## Syncing with the Extension
|
||||
> [!IMPORTANT]
|
||||
> Every time this file is modified, you must run `scripts/sync-constants.sh` to keep the extension's copy up to date.
|
||||
> Every time this directory is modified, you must run `node scripts/build-extension.js` to keep the extension's copy up to date.
|
||||
|
||||
Because Chrome Extensions cannot load files outside their root directory, `constants.js` must be copied to `extension/shared/constants.js` whenever it is modified.
|
||||
Because Browser Extensions (Manifest V3) cannot load files outside their root directory, all files in this directory must be copied to `extension/shared/` whenever they are modified. The build script handles this automatically.
|
||||
|
||||
## Security & Versioning Constants
|
||||
- `OFFICIAL_SERVER_TOKEN`: A 32-byte hex token required to connect to the official relay server.
|
||||
- `APP_VERSION`: The current version of the extension. Used by the server to enforce minimum version requirements (Revocation). This must always be in sync with `manifest.json`.
|
||||
- `APP_VERSION`: The current version of the extension. Automatically injected from the git tag during CI release builds.
|
||||
- `OFFICIAL_SERVER_URL`: The default endpoint for the official KoalaSync relay.
|
||||
- `ROOM_DATA`: Server response with current room state (peers).
|
||||
- `PLAY`: Sync command to start playback.
|
||||
- `PAUSE`: Sync command to pause playback.
|
||||
- `SEEK`: Sync command to change the current time.
|
||||
- `PEER_STATUS`: Heartbeat or join/leave notification for peers.
|
||||
- `FORCE_SYNC_PREPARE`: Phase 1 of Force Sync (Pause & Seek).
|
||||
- `FORCE_SYNC_ACK`: Peer confirmation of Phase 1 readiness.
|
||||
- `FORCE_SYNC_EXECUTE`: Phase 2 of Force Sync (Start Playback).
|
||||
- `ERROR`: Generic error message from the server.
|
||||
|
||||
## Protocol Events
|
||||
For the complete and current event list, see the `EVENTS` object in [`constants.js`](constants.js). Key events include:
|
||||
|
||||
| Event | Direction | Purpose |
|
||||
|:------|:----------|:--------|
|
||||
| `JOIN_ROOM` | Client → Server | Request to join a room with credentials |
|
||||
| `LEAVE_ROOM` | Client → Server | Leave the current room |
|
||||
| `ROOM_DATA` | Server → Client | Current room state (peers list) |
|
||||
| `PLAY` / `PAUSE` / `SEEK` | Bidirectional relay | Media control commands |
|
||||
| `PEER_STATUS` | Bidirectional relay | Heartbeat or join/leave notification |
|
||||
| `FORCE_SYNC_PREPARE` | Bidirectional relay | Phase 1: Pause & seek to target time |
|
||||
| `FORCE_SYNC_ACK` | Bidirectional relay | Phase 1 confirmation: peer is buffered |
|
||||
| `FORCE_SYNC_EXECUTE` | Bidirectional relay | Phase 2: Resume playback simultaneously |
|
||||
| `EVENT_ACK` | Server → Client | Delivery confirmation for UI feedback |
|
||||
| `EPISODE_LOBBY` | Bidirectional relay | Episode transition: waiting for all peers |
|
||||
| `EPISODE_READY` | Bidirectional relay | Episode confirmation: peer has loaded |
|
||||
| `GET_ROOMS` / `ROOM_LIST` | Client ↔ Server | Room discovery |
|
||||
| `ERROR` | Server → Client | Error message |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* blacklist.js
|
||||
*
|
||||
* ⚠️ WARNING: This is the SINGLE SOURCE OF TRUTH.
|
||||
* If you edit this file, you MUST run /scripts/sync-constants.bat
|
||||
* If you edit this file, you MUST run: node scripts/build-extension.js
|
||||
* to propagate changes to the extension and relay server.
|
||||
*
|
||||
* Domains to be filtered out from the tab selection dropdown to reduce "noise".
|
||||
@@ -17,13 +17,37 @@ export const BLACKLIST_DOMAINS = [
|
||||
'msn.com',
|
||||
'baidu.com',
|
||||
'yandex.ru',
|
||||
'ecosia.org',
|
||||
'startpage.com',
|
||||
'search.brave.com',
|
||||
'qwant.com',
|
||||
'you.com',
|
||||
'perplexity.ai',
|
||||
'ask.com',
|
||||
'search.yahoo.com',
|
||||
'swisscows.ch',
|
||||
'mojeek.com',
|
||||
|
||||
// Mail Providers
|
||||
'mail.google.com',
|
||||
'gmail.com',
|
||||
'outlook.live.com',
|
||||
'outlook.office.com',
|
||||
'mail.yahoo.com',
|
||||
'gmx.net',
|
||||
'gmx.de',
|
||||
'gmx.com',
|
||||
'web.de',
|
||||
'protonmail.com',
|
||||
'proton.me',
|
||||
't-online.de',
|
||||
'posteo.de',
|
||||
'mailbox.org',
|
||||
'mail.de',
|
||||
'zoho.com',
|
||||
'fastmail.com',
|
||||
'tutanota.com',
|
||||
'mail.ru',
|
||||
|
||||
// Cloud Storage & Documents
|
||||
'docs.google.com',
|
||||
@@ -43,7 +67,6 @@ export const BLACKLIST_DOMAINS = [
|
||||
|
||||
// Productivity & Project Management
|
||||
'atlassian.net',
|
||||
'jira',
|
||||
'trello.com',
|
||||
'notion.so',
|
||||
'monday.com',
|
||||
@@ -61,9 +84,15 @@ 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.',
|
||||
'ebay.com',
|
||||
'aliexpress.com',
|
||||
'etsy.com',
|
||||
@@ -78,17 +107,67 @@ export const BLACKLIST_DOMAINS = [
|
||||
'myanimelist.net',
|
||||
|
||||
// Development & Utilities
|
||||
'timer.shik3i.net',
|
||||
'koalastuff.net',
|
||||
'auth.koalastuff.net',
|
||||
'blog.koalastuff.net',
|
||||
'clicker.koalastuff.net',
|
||||
'cookies.koalastuff.net',
|
||||
'multibox.koalastuff.net',
|
||||
'snippets.koalastuff.net',
|
||||
'status.koalastuff.net',
|
||||
'sync.koalastuff.net',
|
||||
'timer.koalastuff.net',
|
||||
'localhost',
|
||||
'zoom.us',
|
||||
'teams.microsoft.com',
|
||||
'meet.google.com',
|
||||
'chrome.google.com',
|
||||
|
||||
// Music Streaming
|
||||
'music.youtube.com',
|
||||
'open.spotify.com',
|
||||
'soundcloud.com',
|
||||
'deezer.com',
|
||||
'tidal.com',
|
||||
|
||||
// Knowledge & Blogs
|
||||
'wikipedia.org',
|
||||
'medium.com',
|
||||
'dev.to',
|
||||
'news.ycombinator.com',
|
||||
|
||||
// Design & Creative Tools
|
||||
'figma.com',
|
||||
'canva.com',
|
||||
'miro.com',
|
||||
|
||||
// Online IDEs & Hosting
|
||||
'vscode.dev',
|
||||
'replit.com',
|
||||
'codesandbox.io',
|
||||
'vercel.com',
|
||||
'netlify.com',
|
||||
|
||||
// Social & Image Sharing
|
||||
'pinterest.com',
|
||||
'tumblr.com',
|
||||
|
||||
// Language Learning
|
||||
'duolingo.com',
|
||||
'hellotalk.com',
|
||||
|
||||
// Google Utilities
|
||||
'calendar.google.com',
|
||||
'keep.google.com',
|
||||
|
||||
// Finance & Payments
|
||||
'paypal.com',
|
||||
'stripe.com',
|
||||
|
||||
// Games & Idle Sites
|
||||
'milkywayidle.com',
|
||||
'melvoridle.com',
|
||||
'cookieclicker.',
|
||||
'orteil.dashnet.org',
|
||||
'clickerheroes.com',
|
||||
'kongregate.com',
|
||||
'armorgames.com',
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
* KoalaSync Shared Constants & Protocol Definitions
|
||||
*
|
||||
* ⚠️ WARNING: This is the SINGLE SOURCE OF TRUTH.
|
||||
* If you edit this file, you MUST run /scripts/sync-constants.bat
|
||||
* If you edit this file, you MUST run: node scripts/build-extension.js
|
||||
* to propagate changes to the extension and relay server.
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = "1.0.0";
|
||||
export const APP_VERSION = "1.1.4";
|
||||
export const APP_VERSION = "1.9.0";
|
||||
|
||||
export const OFFICIAL_SERVER_URL = 'wss://sync.shik3i.net';
|
||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://koalasync.shik3i.net';
|
||||
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
|
||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
|
||||
export const OFFICIAL_SERVER_TOKEN = '62170b705234c4f4807a9b22420bb93cf1a2aacfa4c5d3b47804482babb8eb50';
|
||||
|
||||
export const EVENTS = {
|
||||
@@ -32,8 +32,14 @@ export const EVENTS = {
|
||||
FORCE_SYNC_EXECUTE: "force_sync_execute",
|
||||
EVENT_ACK: "event_ack",
|
||||
GET_ROOMS: "get_rooms",
|
||||
ROOM_LIST: "room_list"
|
||||
ROOM_LIST: "room_list",
|
||||
|
||||
// Episode Auto-Sync
|
||||
EPISODE_LOBBY: "episode_lobby", // Broadcast: waiting for everyone on this episode
|
||||
EPISODE_READY: "episode_ready", // Response: loaded the episode and paused at 0:00
|
||||
EPISODE_LOBBY_CANCEL: "episode_lobby_cancel" // Broadcast: cancel active lobby and resume
|
||||
};
|
||||
|
||||
export const HEARTBEAT_INTERVAL = 15000; // 15s
|
||||
export const FORCE_SYNC_TIMEOUT = 5000; // 5s timeout for ACKs
|
||||
export const FORCE_SYNC_TIMEOUT = 8500; // 8.5s timeout for force sync ACKs (must be > content.js poll timeout of 8s)
|
||||
export const EPISODE_LOBBY_TIMEOUT = 60000; // 60s timeout for episode lobby
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* KoalaSync Shared Name Generation & Emoji Mapping
|
||||
*
|
||||
* ⚠️ WARNING: This is the SINGLE SOURCE OF TRUTH.
|
||||
* If you edit this file, you MUST run: node scripts/build-extension.js
|
||||
* to propagate changes to the extension.
|
||||
*
|
||||
* The emoji map covers every animal/creature that has a Unicode emoji.
|
||||
* Entries are sorted by key length (longest first) at lookup time to
|
||||
* prevent substring false-matches (e.g. "caterpillar" must be checked
|
||||
* before "cat").
|
||||
*
|
||||
* If you add a new animal noun to USERNAME_NOUNS, ensure it has a
|
||||
* corresponding entry in ANIMAL_EMOJI_MAP (or a substring that already
|
||||
* maps to a suitable emoji).
|
||||
*/
|
||||
|
||||
export const USERNAME_ADJECTIVES = [
|
||||
'Happy', 'Cool', 'Fast', 'Smart', 'Brave', 'Calm', 'Sneaky', 'Lazy',
|
||||
'Wild', 'Chill', 'Lucky', 'Epic', 'Swift', 'Bold', 'Mighty',
|
||||
'Cosmic', 'Neon', 'Shadow', 'Crystal', 'Thunder', 'Silent', 'Golden',
|
||||
'Fierce', 'Noble', 'Mystic', 'Frozen', 'Blazing', 'Sapphire', 'Iron', 'Crimson',
|
||||
'Turbo', 'Zen', 'Pixel', 'Cyber', 'Solar', 'Lunar', 'Astro', 'Hyper',
|
||||
'Steel', 'Rogue', 'Alpha', 'Omega', 'Royal', 'Pixel', 'Warp', 'Frost',
|
||||
];
|
||||
|
||||
export const USERNAME_NOUNS = [
|
||||
'Koala', 'Panda', 'Tiger', 'Eagle', 'Fox', 'Bear', 'Wolf', 'Lion',
|
||||
'Hawk', 'Seal', 'Owl', 'Shark', 'Dragon', 'Phoenix', 'Falcon',
|
||||
'Panther', 'Raven', 'Cobra', 'Lynx', 'Jaguar', 'Orca', 'Mantis',
|
||||
'Viper', 'Condor', 'Badger', 'Otter', 'Rhino', 'Crane', 'Mongoose',
|
||||
'Specter',
|
||||
'Cat', 'Dog', 'Deer', 'Bat', 'Gorilla', 'Monkey', 'Rabbit',
|
||||
'Horse', 'Unicorn', 'Zebra', 'Leopard', 'Cheetah', 'Puma',
|
||||
'Ram', 'Goat', 'Bull', 'Donkey', 'Moose',
|
||||
'Elephant', 'Giraffe', 'Hippo', 'Sloth', 'Kangaroo',
|
||||
'Raccoon', 'Hamster', 'Hedgehog', 'Skunk', 'Beaver', 'Bison',
|
||||
'Camel', 'Llama', 'Hyena', 'Coyote',
|
||||
'Mouse', 'Pig', 'Boar', 'Polar', 'Orangutan', 'Mammoth',
|
||||
'Crow', 'Duck', 'Swan', 'Penguin', 'Parrot', 'Peacock',
|
||||
'Dove', 'Dodo', 'Turkey', 'Flamingo', 'Chicken', 'Rooster', 'Goose',
|
||||
'Dolphin', 'Whale', 'Crab', 'Lobster', 'Octopus', 'Squid',
|
||||
'Jellyfish', 'Turtle',
|
||||
'Crocodile', 'Lizard', 'Snake', 'Frog', 'Toad', 'Gecko',
|
||||
'Bee', 'Ant', 'Spider', 'Scorpion', 'Butterfly', 'Ladybug',
|
||||
'Beetle', 'Snail', 'Dragonfly', 'Caterpillar',
|
||||
'Alien', 'Robot', 'Mermaid', 'Ghoul', 'Sprite', 'Cyborg',
|
||||
'Dinosaur', 'Reaper', 'Wraith', 'Sphinx',
|
||||
'Yeti', 'Goblin', 'Pirate', 'Ninja', 'Samurai', 'Wizard', 'Knight',
|
||||
'Bandit', 'Storm', 'Comet', 'Nebula', 'Quasar', 'Vortex',
|
||||
];
|
||||
|
||||
export const ANIMAL_EMOJI_MAP = {
|
||||
'hippopotamus': '🦛',
|
||||
'rhinoceros': '🦏',
|
||||
'caterpillar': '🐛',
|
||||
'chimpanzee': '🐵',
|
||||
'orangutan': '🦧',
|
||||
'blackbird': '🐦⬛',
|
||||
'bumblebee': '🐝',
|
||||
'cockatoo': '🦜',
|
||||
'cockroach': '🪳',
|
||||
'dragonfly': '🐉',
|
||||
'grasshopper': '🦗',
|
||||
'hedgehog': '🦔',
|
||||
'jellyfish': '🪼',
|
||||
'kangaroo': '🦘',
|
||||
'ladybird': '🐞',
|
||||
'ladybug': '🐞',
|
||||
'porcupine': '🦔',
|
||||
'scorpion': '🦂',
|
||||
'tarantula': '🕷️',
|
||||
'alligator': '🐊',
|
||||
'anaconda': '🐍',
|
||||
'antelope': '🦌',
|
||||
'blowfish': '🐡',
|
||||
'butterfly': '🦋',
|
||||
'chameleon': '🦎',
|
||||
'chipmunk': '🐿️',
|
||||
'crocodile': '🐊',
|
||||
'dinosaur': '🦖',
|
||||
'elephant': '🐘',
|
||||
'flamingo': '🦩',
|
||||
'giraffe': '🦒',
|
||||
'hamster': '🐹',
|
||||
'leopard': '🐆',
|
||||
'lobster': '🦞',
|
||||
'mermaid': '🧜♀️',
|
||||
'mongoose': '🦦',
|
||||
'mosquito': '🦟',
|
||||
'pangolin': '🦔',
|
||||
'peacock': '🦚',
|
||||
'penguin': '🐧',
|
||||
'phoenix': '🐦🔥',
|
||||
'raccoon': '🦝',
|
||||
'seahorse': '🐴',
|
||||
'sealion': '🦭',
|
||||
'unicorn': '🦄',
|
||||
'vampire': '🦇',
|
||||
'warthog': '🐗',
|
||||
'wolverine': '🦡',
|
||||
'mammoth': '🦣',
|
||||
'meerkat': '🦦',
|
||||
'octopus': '🐙',
|
||||
'opposum': '🐭',
|
||||
'ostrich': '🐦',
|
||||
'panther': '🐆',
|
||||
'pelican': '🦩',
|
||||
'rooster': '🐓',
|
||||
'serpent': '🐍',
|
||||
'specter': '👻',
|
||||
'spectre': '👻',
|
||||
'sparrow': '🐦',
|
||||
'spider': '🕷️',
|
||||
'sphinx': '🦁',
|
||||
'squirrel': '🐿️',
|
||||
'stingray': '🦈',
|
||||
'termite': '🐜',
|
||||
'tortoise': '🐢',
|
||||
'turkey': '🦃',
|
||||
'walrus': '🦭',
|
||||
'wombat': '🦡',
|
||||
'woodpecker': '🐦',
|
||||
'alien': '👾',
|
||||
'badger': '🦡',
|
||||
'beaver': '🦫',
|
||||
'beetle': '🪲',
|
||||
'beluga': '🐋',
|
||||
'bison': '🦬',
|
||||
'bobcat': '🐱',
|
||||
'buffalo': '🦬',
|
||||
'bunny': '🐰',
|
||||
'camel': '🐪',
|
||||
'cheetah': '🐆',
|
||||
'chicken': '🐔',
|
||||
'cobra': '🐍',
|
||||
'condor': '🦅',
|
||||
'cougar': '🐆',
|
||||
'coyote': '🐺',
|
||||
'crane': '🦩',
|
||||
'cricket': '🦗',
|
||||
'crow': '🐦⬛',
|
||||
'cyborg': '🤖',
|
||||
'dolphin': '🐬',
|
||||
'donkey': '🫏',
|
||||
'dragon': '🐉',
|
||||
'drake': '🐉',
|
||||
'eagle': '🦅',
|
||||
'falcon': '🦅',
|
||||
'ferret': '🦦',
|
||||
'gazelle': '🦌',
|
||||
'gecko': '🦎',
|
||||
'gerbil': '🐹',
|
||||
'ghost': '👻',
|
||||
'ghoul': '👻',
|
||||
'goose': '🪿',
|
||||
'gopher': '🐹',
|
||||
'gorilla': '🦍',
|
||||
'grizzly': '🐻',
|
||||
'heron': '🦩',
|
||||
'hippo': '🦛',
|
||||
'hornet': '🐝',
|
||||
'hyena': '🐺',
|
||||
'iguana': '🦎',
|
||||
'jackal': '🐺',
|
||||
'jaguar': '🐆',
|
||||
'kitten': '🐱',
|
||||
'koala': '🐨',
|
||||
'lemur': '🐒',
|
||||
'lizard': '🦎',
|
||||
'llama': '🦙',
|
||||
'locust': '🦗',
|
||||
'lynx': '🐱',
|
||||
'macaw': '🦜',
|
||||
'mantis': '🦗',
|
||||
'mink': '🦦',
|
||||
'monkey': '🐵',
|
||||
'moose': '🦌',
|
||||
'mouse': '🐭',
|
||||
'orca': '🐋',
|
||||
'otter': '🦦',
|
||||
'oyster': '🦪',
|
||||
'panda': '🐼',
|
||||
'parrot': '🦜',
|
||||
'pigeon': '🕊️',
|
||||
'polar': '🐻❄️',
|
||||
'poodle': '🐩',
|
||||
'puffin': '🐧',
|
||||
'puma': '🐆',
|
||||
'rabbit': '🐰',
|
||||
'raptor': '🦖',
|
||||
'raven': '🐦⬛',
|
||||
'reaper': '👻',
|
||||
'rhino': '🦏',
|
||||
'robin': '🐦',
|
||||
'robot': '🤖',
|
||||
'salmon': '🐟',
|
||||
'shrimp': '🦐',
|
||||
'skunk': '🦨',
|
||||
'sloth': '🦥',
|
||||
'snail': '🐌',
|
||||
'snake': '🐍',
|
||||
'sprite': '🧚',
|
||||
'squid': '🦑',
|
||||
'swan': '🦢',
|
||||
'tapir': '🐗',
|
||||
'tiger': '🐯',
|
||||
'toad': '🐸',
|
||||
'trout': '🐟',
|
||||
'tuna': '🐟',
|
||||
'turtle': '🐢',
|
||||
'viper': '🐍',
|
||||
'vulture': '🦅',
|
||||
'weasel': '🦦',
|
||||
'whale': '🐋',
|
||||
'wolf': '🐺',
|
||||
'wraith': '👻',
|
||||
'zebra': '🦓',
|
||||
'ape': '🦍',
|
||||
'ant': '🐜',
|
||||
'bat': '🦇',
|
||||
'bee': '🐝',
|
||||
'bug': '🐛',
|
||||
'cat': '🐱',
|
||||
'cow': '🐮',
|
||||
'crab': '🦀',
|
||||
'dog': '🐶',
|
||||
'duck': '🦆',
|
||||
'elk': '🦌',
|
||||
'fly': '🪰',
|
||||
'fox': '🦊',
|
||||
'frog': '🐸',
|
||||
'goat': '🐐',
|
||||
'hawk': '🦅',
|
||||
'hen': '🐔',
|
||||
'hog': '🐷',
|
||||
'lion': '🦁',
|
||||
'mole': '🐭',
|
||||
'moth': '🦋',
|
||||
'mule': '🫏',
|
||||
'owl': '🦉',
|
||||
'pig': '🐷',
|
||||
'ram': '🐏',
|
||||
'rat': '🐀',
|
||||
'seal': '🦭',
|
||||
'shark': '🦈',
|
||||
'wasp': '🐝',
|
||||
'yak': '🐂',
|
||||
'doe': '🦌',
|
||||
'ewe': '🐑',
|
||||
'buck': '🦌',
|
||||
'ox': '🐂',
|
||||
'bull': '🐂',
|
||||
'dodo': '🦤',
|
||||
'boar': '🐗',
|
||||
'bear': '🐻',
|
||||
'deer': '🦌',
|
||||
'dove': '🕊️',
|
||||
'fish': '🐟',
|
||||
'hare': '🐰',
|
||||
'horse': '🐴',
|
||||
'lamb': '🐑',
|
||||
'mare': '🐴',
|
||||
'pony': '🐴',
|
||||
'pup': '🐶',
|
||||
'croc': '🐊',
|
||||
'gnat': '🦟',
|
||||
'gnu': '🦬',
|
||||
'yeti': '🦍',
|
||||
'goblin': '👺',
|
||||
'pirate': '🏴',
|
||||
'ninja': '🥷',
|
||||
'samurai': '🥷',
|
||||
'wizard': '🧙',
|
||||
'knight': '⚔️',
|
||||
'bandit': '🦹',
|
||||
'storm': '🌩️',
|
||||
'comet': '☄️',
|
||||
'nebula': '🌌',
|
||||
'quasar': '🌟',
|
||||
'vortex': '🌪️',
|
||||
};
|
||||
|
||||
export function getAvatarForName(username) {
|
||||
if (!username) return '\u{1F464}';
|
||||
const lower = username.toLowerCase();
|
||||
const sorted = Object.entries(ANIMAL_EMOJI_MAP).sort((a, b) => b[0].length - a[0].length);
|
||||
for (const [key, emoji] of sorted) {
|
||||
if (lower.includes(key)) return emoji;
|
||||
}
|
||||
return '\u{1F464}';
|
||||
}
|
||||
|
||||
export function generateUsername() {
|
||||
const adj = USERNAME_ADJECTIVES[Math.floor(Math.random() * USERNAME_ADJECTIVES.length)];
|
||||
const noun = USERNAME_NOUNS[Math.floor(Math.random() * USERNAME_NOUNS.length)];
|
||||
return `${adj}${noun}`;
|
||||
}
|
||||
@@ -1,33 +1,52 @@
|
||||
# KoalaSync Landing Page
|
||||
# KoalaSync Website & Invitation Bridge
|
||||
|
||||
This directory contains the static marketing website for KoalaSync. It is built using vanilla HTML, CSS, and JavaScript to ensure maximum performance, zero tracking, and easy hosting.
|
||||
This directory contains the KoalaSync website. It serves a dual purpose: it is both the **marketing landing page** and the **technical bridge** for joining synchronized rooms.
|
||||
|
||||
## Features
|
||||
- **Privacy First**: No external fonts, scripts, or trackers.
|
||||
- **Modern Tech Aesthetic**: Pure CSS animated gradients and glassmorphism.
|
||||
- **Smart Join**: Integrated bridge for communication with the KoalaSync browser extension.
|
||||
## Core Roles
|
||||
|
||||
### 1. Marketing & Onboarding
|
||||
Provides a premium, multi-language (EN/DE/FR/ES/PT-BR/RU) overview of features, setup instructions, and direct links to the extension stores.
|
||||
|
||||
### 2. The Invitation Bridge (`join.html`)
|
||||
The website handles incoming invitation links. When a user clicks a link like `sync.koalastuff.net/join.html#join:roomID:pass`, the website:
|
||||
- **Detects the Extension**: Verifies if KoalaSync is installed via the `bridge.js` content script.
|
||||
- **Privacy-First Handshake**: The room credentials (ID/Password) are stored in the **URL Hash (#)**. This ensures the sensitive credentials **never reach the web server** and are processed entirely within the user's browser.
|
||||
- **Auto-Join**: If the extension is detected, it automatically triggers the join flow without requiring user input.
|
||||
|
||||
## Architecture
|
||||
|
||||
The website is 100% **Static HTML, CSS, and JS**.
|
||||
- **Static i18n Compiler**: The site uses a lightweight, zero-dependency Node.js compiler (`build.js`) to parse dictionary files inside `/locales/` against a single source-of-truth template (`template.html`), outputting the fully deployable static folder to `/www/`.
|
||||
- **Build-time Minification**: `build.js` automatically minifies CSS and JS during compilation using a built-in state-machine tokenizer (no npm dependencies). Source files are written unminified (`style.css`, `app.js`, `lang-init.js`) — always edit source files, never the generated `.min.*` files in `www/`.
|
||||
- **Zero Backend**: No Node.js, PHP, or databases are required to host the compiled website.
|
||||
- **Zero Tracking**: All assets (fonts, icons) are self-hosted to prevent third-party tracking.
|
||||
- **Responsive**: Fully optimized for mobile with a native-feel hamburger menu.
|
||||
|
||||
## Hosting with Caddy
|
||||
|
||||
Caddy is the recommended web server for KoalaSync due to its automatic HTTPS and simple configuration.
|
||||
Caddy is the recommended web server. It provides automatic HTTPS and high-performance static file serving.
|
||||
|
||||
### Example Caddyfile
|
||||
### Recommended Caddyfile
|
||||
|
||||
To host the website on `koalasync.shik3i.net`, you can use the following configuration:
|
||||
For a more comprehensive configuration that includes the Relay Server reverse proxy, see the root [Caddyfile.example](../Caddyfile.example).
|
||||
|
||||
```caddy
|
||||
koalasync.shik3i.net {
|
||||
# Path to the website directory
|
||||
root * /var/www/koalasync/website
|
||||
|
||||
# Enable static file serving
|
||||
sync.koalastuff.net {
|
||||
root * /var/www/koalasync/website/www
|
||||
file_server
|
||||
|
||||
# Enable Gzip/Zstd compression
|
||||
encode zstd gzip
|
||||
|
||||
# Security Headers
|
||||
|
||||
# Static Caching for high-performance PageSpeed (1 year with validation)
|
||||
@static {
|
||||
file
|
||||
path *.ico *.css *.js *.png *.svg *.webp
|
||||
}
|
||||
header @static Cache-Control "public, max-age=31536000, must-revalidate"
|
||||
|
||||
# Security Headers & Content Security Policy (CSP)
|
||||
header {
|
||||
# Strict Content Security Policy (restricts scripts and connections to self, forbids frames)
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';"
|
||||
# Prevent FLoC tracking
|
||||
Permissions-Policy interest-cohort=()
|
||||
# Security best practices
|
||||
@@ -36,16 +55,20 @@ koalasync.shik3i.net {
|
||||
X-Frame-Options DENY
|
||||
Referrer-Policy no-referrer-when-downgrade
|
||||
}
|
||||
|
||||
# Custom 404 page
|
||||
handle_errors {
|
||||
rewrite * /{err.status_code}.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Deployment Steps
|
||||
1. Copy the contents of this folder to `/var/www/koalasync/website` on your server.
|
||||
2. Update the path in your `Caddyfile`.
|
||||
3. Reload Caddy: `caddy reload`.
|
||||
## Local Development & Compilation
|
||||
|
||||
1. Run the compilation script from the repository root to generate the `/website/www` folder:
|
||||
```bash
|
||||
node website/build.js
|
||||
```
|
||||
2. Serve the compiled `/www` directory using any local development server:
|
||||
```bash
|
||||
npx serve website/www
|
||||
```
|
||||
3. To test the invitation flow locally, navigate to `http://localhost:5000/join.html#join:test-room:test-pass`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Never edit files inside `website/www/` directly.** This directory is fully auto-generated by `build.js`. Always edit source files (`template.html`, `style.css`, `app.js`, `lang-init.js`, locale files in `locales/`) and re-run `node website/build.js` to apply changes. CSS and JS are output as `style.min.css`, `app.min.js`, and `lang-init.min.js` — the `.min.*` naming makes it visually obvious these are build artifacts. Editing minified files in `www/` will result in lost changes on the next build.
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
# KoalaSync Translation & Localization Guide
|
||||
|
||||
Welcome to the **KoalaSync** translation and internationalization framework! This document provides clear, professional instructions for developers and contributors looking to maintain, audit, or add new languages to the official KoalaSync website.
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ Architecture Overview
|
||||
|
||||
The KoalaSync website utilizes a custom, high-performance, zero-dependency static site generator built in Node.js. Instead of using complex client-side translation runtimes or bulky frameworks, localized pages are compiled ahead-of-time (AOT) to maintain lightning-fast page speeds and strict data sovereignty.
|
||||
|
||||
* **Template Source:** [`website/template.html`](file:///Users/koala/Documents/KoalaPlay/website/template.html) (Single Source of Truth)
|
||||
* **Locales Source:** `/website/locales/[lang].json` (Structured JSON translation dictionaries)
|
||||
* **Build Pipeline:** [`website/build.js`](file:///Users/koala/Documents/KoalaPlay/website/build.js) (Pure Node.js script that compiles pages into `/website/www/`)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Supported Languages Dashboard
|
||||
|
||||
We divide supported languages into two tiers: **Core Languages** (fully hand-crafted and audited by native speakers) and **Extended Languages** (auto-generated using translation models to expand initial coverage).
|
||||
|
||||
> [!TIP]
|
||||
> **Help Us Improve!**
|
||||
> We welcome community contributions to audit "Auto-Generated" translations and elevate them to "Verified" status.
|
||||
|
||||
| Language Code | Language Name | Verification Status | Rationale / Context |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `en` | **English** | `100% Manually Verified` | Primary developer language and system default |
|
||||
| `de` | **German** | `100% Manually Verified` | Core market and compliance baseline |
|
||||
| `fr` | **French** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `es` | **Spanish** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `pt-BR` | **Portuguese (Brasil)** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
| `ru` | **Russian** | `Auto-Generated` | Needs manual native review and polishing |
|
||||
|
||||
> [!WARNING]
|
||||
> **Autogeneration Quality Rule**
|
||||
> Any newly contributed languages must be committed as `"Auto-Generated"` until fully reviewed and signed off by a native speaker in a pull request.
|
||||
|
||||
---
|
||||
|
||||
## ⚖️ Strict Legal Exclusion Rule
|
||||
|
||||
Our legal pages have strict constraints to protect user privacy and avoid regulatory liabilities.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **DO NOT TRANSLATE LEGAL DOCUMENTS**
|
||||
> The legal notice ([impressum.html](file:///Users/koala/Documents/KoalaPlay/website/impressum.html)) and privacy policy ([datenschutz.html](file:///Users/koala/Documents/KoalaPlay/website/datenschutz.html)) **MUST remain exclusively in English and German**.
|
||||
>
|
||||
> * **Rationale:** Legal compliance under the European Union General Data Protection Regulation (GDPR) and the German Digital Services Act (DDG). Offering automated translations of legally binding notices introduces compliance risks due to potential mistranslations of liability limits.
|
||||
> * **Technical Fallback:** The dynamic initializer script (`lang-init.js`) is configured to automatically fallback to **English** for legal pages if a user visits them with a French, Spanish, or other unsupported language preference, keeping their dynamic dropdown choice intact for homepage links.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Step-by-Step: Adding a New Language
|
||||
|
||||
Adding a new language (e.g., Italian - `it`) is straightforward. Follow these four structured steps:
|
||||
|
||||
### Step 1: Create the Translation Dictionary
|
||||
Create a new JSON file inside the locales directory named `[lang].json` (e.g., `website/locales/it.json`).
|
||||
1. Copy the structure of [`website/locales/en.json`](file:///Users/koala/Documents/KoalaPlay/website/locales/en.json) to use as your baseline.
|
||||
2. Translate all string values while keeping the JSON keys identical.
|
||||
3. Configure the language metadata keys at the top of the file:
|
||||
```json
|
||||
{
|
||||
"LANG_CODE": "it",
|
||||
"HTML_CLASS": "lang-it",
|
||||
"CANONICAL_PATH": "it/",
|
||||
"LANG_TOGGLE_URL": "../",
|
||||
"LANG_TOGGLE_TEXT": "EN"
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Register in the Compiler
|
||||
Open the static site generator script [`website/build.js`](file:///Users/koala/Documents/KoalaPlay/website/build.js) and append your new language code to the active `languages` array:
|
||||
```javascript
|
||||
// Add 'it' to the array
|
||||
const languages = ['en', 'de', 'fr', 'es', 'pt-BR', 'ru', 'it'];
|
||||
```
|
||||
|
||||
### Step 3: Run the Build Script
|
||||
Execute the compiler from the root of the repository:
|
||||
```bash
|
||||
node website/build.js
|
||||
```
|
||||
The compiler will automatically:
|
||||
1. Load the new JSON translation file.
|
||||
2. Create the target subdirectory `/website/www/it/`.
|
||||
3. Generate the compiled `/website/www/it/index.html` landing page, injecting correct relative assets and canonical metadata.
|
||||
|
||||
### Step 4: Update the Dashboard
|
||||
Open this `TRANSLATION.md` file and add your language to the **Supported Languages Dashboard** table, marking it as `Auto-Generated` (unless manually verified).
|
||||
|
||||
---
|
||||
|
||||
## 🔮 Future Roadmap: Dynamic Utility Pages
|
||||
|
||||
For pages that require fully dynamic, client-side interactions (like the room invitation bridge [`join.html`](file:///Users/koala/Documents/KoalaPlay/website/join.html)), we need to scale to unlimited languages without bloating the HTML size or polluting the URL.
|
||||
|
||||
### Clean Client-Side i18n Architecture
|
||||
|
||||
To maintain zero URL pollution (e.g. keeping invitation links clean as `/join.html#join:roomID:password`), we propose an **asynchronous JSON dictionary injection architecture**:
|
||||
|
||||
#### 1. Page Lifecycle Flow
|
||||
1. **User Landing:** The guest enters `/join.html` with a shared hash.
|
||||
2. **Language Resolution:** `lang-init.js` immediately reads their saved preference (`localStorage` or `navigator.language`) and applies the active class (e.g. `html.lang = "es"`).
|
||||
3. **Async Fetching:** A client-side loader script (`i18n-client.js`) runs asynchronously, downloading the correct dictionary (`fetch("/locales/es.json")`).
|
||||
4. **DOM Translation:** The script scans the page for elements carrying a `data-i18n` attribute and safely updates their text content at runtime, avoiding dual-text nodes and stylesheet recalculations.
|
||||
|
||||
#### 2. Declarative HTML Markup
|
||||
Elements are defined with custom data attributes specifying translation keys. English text is placed as the static HTML fallback:
|
||||
```html
|
||||
<h1 data-i18n="JOIN_TITLE">Ready to sync?</h1>
|
||||
<p id="join-desc" data-i18n="JOIN_SUBTITLE">You've been invited to join a session.</p>
|
||||
```
|
||||
|
||||
#### 3. Zero-Dependency Engine (`i18n-client.js`)
|
||||
```javascript
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// 1. Recover the language determined during early initialization
|
||||
const activeLang = document.documentElement.lang || 'en';
|
||||
if (activeLang === 'en') return; // Default markup is already in English
|
||||
|
||||
// 2. Fetch the corresponding locale JSON asynchronously
|
||||
try {
|
||||
const response = await fetch(`locales/${activeLang}.json`);
|
||||
if (!response.ok) throw new Error('Locale file unavailable');
|
||||
const dictionary = await response.json();
|
||||
|
||||
// 3. Scan and translate data-i18n attributes
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
if (dictionary[key]) {
|
||||
if (el.tagName === 'IMG') {
|
||||
el.alt = dictionary[key];
|
||||
} else {
|
||||
el.textContent = dictionary[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Dynamic i18n loading failed. Defaulting to English:', err);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### Core Benefits
|
||||
* **Zero URL Pollution:** Keeps invitation hashes private and avoids messy query parameters (`?lang=de`), protecting user privacy.
|
||||
* **Optimal Performance:** Eliminates duplicate hidden text blocks, cutting page weight in half and ensuring smooth rendering.
|
||||
* **Infinite Scale:** Adding new languages to dynamic pages requires zero edits to HTML markup; the engine simply fetches new JSON dictionaries on-demand.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Extension Internationalization (i18n)
|
||||
|
||||
In **v2.0**, we extended full internationalization support to the **Browser Extension itself**. The architecture mirrors our web-based dynamic localization model to maintain complete parity.
|
||||
|
||||
* **Locales Directory:** [`extension/locales/`](file:///Users/koala/Documents/KoalaPlay/extension/locales/)
|
||||
* **Active Dictionaries:**
|
||||
* [`en.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/en.json) (Baseline English)
|
||||
* [`de.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/de.json) (German)
|
||||
* [`fr.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/fr.json) (French)
|
||||
* [`es.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/es.json) (Spanish)
|
||||
* [`pt-BR.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/pt-BR.json) (Portuguese (Brasil))
|
||||
* [`ru.json`](file:///Users/koala/Documents/KoalaPlay/extension/locales/ru.json) (Russian)
|
||||
* **Translation Engine:** [`extension/i18n.js`](file:///Users/koala/Documents/KoalaPlay/extension/i18n.js)
|
||||
* **Validation Script:** [`scripts/test-locales.js`](file:///Users/koala/Documents/KoalaPlay/scripts/test-locales.js)
|
||||
|
||||
### ⚙️ How it Works inside the Extension
|
||||
|
||||
1. **System Locale Auto-Detection**: On first run, the extension detects the browser system language using `navigator.language` or `chrome.i18n.getUILanguage()`.
|
||||
2. **On-the-Fly Redraws**: When the user selects a different language in the settings tab (`#langSelector`), the selection is stored in `chrome.storage.sync` and the translation engine immediately triggers `translateDOM()`. The interface, empty state cards, tooltips, dynamic onboarding tutorial guides, and status badges re-render instantly without reloading the popup.
|
||||
3. **Localized System Notifications**: On play, pause, or seek commands, `background.js` retrieves the user's active locale preference from storage, loads the correct dictionary, and pushes native OS notifications fully translated.
|
||||
|
||||
### 🧪 Auditing & Sync Checks
|
||||
|
||||
To ensure that no language dictionary falls out of sync (causing missing labels or blank interfaces), developers must run the locale auditor tool before packaging releases:
|
||||
```bash
|
||||
node scripts/test-locales.js
|
||||
```
|
||||
This script asserts that all JSON dictionary files under `extension/locales/` share exactly the same set of keys as the English baseline (`en.json`).
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
// KoalaSync Landing Page Logic
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Scroll Reveal Logic
|
||||
// Scroll Reveal Logic (IntersectionObserver for performance)
|
||||
const revealElements = document.querySelectorAll('[data-reveal]');
|
||||
|
||||
const revealOnScroll = () => {
|
||||
const windowHeight = window.innerHeight;
|
||||
revealElements.forEach(el => {
|
||||
const elementTop = el.getBoundingClientRect().top;
|
||||
const revealPoint = 150;
|
||||
|
||||
if (elementTop < windowHeight - revealPoint) {
|
||||
el.classList.add('revealed');
|
||||
const revealObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('revealed');
|
||||
revealObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
};
|
||||
}, {
|
||||
rootMargin: '0px 0px -150px 0px',
|
||||
threshold: 0.1
|
||||
});
|
||||
|
||||
// Initial check
|
||||
revealOnScroll();
|
||||
|
||||
// Scroll listener
|
||||
window.addEventListener('scroll', revealOnScroll);
|
||||
revealElements.forEach(el => revealObserver.observe(el));
|
||||
|
||||
// Navbar scroll effect
|
||||
const nav = document.querySelector('nav');
|
||||
@@ -50,7 +46,53 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Invite Detection & Bridge
|
||||
const checkInvite = () => {
|
||||
const isJoinPage = window.location.pathname.includes('join.html');
|
||||
const isJoinPage = window.location.pathname.includes('join');
|
||||
|
||||
// Dev Simulation Mode via URL Search Parameter (?dev=success) or Hash (#dev=success / #devsuccess)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
let devMode = urlParams.get('dev');
|
||||
|
||||
if (!devMode) {
|
||||
const hashClean = window.location.hash.startsWith('#') ? window.location.hash.substring(1) : window.location.hash;
|
||||
const hashParams = new URLSearchParams(hashClean);
|
||||
devMode = hashParams.get('dev');
|
||||
}
|
||||
|
||||
if (!devMode) {
|
||||
if (window.location.hash.includes('devsuccess') || window.location.search.includes('devsuccess')) devMode = 'success';
|
||||
if (window.location.hash.includes('devfailure') || window.location.search.includes('devfailure')) devMode = 'failure';
|
||||
}
|
||||
|
||||
if (isJoinPage && devMode) {
|
||||
setTimeout(() => {
|
||||
const displayRoom = document.getElementById('display-room-id');
|
||||
const actions = document.getElementById('join-actions');
|
||||
if (displayRoom) displayRoom.textContent = 'DEV-ROOM';
|
||||
|
||||
if (actions) {
|
||||
actions.innerHTML = `
|
||||
<div class="joining-spinner" style="text-align:center; padding: 1rem;">
|
||||
<div class="join-spinner"></div>
|
||||
<div style="font-weight: 600; color: var(--accent);">
|
||||
<span lang="en">Simulating connection (DEV)...</span><span lang="de">Verbindung wird simuliert (DEV)...</span>
|
||||
</div>
|
||||
<p style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.5rem;">
|
||||
<span lang="en">Simulating status event in 1.5 seconds.</span><span lang="de">Status-Event wird in 1,5 Sekunden simuliert.</span>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent('KOALASYNC_STATUS', {
|
||||
detail: {
|
||||
success: devMode === 'success',
|
||||
message: devMode === 'failure' ? 'Simulated Connection Timeout!' : ''
|
||||
}
|
||||
}));
|
||||
}, 1500);
|
||||
}
|
||||
}, 600);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use a short timeout to let the bridge script initialize its dataset attribute
|
||||
setTimeout(() => {
|
||||
@@ -71,17 +113,52 @@ 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 = `
|
||||
<div class="join-card-actions">
|
||||
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/" class="btn btn-primary btn-firefox">
|
||||
<img src="assets/firefox.svg" alt="Firefox" width="20" style="display: block;">
|
||||
<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" target="_blank" class="btn btn-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" 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>
|
||||
</div>
|
||||
<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="join-card-actions">
|
||||
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc" class="btn btn-primary">
|
||||
<img src="assets/chrome.svg" alt="Chrome" 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" target="_blank" class="btn btn-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" 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>
|
||||
</div>
|
||||
<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 class="join-spinner"></div>
|
||||
<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>
|
||||
`;
|
||||
|
||||
@@ -117,7 +194,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
inviteSpan.appendChild(document.createTextNode(' detected!'));
|
||||
|
||||
const joinLink = document.createElement('a');
|
||||
joinLink.href = 'join.html' + window.location.hash;
|
||||
joinLink.href = 'join' + window.location.hash;
|
||||
joinLink.className = 'btn-banner';
|
||||
joinLink.textContent = 'OPEN JOIN PAGE';
|
||||
|
||||
@@ -151,45 +228,74 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Listen for status from Extension
|
||||
window.addEventListener('KOALASYNC_STATUS', (e) => {
|
||||
const { success, message } = e.detail;
|
||||
const isJoinPage = window.location.pathname.includes('join.html');
|
||||
const isJoinPage = window.location.pathname.includes('join');
|
||||
|
||||
if (isJoinPage) {
|
||||
const icon = document.getElementById('join-status-icon');
|
||||
const title = document.getElementById('join-title');
|
||||
const actions = document.getElementById('join-actions');
|
||||
const desc = document.getElementById('join-desc');
|
||||
const ring = document.getElementById('status-ring');
|
||||
|
||||
if (success) {
|
||||
if (icon) icon.textContent = '✅';
|
||||
title.textContent = 'Erfolgreich!';
|
||||
if (ring) {
|
||||
ring.classList.remove('active-pulse');
|
||||
ring.style.display = 'none';
|
||||
}
|
||||
if (icon) {
|
||||
icon.innerHTML = '<img src="assets/KoalaThumbsUp.webp" alt="Success" class="join-status-mascot">';
|
||||
icon.style.transform = 'scale(1)';
|
||||
}
|
||||
const isDE = document.documentElement.classList.contains('lang-de');
|
||||
title.textContent = isDE ? 'Erfolgreich!' : 'Success!';
|
||||
desc.innerHTML = isDE
|
||||
? 'Verbunden! <br><span style="color:var(--accent); font-weight:bold;">Wähle jetzt einen Video-Tab in der Erweiterung aus.</span>'
|
||||
: 'Connected! <br><span style="color:var(--accent); font-weight:bold;">Now select a video tab in the extension.</span>';
|
||||
|
||||
let count = 3;
|
||||
const updateCountdown = () => {
|
||||
desc.innerHTML = `Du bist dem Raum beigetreten. <br><span style="color:var(--accent); font-weight:bold;">Dieser Tab schließt sich in ${count} Sekunden...</span>`;
|
||||
if (count <= 0) {
|
||||
window.close();
|
||||
desc.textContent = 'Beitritt erfolgreich! Du kannst diesen Tab jetzt manuell schließen.';
|
||||
desc.textContent = isDE ? 'Beitritt erfolgreich! Du kannst diesen Tab jetzt manuell schließen.' : 'Joined successfully! You can close this tab manually.';
|
||||
} else {
|
||||
count--;
|
||||
setTimeout(updateCountdown, 1000);
|
||||
}
|
||||
};
|
||||
updateCountdown();
|
||||
setTimeout(updateCountdown, 1000);
|
||||
|
||||
actions.innerHTML = '<button class="primary" onclick="window.close()" style="background:var(--success); width: 100%;">TAB JETZT SCHLIESSEN</button>';
|
||||
const closeLabel = isDE ? 'TAB JETZT SCHLIESSEN' : 'CLOSE TAB NOW';
|
||||
actions.innerHTML = `
|
||||
<div class="join-card-actions">
|
||||
<button class="btn btn-success" onclick="window.close()">${closeLabel}</button>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
if (icon) icon.textContent = '❌';
|
||||
title.textContent = 'Fehler';
|
||||
desc.textContent = `Beitritt fehlgeschlagen: ${message}`;
|
||||
actions.innerHTML = '<button class="primary" onclick="location.reload()" style="width: 100%;">ERNEUT VERSUCHEN</button>';
|
||||
if (ring) {
|
||||
ring.classList.remove('active-pulse');
|
||||
ring.style.display = 'none';
|
||||
}
|
||||
if (icon) {
|
||||
icon.innerHTML = '<img src="assets/KoalaThumbsDown.webp" alt="Error" class="join-status-mascot" onerror="this.outerHTML=\'❌\'">';
|
||||
icon.style.transform = 'scale(1)';
|
||||
}
|
||||
const isDE = document.documentElement.classList.contains('lang-de');
|
||||
title.textContent = isDE ? 'Fehler' : 'Error';
|
||||
desc.textContent = isDE ? `Beitritt fehlgeschlagen: ${message}` : `Join failed: ${message}`;
|
||||
const retryLabel = isDE ? 'ERNEUT VERSUCHEN' : 'TRY AGAIN';
|
||||
actions.innerHTML = `
|
||||
<div class="join-card-actions">
|
||||
<button class="btn btn-primary" onclick="location.reload()">${retryLabel}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
const banner = document.getElementById('koala-banner');
|
||||
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 = '';
|
||||
@@ -202,5 +308,364 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const updateDynamicVersion = async () => {
|
||||
try {
|
||||
const versionPath = document.documentElement.lang === 'en' ? 'version.json' : '../version.json';
|
||||
const response = await fetch(versionPath);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
const { version, date } = data;
|
||||
if (!version || !date) return;
|
||||
|
||||
const releaseDate = new Date(date);
|
||||
const now = new Date();
|
||||
const diffMs = now - releaseDate;
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
|
||||
let relativeTimeEn = '';
|
||||
let relativeTimeDe = '';
|
||||
|
||||
if (diffDays > 0) {
|
||||
relativeTimeEn = `${diffDays} ${diffDays === 1 ? 'day' : 'days'} ago`;
|
||||
relativeTimeDe = `vor ${diffDays} ${diffDays === 1 ? 'Tag' : 'Tagen'}`;
|
||||
} else if (diffHours > 0) {
|
||||
relativeTimeEn = `${diffHours} ${diffHours === 1 ? 'hour' : 'hours'} ago`;
|
||||
relativeTimeDe = `vor ${diffHours} ${diffHours === 1 ? 'Stunde' : 'Stunden'}`;
|
||||
} else if (diffMins > 0) {
|
||||
relativeTimeEn = `${diffMins} ${diffMins === 1 ? 'minute' : 'minutes'} ago`;
|
||||
relativeTimeDe = `vor ${diffMins} ${diffMins === 1 ? 'Minute' : 'Minuten'}`;
|
||||
} else {
|
||||
relativeTimeEn = 'just now';
|
||||
relativeTimeDe = 'gerade eben';
|
||||
}
|
||||
|
||||
const badgeEn = document.querySelector('.version-text-en');
|
||||
const badgeDe = document.querySelector('.version-text-de');
|
||||
|
||||
if (badgeEn) {
|
||||
badgeEn.textContent = `v${version} OUT NOW • ${relativeTimeEn}`;
|
||||
}
|
||||
if (badgeDe) {
|
||||
badgeDe.textContent = `v${version} JETZT VERFÜGBAR • ${relativeTimeDe}`;
|
||||
}
|
||||
|
||||
// Update Schema.org structured data dynamically
|
||||
const schemaScript = document.getElementById('schema-software');
|
||||
if (schemaScript) {
|
||||
try {
|
||||
const schema = JSON.parse(schemaScript.textContent);
|
||||
schema.softwareVersion = version;
|
||||
schemaScript.textContent = JSON.stringify(schema, null, 2);
|
||||
} catch (err) {
|
||||
console.warn('Failed to dynamically update schema version:', err);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch dynamic version info:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Extension Mockup Tab Switcher
|
||||
const mockTabs = document.querySelectorAll('.mock-tab');
|
||||
const mockScreens = document.querySelectorAll('.mock-screen');
|
||||
|
||||
mockTabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
mockTabs.forEach(t => t.classList.remove('active'));
|
||||
mockScreens.forEach(s => s.classList.remove('active'));
|
||||
|
||||
tab.classList.add('active');
|
||||
const targetId = tab.getAttribute('data-target');
|
||||
const targetScreen = document.getElementById(targetId);
|
||||
if (targetScreen) {
|
||||
targetScreen.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Terminal Tab Switcher
|
||||
const termTabBtns = document.querySelectorAll('.terminal-tab-btn');
|
||||
const termPanes = document.querySelectorAll('.terminal-pane');
|
||||
|
||||
termTabBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
termTabBtns.forEach(b => b.classList.remove('active'));
|
||||
termPanes.forEach(p => p.classList.remove('active'));
|
||||
|
||||
btn.classList.add('active');
|
||||
const targetPaneId = btn.getAttribute('data-tab');
|
||||
const targetPane = document.getElementById(targetPaneId);
|
||||
if (targetPane) {
|
||||
targetPane.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Terminal Clipboard Copy
|
||||
const copyBtn = document.querySelector('.terminal-copy-btn');
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', () => {
|
||||
const activePane = document.querySelector('.terminal-pane.active');
|
||||
if (!activePane) return;
|
||||
const codeElement = activePane.querySelector('code');
|
||||
if (!codeElement) return;
|
||||
|
||||
const textToCopy = codeElement.innerText || codeElement.textContent;
|
||||
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
const isDE = document.documentElement.classList.contains('lang-de');
|
||||
const originalHTML = copyBtn.innerHTML;
|
||||
|
||||
copyBtn.innerHTML = isDE ? '✅ Kopiert!' : '✅ Copied!';
|
||||
copyBtn.disabled = true;
|
||||
|
||||
setTimeout(() => {
|
||||
copyBtn.innerHTML = originalHTML;
|
||||
copyBtn.disabled = false;
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy text: ', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Mobile Hamburger Menu Toggle
|
||||
const hamburger = document.querySelector('.hamburger');
|
||||
const navLinks = document.querySelector('#primary-nav');
|
||||
if (hamburger && navLinks) {
|
||||
hamburger.setAttribute('aria-expanded', 'false');
|
||||
|
||||
const open = () => {
|
||||
navLinks.classList.add('open');
|
||||
hamburger.setAttribute('aria-expanded', 'true');
|
||||
document.addEventListener('keydown', onEsc);
|
||||
};
|
||||
const close = () => {
|
||||
navLinks.classList.remove('open');
|
||||
hamburger.setAttribute('aria-expanded', 'false');
|
||||
document.removeEventListener('keydown', onEsc);
|
||||
};
|
||||
const toggle = () => navLinks.classList.contains('open') ? close() : open();
|
||||
const onEsc = (e) => { if (e.key === 'Escape') close(); };
|
||||
|
||||
hamburger.addEventListener('click', toggle);
|
||||
navLinks.querySelectorAll('a').forEach(a => a.addEventListener('click', close));
|
||||
}
|
||||
|
||||
// Dynamically localize home links on root dynamic pages (impressum, datenschutz, join)
|
||||
const localizeHomeLinks = () => {
|
||||
const activeLang = localStorage.getItem('koala_lang') || (navigator.language.startsWith('de') ? 'de' : 'en');
|
||||
const path = window.location.pathname;
|
||||
const pathSegments = path.split('/');
|
||||
const isSubdir = pathSegments.some(seg => ['de', 'fr', 'es', 'pt-BR', 'ru'].includes(seg));
|
||||
|
||||
// Only need to do this dynamic rewrite if we are NOT already inside a localized subdirectory
|
||||
if (!isSubdir) {
|
||||
const homeLinks = document.querySelectorAll('a[href="./"], a[href="de/"], a[href="fr/"], a[href="es/"], a[href="pt-BR/"], a[href="ru/"]');
|
||||
homeLinks.forEach(link => {
|
||||
link.href = (activeLang === 'en') ? './' : `${activeLang}/`;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Modern Language Selector Navigation and State Toggling
|
||||
const handleLanguageChange = (e) => {
|
||||
const select = e.currentTarget;
|
||||
const newLang = select.value;
|
||||
const path = window.location.pathname;
|
||||
|
||||
// Save the user's preference
|
||||
localStorage.setItem('koala_lang', newLang);
|
||||
|
||||
// Determine if we are on a static landing page versus a dynamic utility page
|
||||
const isLegalOrJoin = path.includes('impressum') || path.includes('datenschutz') || path.includes('join');
|
||||
const isIndex = !isLegalOrJoin;
|
||||
|
||||
if (isIndex) {
|
||||
// Static navigation: Route to correct subdirectory
|
||||
const pathSegments = path.split('/');
|
||||
const isSubdir = pathSegments.some(seg => ['de', 'fr', 'es', 'pt-BR', 'ru'].includes(seg));
|
||||
|
||||
let targetPath;
|
||||
if (newLang === 'en') {
|
||||
if (isSubdir) {
|
||||
targetPath = '../';
|
||||
} else {
|
||||
targetPath = './';
|
||||
}
|
||||
} else {
|
||||
if (isSubdir) {
|
||||
// Switching from one language subdirectory to another (e.g., /de/ to /fr/)
|
||||
targetPath = '../' + newLang + '/';
|
||||
} else {
|
||||
// Switching from root (English) to a language subdirectory (e.g., / to /fr/)
|
||||
targetPath = newLang + '/';
|
||||
}
|
||||
}
|
||||
|
||||
window.location.href = targetPath;
|
||||
} else {
|
||||
// Dynamic page: Toggle classes and update elements dynamically without navigating away
|
||||
const html = document.documentElement;
|
||||
html.classList.remove('lang-en', 'lang-de', 'lang-fr', 'lang-es', 'lang-pt-br', 'lang-ru');
|
||||
|
||||
// Fallback dynamic pages to 'en' if 'de' is not chosen (since fr/es markup is not present)
|
||||
const activeDisplayLang = (newLang === 'de') ? 'de' : 'en';
|
||||
html.classList.add('lang-' + activeDisplayLang);
|
||||
html.lang = activeDisplayLang;
|
||||
|
||||
// Sync all selects on the page to the new value
|
||||
document.querySelectorAll('.lang-dropdown').forEach(sel => {
|
||||
sel.value = newLang;
|
||||
});
|
||||
|
||||
// Update titles dynamically
|
||||
const isJoin = path.includes('join');
|
||||
if (isJoin) {
|
||||
const titles = { en: 'Join Room | KoalaSync', de: 'Raum beitreten | KoalaSync' };
|
||||
document.title = titles[activeDisplayLang] || titles.en;
|
||||
}
|
||||
|
||||
// Localize home links dynamically
|
||||
localizeHomeLinks();
|
||||
}
|
||||
};
|
||||
|
||||
// Register change event listener for the dropdowns
|
||||
document.querySelectorAll('.lang-dropdown').forEach(select => {
|
||||
select.addEventListener('change', handleLanguageChange);
|
||||
});
|
||||
|
||||
// Initialize language select elements to show the current preferred language
|
||||
const initLanguageSelectorValue = () => {
|
||||
const savedLang = localStorage.getItem('koala_lang');
|
||||
const browserLang = navigator.language.startsWith('de') ? 'de' : 'en';
|
||||
const activePref = savedLang || browserLang;
|
||||
|
||||
document.querySelectorAll('.lang-dropdown').forEach(select => {
|
||||
select.value = activePref;
|
||||
});
|
||||
};
|
||||
|
||||
// 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.textContent = `${user}@${domain}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Automated Store/Local Badge Linking based on User-Agent
|
||||
const detectBrowserAndElevateBadge = () => {
|
||||
const isFirefox = navigator.userAgent.includes('Firefox');
|
||||
const isChrome = navigator.userAgent.includes('Chrome') || navigator.userAgent.includes('Chromium');
|
||||
const chromeBtns = document.querySelectorAll('.btn-primary');
|
||||
const firefoxBtns = document.querySelectorAll('.btn-firefox');
|
||||
|
||||
if (isFirefox && chromeBtns.length > 0 && firefoxBtns.length > 0) {
|
||||
// User is on Firefox: Elevate Firefox button to primary, make Chrome secondary
|
||||
chromeBtns.forEach(btn => {
|
||||
btn.classList.remove('btn-primary');
|
||||
btn.classList.add('btn-secondary');
|
||||
});
|
||||
|
||||
firefoxBtns.forEach(btn => {
|
||||
// Put Firefox first in visual order
|
||||
btn.style.order = '-1';
|
||||
|
||||
// Add subtle focus scale effect
|
||||
btn.style.transform = 'scale(1.05)';
|
||||
btn.addEventListener('mouseleave', () => {
|
||||
btn.style.transform = 'scale(1)';
|
||||
});
|
||||
btn.addEventListener('mouseenter', () => {
|
||||
btn.style.transform = 'scale(1.05) translateY(-2px)';
|
||||
});
|
||||
});
|
||||
} else if (isChrome && chromeBtns.length > 0 && firefoxBtns.length > 0) {
|
||||
// User is on Chrome: Make Firefox secondary
|
||||
firefoxBtns.forEach(btn => {
|
||||
btn.classList.remove('btn-firefox');
|
||||
btn.classList.add('btn-secondary');
|
||||
btn.style.color = 'var(--text)';
|
||||
btn.style.background = 'var(--card)';
|
||||
btn.style.border = '1px solid var(--glass-border)';
|
||||
btn.style.boxShadow = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Step 1 Landing Page Download Badges & Nav Badge
|
||||
setTimeout(() => {
|
||||
const isInstalled = document.documentElement.dataset.koalasyncInstalled === 'true';
|
||||
|
||||
// Nav Badge Logic
|
||||
const navBadge = document.getElementById('nav-extension-status');
|
||||
if (isInstalled && navBadge) {
|
||||
navBadge.style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
const illusChrome = document.querySelectorAll('.illus-store-btn.chrome');
|
||||
const illusFirefox = document.querySelectorAll('.illus-store-btn.firefox');
|
||||
|
||||
if (isFirefox && illusFirefox.length > 0) {
|
||||
illusFirefox.forEach(btn => {
|
||||
btn.style.order = '-1';
|
||||
if (!isInstalled) {
|
||||
btn.classList.add('install-breathe');
|
||||
btn.style.cursor = 'pointer';
|
||||
btn.onclick = () => window.open('https://addons.mozilla.org/de/firefox/addon/koalasync/', '_blank');
|
||||
}
|
||||
});
|
||||
illusChrome.forEach(btn => {
|
||||
btn.style.opacity = '0.5';
|
||||
btn.style.transform = 'scale(0.95)';
|
||||
});
|
||||
} else if (isChrome && illusChrome.length > 0) {
|
||||
illusChrome.forEach(btn => {
|
||||
btn.style.order = '-1';
|
||||
if (!isInstalled) {
|
||||
btn.classList.add('install-breathe');
|
||||
btn.style.cursor = 'pointer';
|
||||
btn.onclick = () => window.open('https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc', '_blank');
|
||||
}
|
||||
});
|
||||
illusFirefox.forEach(btn => {
|
||||
btn.style.opacity = '0.5';
|
||||
btn.style.transform = 'scale(0.95)';
|
||||
});
|
||||
}
|
||||
|
||||
// Pulse main hero CTA buttons via Web Animations API
|
||||
// (avoids CSS transition/inline-style conflicts from mouse handlers)
|
||||
if (!isInstalled) {
|
||||
const heroBtns = document.querySelectorAll(isFirefox ? '.btn-firefox' : (isChrome ? '.btn-primary' : null));
|
||||
if (heroBtns && heroBtns.length > 0) {
|
||||
heroBtns.forEach(btn => {
|
||||
const isFF = btn.classList.contains('btn-firefox');
|
||||
const glowColor = isFF ? 'rgba(249, 115, 22, ' : 'rgba(99, 102, 241, ';
|
||||
btn.animate([
|
||||
{ transform: 'scale(1)', boxShadow: `0 0 15px ${glowColor}0.2)` },
|
||||
{ transform: 'scale(1.05)', boxShadow: `0 0 25px ${glowColor}0.5)` },
|
||||
{ transform: 'scale(1)', boxShadow: `0 0 15px ${glowColor}0.2)` }
|
||||
], {
|
||||
duration: 2500,
|
||||
iterations: Infinity,
|
||||
easing: 'ease-in-out'
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 600);
|
||||
};
|
||||
|
||||
detectBrowserAndElevateBadge();
|
||||
checkInvite();
|
||||
updateDynamicVersion();
|
||||
localizeHomeLinks();
|
||||
initLanguageSelectorValue();
|
||||
});
|
||||
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 5.1 KiB |