Compare commits

..

46 Commits

Author SHA1 Message Date
Timo 8050748e61 fix(extension): harden chat and target tab lifecycle 2026-07-31 09:48:22 +02:00
Timo 0b6ae803c8 feat: complete docked chat interactions 2026-07-29 22:31:40 +02:00
KoalaDev 172073896f fix: prevent language selector text clipping 2026-07-28 07:46:07 +02:00
KoalaDev ffd629a522 fix: remove unsupported legacy redirects 2026-07-28 07:35:46 +02:00
KoalaDev 2c5e2a7cc3 feat: expand help center and harden website SEO 2026-07-28 07:29:41 +02:00
KoalaDev fc005c0c88 docs: add Help Center and Edge store badge 2026-07-28 04:04:33 +02:00
KoalaDev f97885cf8d docs: render Edge icon in store badges 2026-07-28 03:33:07 +02:00
KoalaDev daa513bac5 docs: add Microsoft Edge store links 2026-07-28 03:26:23 +02:00
GitHub Action b3f9693d34 chore(release): update versions to v3.0.1 [skip ci] 2026-07-26 03:56:23 +00:00
Timo 53befe8bad merge: Google Drive and chat fixes for v3.0.1 2026-07-26 05:54:57 +02:00
Timo 750b47d32a fix(i18n): correct website and extension copy 2026-07-26 05:53:57 +02:00
Timo 03710cec33 docs(comparisons): reflect encrypted text chat 2026-07-26 05:06:03 +02:00
Timo 1b97151e69 docs(privacy): document encrypted room chat 2026-07-26 04:32:06 +02:00
Timo 63db02211b fix(chat): harden overlay interactions and notifications 2026-07-26 04:14:25 +02:00
GitHub Action 90a58eb507 chore(release): update versions to v3.0.0 [skip ci] 2026-07-26 00:25:06 +00:00
KoalaDev 72113b6504 Merge pull request #32 from Shik3i/agent/update-tooling-node24
Prepare v3.0.0: encrypted text chat and runtime updates
2026-07-26 02:24:12 +02:00
Timo a0954079a1 harden chat settings and server image 2026-07-26 02:22:07 +02:00
Timo 9251ae6aff add dedicated chat settings 2026-07-26 02:12:28 +02:00
Timo 511e042b0b update tooling and server runtime 2026-07-26 01:58:23 +02:00
KoalaDev 372c384a19 Merge pull request #31 from Shik3i/agent/dependabot-security-updates
Fix Dependabot build dependency alerts
2026-07-26 01:32:41 +02:00
Timo 79715046a8 fix npm 11 lockfile portability 2026-07-26 01:23:07 +02:00
Timo 2ae79386a0 fix vulnerable build dependencies 2026-07-26 01:18:24 +02:00
Timo 883e49433e Merge branch 'feature/textchat' 2026-07-26 01:04:38 +02:00
Timo 59135b319e Merge remote-tracking branch 'origin/main' into feature/textchat
# Conflicts:
#	website/build.cjs
2026-07-26 01:03:27 +02:00
Timo ae2238e3bc docs: plan modern chat for v3.0 2026-07-26 01:02:33 +02:00
Timo 77790a279c fix(chat): preserve mixed-version compatibility 2026-07-26 01:02:20 +02:00
KoalaDev 2df048621b Centralize KoalaStuff legal notice 2026-07-23 03:04:20 +02:00
KoalaDev 17c3ea295a Centralize KoalaStuff legal notice 2026-07-23 02:59:58 +02:00
Timo 5d3cee4271 fix: update sitemap dates for dirty sources 2026-07-19 21:41:45 +02:00
Timo 1149de7ab7 fix: derive sitemap dates from page history 2026-07-19 21:33:53 +02:00
Timo 4882e058f1 fix: keep support SEO and sitemap metadata accurate 2026-07-19 21:21:49 +02:00
Timo 8d7085c97e fix: hide access banner on smaller screens 2026-07-19 21:10:50 +02:00
Timo 630869260b ci: publish text chat server beta 2026-07-19 20:43:11 +02:00
Timo e9d735cd39 Merge remote-tracking branch 'origin/main' into feature/textchat
# Conflicts:
#	extension/background.js
#	extension/popup.js
2026-07-19 20:32:24 +02:00
Timo aee3588f43 Document v2.6.4 release 2026-07-16 12:01:21 +02:00
GitHub Action 7f898e6eb4 chore(release): update versions to v2.6.4 [skip ci] 2026-07-16 09:51:25 +00:00
Timo d2d4f5f3ea Localize extension store metadata 2026-07-16 11:50:21 +02:00
GitHub Action 6e625e3a38 chore(release): update versions to v2.6.3 [skip ci] 2026-07-15 11:37:32 +00:00
KoalaDev 354500873c fix: keep llms release version in sync 2026-07-15 10:05:26 +02:00
KoalaDev 9e0d294758 fix(chat): harden client state and messaging 2026-07-15 08:40:27 +02:00
KoalaDev 36c4c492d3 fix(chat): preserve detached overlay geometry 2026-07-15 07:31:27 +02:00
KoalaDev e9dc299fc9 feat(chat): add in-page overlay 2026-07-15 07:23:02 +02:00
KoalaDev ee0d68d8d3 feat(chat): add client encryption and key lifecycle 2026-07-15 07:10:32 +02:00
KoalaDev f2eee6dc41 feat(chat): add stateless ciphertext relay 2026-07-15 07:02:08 +02:00
KoalaDev 8fee98834c feat(chat): add encrypted invite contract 2026-07-15 06:58:22 +02:00
KoalaDev 771da1cf82 docs: add E2E chat feature brief
Design brief for the in-page E2E encrypted chat: threat model, key
derivation via URL fragment, #j2 link format, backward compatibility
with old extensions, and the constraints the discarded feature/soonTMChat
branch got wrong.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 06:25:52 +02:00
119 changed files with 8009 additions and 3169 deletions
+25 -4
View File
@@ -6,9 +6,9 @@ name: Beta Server Image
#
# Tags produced (on ghcr.io/<owner>/<repo>):
# - beta moving channel pointer to the newest build
# - <branch-slug> e.g. feature-host-control-mode
# - <branch-slug> e.g. feature-textchat
# - sha-<short-commit> immutable, pin to an exact build
# - <custom> only on manual run, e.g. hostcontrol01
# - <custom> only on manual run, e.g. chatbeta01
# Never ':latest' (flavor: latest=false).
#
# Remove this workflow once the feature is merged & released the normal way.
@@ -16,11 +16,11 @@ name: Beta Server Image
on:
push:
branches:
- feature/host-control-mode
- feature/textchat
workflow_dispatch:
inputs:
tag:
description: 'Extra immutable tag for this build (e.g. hostcontrol01). Optional.'
description: 'Extra immutable tag for this build (e.g. chatbeta01). Optional.'
required: false
default: ''
@@ -37,6 +37,27 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: |
package-lock.json
server/package-lock.json
- name: Install root dependencies
run: npm ci
- name: Install server dependencies
run: npm ci
working-directory: server
- name: Run verification suite
run: npm run verify
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
+2
View File
@@ -23,6 +23,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v6
+15 -2
View File
@@ -72,6 +72,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v6
@@ -109,7 +111,11 @@ jobs:
sed -i "s/\"softwareVersion\": \".*\"/\"softwareVersion\": \"$VERSION\"/" website/template.html
echo " ✓ website/template.html -> softwareVersion $VERSION"
# 6. README.md — version badge & banner
# 6. website/llms.txt — machine-readable release metadata
sed -i "s/Current website release: .*/Current website release: $VERSION/" website/llms.txt
echo " ✓ website/llms.txt -> $VERSION"
# 7. README.md — version badge & banner
sed -i "s|Release-v[0-9]\+\.[0-9]\+\.[0-9]\+-blue|Release-v$VERSION-blue|g" README.md
sed -i "s/New v[0-9]\+\.[0-9]\+\.[0-9]\+ Release/New v$VERSION Release/g" README.md
echo " ✓ README.md -> v$VERSION"
@@ -120,7 +126,7 @@ jobs:
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 website/template.html README.md
git add extension/manifest.base.json shared/constants.js package.json website/version.json website/template.html website/llms.txt README.md
git commit -m "chore(release): update versions to $GITHUB_REF_NAME [skip ci]" || echo "No changes to commit"
git push origin HEAD:main
env:
@@ -131,6 +137,12 @@ jobs:
npm ci
npm run build:extension
- name: Generate extension checksums
run: |
cd dist
sha256sum koalasync-chrome.zip koalasync-firefox.zip > SHA256SUMS
cat SHA256SUMS
- name: Generate artifact attestation for extensions
uses: actions/attest@v4
with:
@@ -152,6 +164,7 @@ jobs:
files: |
dist/koalasync-chrome.zip
dist/koalasync-firefox.zip
dist/SHA256SUMS
name: Release ${{ github.ref_name }}
generate_release_notes: true
draft: false
+1 -1
View File
@@ -22,7 +22,7 @@ Please note that by participating in this project, you agree to abide by our [Co
### Prerequisites
- **Node.js** v18+
- **Node.js** v20.9+
- **Docker** (for local relay server testing)
### Quick Start
+13 -4
View File
@@ -6,15 +6,16 @@
<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/badge/Release-v2.6.3-blue?logo=github" alt="GitHub release"></a>
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v3.0.1-blue?logo=github" alt="GitHub release"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-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>
<a href="https://microsoftedge.microsoft.com/addons/detail/koalasync/gefnlpeoagcjfbmmfjjildkmofekbibg"><img src="https://img.shields.io/badge/Edge-Download-0078D7?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48IS0tISBGb250IEF3ZXNvbWUgRnJlZSA3LjMuMSBieSBAZm9udGF3ZXNvbWUgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbSBMaWNlbnNlIC0gaHR0cHM6Ly9mb250YXdlc29tZS5jb20vbGljZW5zZS9mcmVlIChJY29uczogQ0MgQlkgNC4wLCBGb250czogU0lMIE9GTCAxLjEsIENvZGU6IE1JVCBMaWNlbnNlKSBDb3B5cmlnaHQgMjAyNiBGb250aWNvbnMsIEluYy4gLS0%2BPHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEyMC4xIDM3LjRjNDEtMjUuMiA4Ny42LTM4LjIgMTM0LjktMzcuNCAxNjggMCAyNTcgMTIzLjggMjU3IDIxOS41LS4xIDMyLjctMTMgNjMuOS0zNS45IDg3LjItMjIuOSAyMy4yLTU0IDM2LjUtODYuNyAzNy03NS4yIDAtOTEuNS0yMy4xLTkxLjUtMzIgMC0zLjggMS4yLTYuMiA0LjgtOS40bDEtMS4yIC40LTEuNmMxMC41LTExLjUgMTUuOS0yNi4yIDE1LjktNDEuNiAwLTc4LjctODIuMi0xNDIuNy0xODQtMTQyLjctMzcuNS0uMy03NC41IDguOS0xMDcuNSAyNi45IDI3LTU3LjUgODIuNy05Ny42IDkxLjMtMTAzLjggLjgtLjYgLjMtLjggLjMtLjh6bTE1LjYgMzE4LjFjLTEuNCAzMCA0LjYgNjAgMTYuNCA4Ny4yIDEzLjYgMjYuNCAzMi43IDUxIDU2LjUgNjkuMy01OS41LTExLjUtMTExLjUtNDMuOS0xNDkuNC04OS4zLTM4LjEtNDYuNC01OS4yLTEwNC4zLTU5LjItMTY0LjggMC01MS4yIDYyLjQtOTQuNCAxMzYtOTQuNCAzNi42LS42IDcyLjQgMTAuOSAxMDEuOCAzMi43bC0zLjYgMS4yYy01MS41IDE3LjYtOTguNSA5MC43LTk4LjUgMTU4LjF6TTQ2OS44IDQwMGwtLjcgLjFjLTExLjggMTguOC0yNS45IDM1LjEtNDIuMiA0OS41LTMwLjggMjgtNjguMSA0NS41LTEwOC44IDQ5LjktMTguNiAuMy0zNi44LTMuMi01My44LTExLjQtMjUuNi0xMC4zLTQ3LjEtMzAtNjEuNi01My0xNC40LTIzLjktMjEuMS01MS43LTE5LTc5LjYtLjYtMjAuMSA1LjQtNDAuMyAxNS01OC4yIDEzLjkgMzMuMSAzNy41IDYxLjMgNjcuNiA4MC44czY1LjUgMjkuNSAxMDEuNCAyOC42YzMxIC4zIDYyLjEtNi43IDkwLjItMjAuNWwxLjktLjljMy45LTIuMyA3LjctMy45IDExLjYgMCA0LjUgNC45IDEuOCA5LjItMS4yIDE0LS4yIC4yLS4zIC41LS40IC43eiIvPjwvc3ZnPg%3D%3D" alt="Microsoft Edge Add-on"></a>
</p>
<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>
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.6.3 Release!</b> — See what's changed</a></p>
<p align="center"><a href="docs/CHANGELOG.md"><b>New v3.0.1 Release!</b> — See what's changed</a></p>
### 🌟 Why KoalaSync?
@@ -46,9 +47,10 @@ The easiest and safest way to install KoalaSync is directly through the official
<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>
<a href="https://microsoftedge.microsoft.com/addons/detail/koalasync/gefnlpeoagcjfbmmfjjildkmofekbibg"><img src="https://img.shields.io/badge/Edge-Download-0078D7?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48IS0tISBGb250IEF3ZXNvbWUgRnJlZSA3LjMuMSBieSBAZm9udGF3ZXNvbWUgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbSBMaWNlbnNlIC0gaHR0cHM6Ly9mb250YXdlc29tZS5jb20vbGljZW5zZS9mcmVlIChJY29uczogQ0MgQlkgNC4wLCBGb250czogU0lMIE9GTCAxLjEsIENvZGU6IE1JVCBMaWNlbnNlKSBDb3B5cmlnaHQgMjAyNiBGb250aWNvbnMsIEluYy4gLS0%2BPHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEyMC4xIDM3LjRjNDEtMjUuMiA4Ny42LTM4LjIgMTM0LjktMzcuNCAxNjggMCAyNTcgMTIzLjggMjU3IDIxOS41LS4xIDMyLjctMTMgNjMuOS0zNS45IDg3LjItMjIuOSAyMy4yLTU0IDM2LjUtODYuNyAzNy03NS4yIDAtOTEuNS0yMy4xLTkxLjUtMzIgMC0zLjggMS4yLTYuMiA0LjgtOS40bDEtMS4yIC40LTEuNmMxMC41LTExLjUgMTUuOS0yNi4yIDE1LjktNDEuNiAwLTc4LjctODIuMi0xNDIuNy0xODQtMTQyLjctMzcuNS0uMy03NC41IDguOS0xMDcuNSAyNi45IDI3LTU3LjUgODIuNy05Ny42IDkxLjMtMTAzLjggLjgtLjYgLjMtLjggLjMtLjh6bTE1LjYgMzE4LjFjLTEuNCAzMCA0LjYgNjAgMTYuNCA4Ny4yIDEzLjYgMjYuNCAzMi43IDUxIDU2LjUgNjkuMy01OS41LTExLjUtMTExLjUtNDMuOS0xNDkuNC04OS4zLTM4LjEtNDYuNC01OS4yLTEwNC4zLTU5LjItMTY0LjggMC01MS4yIDYyLjQtOTQuNCAxMzYtOTQuNCAzNi42LS42IDcyLjQgMTAuOSAxMDEuOCAzMi43bC0zLjYgMS4yYy01MS41IDE3LjYtOTguNSA5MC43LTk4LjUgMTU4LjF6TTQ2OS44IDQwMGwtLjcgLjFjLTExLjggMTguOC0yNS45IDM1LjEtNDIuMiA0OS41LTMwLjggMjgtNjguMSA0NS41LTEwOC44IDQ5LjktMTguNiAuMy0zNi44LTMuMi01My44LTExLjQtMjUuNi0xMC4zLTQ3LjEtMzAtNjEuNi01My0xNC40LTIzLjktMjEuMS01MS43LTE5LTc5LjYtLjYtMjAuMSA1LjQtNDAuMyAxNS01OC4yIDEzLjkgMzMuMSAzNy41IDYxLjMgNjcuNiA4MC44czY1LjUgMjkuNSAxMDEuNCAyOC42YzMxIC4zIDYyLjEtNi43IDkwLjItMjAuNWwxLjktLjljMy45LTIuMyA3LjctMy45IDExLjYgMCA0LjUgNC45IDEuOCA5LjItMS4yIDE0LS4yIC4yLS4zIC41LS40IC43eiIvPjwvc3ZnPg%3D%3D&style=for-the-badge" alt="Microsoft Edge 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).*
*(For manual offline installation: Download the latest `.zip` from the [Releases](https://github.com/Shik3i/KoalaSync/releases) page, extract it, and load the extracted browser package using your browser's developer or manual extension installation process.)*
**How to use:**
1. **Create a Room:** Click the Koala icon in your browser and hit `+ Create New Room`.
@@ -110,7 +112,14 @@ To verify your relay is reachable from outside, visit `https://your-domain.com`
#### Supply Chain Security (v2.2.2+)
All official release artifacts (Docker images and extension binaries) are published with signed [artifact attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations) to prove they were built from this repository's source code.
Extension ZIPs attached to GitHub Releases and Docker images published to GitHub Container Registry receive signed [artifact attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations). These attestations connect an artifact's exact digest to this repository, its release tag and commit, and the public GitHub Actions workflow that built it.
Each new GitHub Release also includes `SHA256SUMS` for a quick checksum comparison:
```bash
sha256sum --check SHA256SUMS
```
Checksums detect a changed or incomplete download. Signed provenance additionally verifies where the artifact came from and which workflow built it. Neither check guarantees that the software contains no bugs or vulnerabilities. These attestations cover the GitHub release ZIPs and GHCR images; they do not by themselves prove that browser-store packages are byte-for-byte identical.
**Verify a Docker image:**
```bash
+52
View File
@@ -4,6 +4,58 @@ All notable changes to the KoalaSync browser extension and relay server.
---
## [v3.0.2] — 2026-07-31
### Added
- **Extension: Quick reactions** — Adds six encrypted one-click reactions with a local choice between chat-only display and bounded falling reactions over the video.
- **Extension: Resilient chat activity** — Keeps a bounded per-room browser-session timeline for play, pause, seek, force-sync, join, and leave rows so overlay reinjection no longer loses them.
### Changed
- **Extension: Real dock behavior** — Anchors the collapsed Koala chat launcher to the selected side, reserves a page column in normal and fullscreen layouts, and clips viewport-bound page layers away from the open dock; detached mode remains freely movable.
- **Extension: Koala chat launcher** — Replaces the generic speech-bubble emoji with the KoalaSync extension icon plus a chat marker.
- **Extension: Reliable long chat sends** — Accepts up to 5000 Unicode code points, splits them into at most ten compatible 500-codepoint messages, waits for each relay echo before clearing text, and suppresses chat notifications while the selected video tab is focused.
### Fixed
- **Extension: Strict target-tab lifecycle** — Fully deactivates playback, audio, heartbeat, observer, seek-bridge, and chat code before changing targets, cleans up superseded injections, and limits website-bridge status delivery to the KoalaSync site instead of broadcasting to every open tab.
- **Extension: Tab switching and page restore** — Keeps ordinary tab changes idle and injection-free, makes selecting the same target idempotent, blocks delayed callbacks after teardown, and restores the existing target cleanly after Firefox back/forward-cache suspension.
## [v3.0.1] — 2026-07-26
### Added
- **Extension: Google Drive workaround** — Detects and controls videos opened on `drive.google.com` through the site-specific embedded-player path.
- **Extension: Chat event messages** — Optionally shows timestamped room and playback events in chat while keeping chat-message notifications enabled by default and event notifications disabled by default.
### Fixed
- **Extension: Chat interactions** — Prevents video-player keyboard shortcuts while typing, makes the floating chat draggable, restores left/right docking, updates unread notifications, and keeps chat timestamps visible.
- **Extension: Embedded-player selection** — Avoids selecting a stale hidden media frame when multiple nested frames are temporarily ambiguous.
- **Extension: Popup layout** — Moves the video-selection hint lower and reduces its glow.
- **Website and extension copy** — Corrects malformed translations, separates chat and playback notification labels, removes stale version references and unsupported comparison claims, and accurately documents encrypted live chat and operational logging.
## [v3.0.0] — 2026-07-26
### Added
- **Extension: Optional encrypted room chat** — Rooms now support live-only, end-to-end encrypted text messages. Chat is disabled and hidden by default and can be enabled explicitly in the extension options.
- **Extension: Persistent room chat key** — Every room generates and retains a chat key even while chat is disabled, so chat can be enabled later without creating a new room.
- **Extension: Floating player chat** — When enabled, chat is available from a floating control over the selected player and opens in an overlay without replacing the synchronized video.
- **Extension: Dedicated chat settings** — Chat now has its own settings section for enablement, left/right/free-floating placement, compact/standard/large/custom sizing, and bubble-versus-open startup behavior.
### Security
- **Ciphertext-only relay** — The relay receives and forwards encrypted message payloads without storing chat history or accepting client-supplied plaintext, sender identities, timestamps, or message IDs as authoritative.
- **Backward-compatible rollout** — Versioned `chat-v1` capabilities ensure old non-chat extensions never receive unknown chat events, while current extensions continue to work with older chatless relay versions.
### Changed
- **Build dependencies** — Updated the supported build and validation toolchain and refreshed compatible transitive dependencies.
- **Relay container runtime** — Moved the production container from end-of-life Node.js 20 to Node.js 24 LTS and made production dependency installation deterministic with `npm ci --omit=dev`.
## [v2.6.4] — 2026-07-16
### Changed
- **Extension: Localized store metadata** — Renamed the public extension title to `Watch Party - KoalaSync` and added natural, store-ready descriptions for all 15 supported locales while preserving regional language variants.
- **Extension: Locale-based manifest metadata** — The extension name and description now resolve through Chrome/Firefox `_locales` messages instead of being hardcoded in the manifest.
### Fixed
- **Build: Extension metadata validation** — Extension builds now fail clearly for invalid locale JSON, missing metadata, an incorrect extension title, or store titles and descriptions that exceed their character limits.
## [v2.6.3] — 2026-07-15
### Fixed
+105
View File
@@ -0,0 +1,105 @@
# Ephemeral end-to-end encrypted chat
KoalaSync chat is an optional, live-only text channel rendered on the selected
streaming tab. It is not a messenger and does not add chat UI to the extension
popup.
## Security boundary
- The relay receives ciphertext only and never receives the chat secret.
- The relay stores no messages in RAM or on disk and sends no backlog.
- A late joiner sees only messages sent after joining.
- Room authentication is unchanged. The room password remains separate from the
chat secret and continues to be sent to the relay during `join_room`.
- Message timing and ciphertext length remain visible to the relay. Same-room
replay is accepted by the threat model.
## Invite format and key lifecycle
New invitations use a named fragment format:
```text
#j2:r=<roomId>&p=<password>&k=<base64url-secret>[&u=<relayUrl>]
```
The room creator generates 16 random bytes and encodes them as 22 unpadded
base64url characters. URL fragments are not sent in HTTP requests. The website
parses the fragment and passes structured fields through `bridge.js` to the
extension. `chatKey` must never be included in a relay event.
Legacy `#join:` links remain supported and join normally without chat. Manual
room/password entry also joins without chat. The extension clears any prior chat
secret when joining without a key, switching rooms, or leaving.
Deployment order is website first, extension second. An old extension ignores the
additional structured `chatKey` field and continues joining normally.
## Cryptography
- Secret: 16 cryptographically random bytes.
- KDF: HKDF-SHA256 with `roomId` as salt and a fixed KoalaSync chat info label.
- Encryption: AES-256-GCM using WebCrypto.
- IV: fresh random 12-byte value for every message, prepended to the ciphertext.
- AAD: `${roomId}|${senderId}`.
- The derived `CryptoKey` is cached once per active room.
The relay stamps `id`, `senderId`, and `timestamp` on the ciphertext envelope.
Changing `senderId` causes AES-GCM authentication to fail because the receiver uses
the stamped value as AAD.
## Client policy
- Maximum plaintext length per relay message: 500 Unicode code points.
- The composer accepts up to 5000 Unicode code points and sends them sequentially
as at most ten independently encrypted `chat-v1` messages.
- Decrypted text is untrusted. Escape HTML before applying the supported limited
Markdown formatting.
- Quick reactions use the same encrypted message path as text. They remain readable
by current chat clients without adding a relay-visible reaction event.
- No read receipts and no typing indicators.
- The local message DOM is bounded; this is presentation state, not server history.
## Overlay behavior
- Default dock: right.
- Live modes: right, left, and detached.
- Detached mode is draggable and moderately resizable. Size and position are stored
per origin and clamped to the current viewport.
- Left and right modes anchor the launcher to the selected edge and reserve a
full-height page column while the panel is open. Detached mode is the only freely
movable launcher.
- Docking keeps a reserved column on narrow pages and in fullscreen instead of
silently falling back to an overlay.
- The overlay uses a Shadow DOM. Docking changes root-page or fullscreen-container
width and margins while the dock is open and clips viewport-bound page layers to
the remaining page column. The overrides stop applying when chat is detached or
closed, and the injected style element is removed when chat is destroyed.
- On `fullscreenchange`, its host moves into `document.fullscreenElement` and remains
visible.
- Six encrypted quick reactions are available from the composer. Users can keep them
in chat or additionally show a bounded falling-emoji animation over the video.
- Playback and room activity is retained in a bounded per-room browser-session
timeline so overlay reinjection does not lose command rows. It is not relay history.
- It follows all eucalyptus, cyber, and graphite light/dark theme combinations.
- Without a key, the panel stays closed and a disabled chat control explains that a
current invite link is required.
- Chat display is a local option and defaults to off. Enabling or disabling it never
deletes the room chat secret, so it can be enabled later without creating a room.
- Without the relay `chat` capability, no chat control is shown.
- System notifications are suppressed while the selected video tab and its browser
window are focused.
- A send succeeds only after the relay echoes the authenticated ciphertext back to
the sender. Unconfirmed and remaining split messages stay in the composer.
## Mixed-version rollout
- New extensions announce `chat-v1` in `join_room.clientCapabilities`.
- Old non-chat extensions omit the optional field and receive no `chat_message`
events. Their playback and room protocol remains unchanged.
- The first chat beta omitted the capability. When it sends one valid v1 chat
frame, the relay treats that socket as chat-capable for the rest of the
connection.
- New extensions accept the first beta's unversioned server `chat` capability as
well as `chat-v1`, so a server-first or extension-first rollout degrades safely.
Peer removal and role management are outside chat scope.
+60 -1
View File
@@ -11,6 +11,19 @@ the event names defined in `shared/constants.js`.
object payload.
- The relay caps incoming Socket.IO message size at 4 KB.
## Invite fragments
Current invitations use named URL-fragment fields:
```text
#j2:r=<roomId>&p=<password>&k=<base64url-chat-secret>[&u=<relayUrl>]
```
The fragment is parsed by the website and forwarded to the extension as structured
fields. `k` is client-only and must never appear in a relay payload. Legacy
`#join:<roomId>:<password>[:1:<relayUrl>]` fragments remain valid but provide no chat
secret.
## Connection Handshake
The Socket.IO handshake must include:
@@ -41,6 +54,7 @@ Payload:
"password": "string, max 128, optional",
"tabTitle": "string, max 100, optional",
"mediaTitle": "string, max 100, optional",
"clientCapabilities": ["chat-v1"],
"protocolVersion": "string, max 16"
}
```
@@ -69,13 +83,56 @@ Payload:
"hostPeerId": "string or null",
"controlMode": "everyone | host-only",
"controllers": ["peerId"],
"capabilities": ["host-control", "co-host"]
"capabilities": ["host-control", "co-host", "chat", "chat-v1"]
}
```
`room_data` is sent to the joining socket. It is not the general broadcast used
for every later room update.
## Ephemeral encrypted chat
Relays advertise chat support with `"chat-v1"` in `room_data.capabilities` and keep
the initial beta's `"chat"` flag during the transition. New clients announce
`"chat-v1"` in optional `join_room.clientCapabilities` (at most the first 16 entries
are inspected; unknown or malformed values are ignored). Old clients omit the field
and continue using the pre-chat protocol unchanged.
The relay sends `chat_message` only to sockets that announced `"chat-v1"`. As a
transition for the first chat beta, a socket that sends a valid v1 ciphertext is
marked capable for the rest of that connection. This prevents old non-chat
extensions from receiving unknown events while preserving the first beta's send
path.
### `chat_message`
Client to relay:
```json
{ "ciphertext": "<unpadded-base64url>" }
```
`ciphertext` contains a 12-byte AES-GCM IV followed by ciphertext and the 16-byte
authentication tag. The relay validates only canonical base64url and byte bounds.
It cannot inspect plaintext.
Relay to every chat-capable current room peer, including the sender:
```json
{
"id": "<server-generated UUID>",
"senderId": "<server-stamped peer ID>",
"timestamp": 1710000000000,
"ciphertext": "<unpadded-base64url>"
}
```
Client-provided `id`, `senderId`, `timestamp`, or plaintext fields are discarded.
The relay keeps no message collection and `room_data` contains no chat history.
Messages are limited to 10 per socket per 10 seconds in addition to the global event
budget. There are no typing, read-receipt, history, or chat-specific peer-management
events.
## Room Leave
### `leave_room` (client -> server)
@@ -365,5 +422,7 @@ If sender and target are still in the same room, the relay emits:
- `host-control`
- `co-host`
- `chat`
- `chat-v1`
Clients should treat a missing or unknown capabilities list as unsupported.
+78 -11
View File
@@ -30,6 +30,84 @@
*Prioritized for upcoming phases.*
### v3.0 — Modern Room Chat
- **Release date:** Not scheduled
- **Priority:** P1
- **Category:** Social / Communication / Privacy
- **Status:** Planned. This is a roadmap target, not a release announcement.
- **Existing foundation:** Opt-in, live-only end-to-end encrypted room chat; a floating
Koala launcher over the selected player opens a dockable, detachable, resizable
overlay. Quick reactions can stay in chat or fall over the video, and a bounded
browser-session activity timeline survives overlay reinjection. The relay stores no
chat history and mixed extension versions use capability-gated delivery.
#### Core experience
- Floating bubble with an accessible unread badge (`1`, `2`, `3`, …, `99+`), cleared
only when the chat is actually viewed.
- Subtle new-message pulse with reduced-motion support; no animation or sound while
muted or during Do Not Disturb.
- Drag-and-snap bubble positioning, remembered per site, with reset-to-default.
- Dock left/right, detached overlay, compact mode, fullscreen-safe placement, responsive
narrow-player layout, and keyboard-only operation.
- Per-room mute, mention-only mode, notification sound controls, browser notifications,
and a visible mute state.
- Unread state survives overlay close/reopen and tab focus changes, but room messages
remain memory-only unless a future storage option is explicitly enabled.
#### Messaging
- Replies with quoted context, per-message reaction counters, a full emoji picker,
mentions, and typing indicators.
- Edit and delete own messages with clear local tombstones; no silent mutation.
- Delivery states (`sending`, `sent`, `failed`, `retry`) with idempotent retries and
duplicate suppression after reconnects.
- Message timestamps, date separators, jump-to-latest, unread divider, search within
the current live session, copy, and selectable text.
- Link detection with safe external opening, explicit scheme allowlist, and no automatic
previews or remote-media loading.
- Optional image/GIF/file sharing only after encrypted size limits, malware-risk UX,
relay bandwidth limits, and privacy behavior are designed and tested.
#### Presence, safety, and control
- Online/reconnecting status, join/leave events, and room-member presence without
exposing IP addresses or cross-room identity.
- Local mute/block, host moderation controls, slow mode, spam limits, and configurable
maximum message length.
- Optional read receipts and activity indicators default off; never infer or expose
viewing behavior without explicit consent.
- Clear live-only/no-history explanation, encrypted-chat indicator, room-key fingerprint,
and key-rotation/rejoin UX.
#### Reliability and compatibility
- Versioned chat capabilities for every new wire feature; unknown fields/events remain
harmless to older extensions and old non-chat clients receive no chat traffic.
- Server-first and extension-first rollout tests, including current, first-chat-beta,
old non-chat, reconnect, duplicate-frame, and malformed-capability clients.
- Bounded queues, payloads, unread counters, typing events, reactions, and attachment
metadata; backpressure and rate limits on both client and relay.
- Real Chrome and Firefox extension E2E coverage for bubble, unread count, overlay,
fullscreen, reconnect, and mixed-version rooms before v3.0 can ship.
#### Accessibility and localization
- Screen-reader announcements that do not read every busy-room message by default.
- Complete focus management, logical tab order, Escape behavior, high contrast,
zoom/reflow, reduced motion, and touch target coverage.
- All chat UI, notification text, moderation states, and errors localized across every
supported locale before release.
#### Explicit non-goals for the first v3.0 release
- No server-readable plaintext.
- No mandatory accounts, public room directory, advertising, tracking, or presence
across rooms.
- No unencrypted server-side history. Any future encrypted history requires a separate
threat model, retention controls, export/delete behavior, and explicit opt-in.
### 2. Invite link with target URL for auto-redirect
- **Priority:** P2
@@ -49,17 +127,6 @@
*Ideas and feature requests under evaluation.*
### In-room chat overlay (like TeleParty)
- **Priority:** P3
- **Category:** Social / Communication
- **Background:** A collapsible chat panel to the right of the video (or as an overlay) allowing text-based communication with everyone in the room.
- **Why backlog (still uncertain):**
- **Use case:** No strong personal need — with chat, latency matters less than with voice; async communication tolerates a few seconds of delay.
- **Complexity:** Relatively large feature (UI + message persistence + possibly history).
- **Legal/moderation:** Unclear what moderation requirements would apply if users can exchange chat messages. Could be relevant depending on jurisdiction.
- **Status:** Under evaluation, may come later.
### Cross-frame video detection and control
- **Priority:** P3
+346
View File
@@ -0,0 +1,346 @@
# E2E-verschlüsselter In-Page-Chat für KoalaSync
## Auftrag
Baue einen privacy-first, Ende-zu-Ende-verschlüsselten Text-Chat, der direkt auf der
Streaming-Seite neben dem Video sitzt. Neuer Branch von `main`, Vorschlag
`feature/chat-e2e`.
### Harte Anforderungen
1. **Der Server darf Nachrichten nie lesen können.** Schlüssel nur clientseitig, der
Relay sieht ausschließlich Ciphertext.
2. **Der Server speichert keine Nachrichten.** Reines Live-Relay, kein RAM-Backlog,
keine History. Wer zu spät kommt, verpasst den bisherigen Chat. Das ist gewollt.
3. **Chat läuft auf der Streaming-Seite**, nicht im Extension-Popup.
4. **Themes werden respektiert** (3 Paletten x light/dark).
5. **Keine Read Receipts.** Bewusst gestrichen.
6. **Volle Abwärtskompatibilität**: alte Extensions müssen weiter normal joinen können.
### Bedrohungsmodell (bestimmt alle Krypto-Entscheidungen)
Das hier ist eine Video-Sync-Extension mit optionalem, flüchtigem Chat, **keine
sicherheitskritische Messenger-App**. Ziel ist: ein bösartiger Betreiber eines
Custom-Relays soll nicht einfach mitlesen können. Ob etwas mit sehr viel Aufwand
theoretisch knackbar wäre, ist egal.
Daraus folgt, und das ist bindend:
- **Usability und Performance haben Vorrang** vor kryptografischer Maximalhärte.
- Raum-Passwörter sind kurz, zufällig generiert (z.B. `0XUK3C`) und ständig neu. Wer
eines knackt, kann trollen (`play`/`pause` senden). Nervig, nicht kritisch. Deshalb
nutzt der Server bewusst simples SHA256/HMAC statt bcrypt. **Diese Linie beibehalten,
nicht "verbessern".**
- Der globale `SERVER_SALT` ist bekannt und kein Issue. Räume sind flüchtig, es gibt
keine Datenbank.
### Explizite Nicht-Ziele
- Kein Chat-Tab im Popup. Nicht als Fallback, nicht übergangsweise.
- Keine serverseitige `chatHistory` in irgendeiner Form.
- **Die Auth nicht anfassen.** Das Klartext-Passwort an den Relay ist bewusst so und
wird nicht umgebaut (Begründung unten).
- `website/` Theme-Redesign auf `main` nicht anfassen.
---
## Krypto-Design (entschieden, nicht neu diskutieren)
### Woher der Schlüssel kommt
Das Raum-Passwort ist **unbrauchbar** als Schlüsselmaterial: der Client sendet es im
Klartext an den Relay (`join_room` -> `payload.password`, `server/index.js:349`), der
Server HMACt es erst selbst (`hashPassword`, Zeile 52-55). Der Server könnte jeden
daraus abgeleiteten Schlüssel mitberechnen.
Es zu ändern würde auch nichts bringen: Raum-Passwörter haben ~31 Bit Entropie
(6 Zeichen A-Z0-9). Ein bösartiger Server könnte sie offline durchprobieren und den
Chat-Schlüssel ableiten.
**Lösung: ein eigenes, zufälliges Secret im URL-Fragment.** Fragmente werden nie an
einen Server gesendet, und das Secret hat volle Entropie per Konstruktion. Der
Einladungslink transportiert bereits heute das Passwort im Fragment, der Mechanismus
existiert also schon.
### Primitive
- **16 zufällige Bytes** (128 Bit), base64url, 22 Zeichen. Beispiel:
`R5Ti1nxp0crfAFHf3gVncw`
- Ableitung: **HKDF-SHA256**(secret, salt=roomId) -> AES-256-GCM-Key.
**Kein PBKDF2, kein Stretching.** Slow KDFs existieren nur, um schwache
Menschen-Passwörter zu strecken. Das Secret ist zufällig mit voller Entropie, HKDF
ist ein einziger HMAC (Mikrosekunden). Das ist dieselbe Logik wie sha256-statt-bcrypt
beim Server.
- **Key genau einmal pro Raum ableiten und den `CryptoKey` cachen.** Nicht pro
Nachricht neu ableiten oder importieren.
- Pro Nachricht: **zufälliger 12-Byte-IV**, dem Ciphertext vorangestellt.
- Alles über **WebCrypto**, keine Fremdabhängigkeit.
### AAD: senderId gegen Umetikettierung binden (Pflicht)
`senderId` wird vom Server gestempelt und liegt damit **außerhalb** des Ciphertexts.
Ohne Gegenmaßnahme kann ein bösartiger Relay Alices Ciphertext als Bob weiterreichen.
Das ist exakt der Angreifer, gegen den dieses Feature gebaut wird.
Deshalb: **AES-GCM mit AAD** verschlüsseln.
```
AAD = `${roomId}|${senderId}` // senderId = eigene peerId beim Verschlüsseln,
// envelope.senderId beim Entschlüsseln
```
Etikettiert der Server um, schlägt die Auth-Tag-Prüfung fehl und die Nachricht wird
verworfen. Kostet null Performance und bindet die Nachricht zusätzlich an den Raum
(kein Cross-Room-Replay).
Bewusst **nicht** abgedeckt und akzeptiert: Nachrichtenlängen und Timing bleiben für den
Server sichtbar (kein Padding). Ein Relay kann eine Nachricht innerhalb desselben Raums
erneut abspielen. Für das Bedrohungsmodell irrelevant.
### Sanitization wandert auf den Client (Pflicht)
Der Server sieht nur noch Ciphertext und **kann Text nicht mehr prüfen**. Die Bedrohung
verschwindet dadurch nicht, sie verschiebt sich: entschlüsselter Text stammt von einem
**Peer, der den Key besitzt**.
- Entschlüsselter Text ist **untrusted input**. Vor dem Rendern zwingend durch
`escapeChatHtml`/`formatChatText` (escapen, dann Markdown). Niemals rohes `innerHTML`.
- Die Längengrenze (500 Codepoints) muss der **Client** durchsetzen, vor dem
Verschlüsseln. Der Server kann nur noch Bytes zählen.
- **Bestehende Schranke beachten:** `maxHttpBufferSize: 4096` (`server/index.js:142`)
gilt global pro Socket-Nachricht. Nachgerechnet: 500 Codepoints als 4-Byte-Emoji
ergeben 2000 B Klartext, +16 B GCM-Tag +12 B IV = 2028 B, base64 = 2704 Zeichen,
socket.io-Frame = **2731 B**. Passt, Headroom 1365 B. Bei 700 Codepoints wären es
3799 B. Wer die Zeichengrenze anhebt, muss diese Rechnung neu machen, sonst reißt das
Limit.
---
## Link-Format und Abwärtskompatibilität (entschieden)
### Warum das alte Format nicht erweiterbar ist
Aktuell (`popup.js:559-563`):
```
offiziell: #join:<roomId>:<password>
custom: #join:<roomId>:<password>:1:<encodedUrl>
```
Der Extension-Parser (`popup.js:1294-1313`) nimmt `roomId` von vorne, das Paar
`(flag, url)` von hinten, und **alles dazwischen ist das Passwort** (`parts.join(':')`).
Es gibt keine Position, die er verwirft. Verifiziert durch Ausführen der echten Parser
gegen echte Links:
| Link | Alte Extension | Alte Website |
|---|---|---|
| `#join:SILENT-EAGLE-90:30PXPD:1:wss%3A%2F%2F…` (Kontrolle) | `password:"30PXPD"` OK | `serverFlag:"1"` OK |
| Key inline: `…:30PXPD:<KEY>:1:wss%3A%2F%2F…` | `password:"30PXPD:R5Ti1nxp…"` **korrumpiert** | `serverFlag:"R5Ti…"`, `serverUrl:"1"` -> **falscher Server** |
Zwei verschiedene, irreführende Fehler aus derselben Zeile. Deshalb: neues Präfix.
### Neues Format
```
#j2:r=<roomId>&p=<password>&k=<key>[&u=<encodedRelayUrl>]
```
- `URLSearchParams`, kein positionales Parsen. Behebt nebenbei einen bestehenden Bug:
heute zerschießt ein Doppelpunkt im Passwort den Website-Parser (`parts[2]`), während
der Extension-Parser (`parts.join(':')`) damit klarkommt.
- **`s=1` entfällt.** Es existierte nur wegen des positionalen Parsens. Mit benannten
Parametern gilt: `u` vorhanden bedeutet Custom Relay.
- Das Präfix darf `#join:` **nicht als Teilstring enthalten**, sonst greift
`includes('#join:')` in alten Extensions doch.
Beispiele:
```
offiziell: …/join.html#j2:r=SAPPHIRE-DUCK-49&p=0XUK3C&k=R5Ti1nxp0crfAFHf3gVncw
custom: …/join.html#j2:r=SILENT-EAGLE-90&p=30PXPD&k=R5Ti1nxp0crfAFHf3gVncw
&u=wss%3A%2F%2Fsync.shik3i.net
```
### Warum das alte Extensions nicht bricht
**Die Website ist der Hauptpfad, nicht die Extension.** `website/app.js:452-458` parst
das Fragment selbst, prüft via `document.documentElement.dataset.koalasyncInstalled`
(gesetzt von `bridge.js:9`), ob die Extension da ist, und dispatcht dann automatisch
(`app.js:515-526`, "AUTO-TRIGGER JOIN") ein `KOALASYNC_JOIN_REQUEST` mit fertig
geparsten, **strukturierten Feldern**. `bridge.js` reicht es als `WEB_JOIN_REQUEST` an
den background weiter. In diesem Pfad liest die Extension die URL nie an.
Altes `bridge.js` destrukturiert nur, was es kennt:
```js
const { roomId, password, useCustomServer, serverUrl } = e.detail;
```
Ein zusätzliches `chatKey` fällt dort stillschweigend auf den Boden.
**Daraus folgt die Deploy-Reihenfolge: Website zuerst.** Sie ist die einzige Stelle, die
das neue Format kennen muss, und sie ist sofort deploybar. Die Extension darf beliebig
hinterherhinken (Store-Review, Update-Zyklen der Nutzer).
| Kombination | Ergebnis |
|---|---|
| Neue Website + neue Ext | Join + Chat |
| Neue Website + alte Ext | Join normal, kein Chat, `chatKey` ignoriert |
| Neue Ext + alter `#join:`-Link | Join, kein Key, Chat aus. Legacy-Parser bleibt erhalten |
| Alte Website + neuer Link | Präfix unbekannt, Join-Seite tot. **Existiert nach dem Website-Deploy nicht mehr** |
`checkInviteLink()` (`popup.js:1289`) ist nur der Komfort-Pfad "Popup auf der Join-Seite
öffnen und Felder vorausfüllen". Bei `#j2:` greift er in alten Extensions nicht mehr,
das Feld bleibt leer statt falsch befüllt. Die neue Extension muss dort **beide**
Formate parsen (`#j2:` und Legacy `#join:`).
**`history.replaceState` auf das Legacy-Format: nicht tun.** Naheliegende Idee, um das
Autofill alter Extensions zu retten, aber ein Eigentor: es entfernt `k` aus der
Adressleiste. Nutzer kopieren die URL aus der Adressleiste, um sie weiterzuteilen. Der
weitergegebene Link joint dann zwar, hat aber stillschweigend keinen Chat mehr. Der
Fragment-Inhalt muss unangetastet bleiben.
### Lebenszyklus des Keys
- **Erzeugt wird er vom Raum-Ersteller**, einmal, beim Anlegen des Raums. Er lebt im
background neben `roomId`/`password` und geht in den Invite-Link.
- **Er darf niemals in einem Relay-Payload landen.** Nicht in `join_room`, nirgends.
Dafür einen Test schreiben, der alle ausgehenden Events gegen den Key prüft.
- **Krypto gehört in den background**, nicht in den content script. Der Key wird damit
gar nicht erst in den Kontext einer fremden Seite ausgeliefert. Das Overlay schickt
Klartext an den background und bekommt Klartext zurück, verschlüsselt wird
ausschließlich dort.
- **Kanten, die korrekt fallen müssen:**
- Raum-Ersteller hat eine **alte** Extension: es existiert kein Key, niemand chattet.
Korrektes Verhalten, kein Fehlerfall.
- Ein Peer mit **alter** Extension teilt den Invite aus seinem eigenen Popup: der Link
trägt keinen Key. Wer darüber joint, chattet nicht, während die anderen chatten.
- Jemand tippt Raum und Passwort **manuell**: kein Key, kein Chat.
- Relay **ohne** Chat-Capability: Chat-UI gar nicht erst anzeigen.
---
## Vorgeschichte
Vorgängerbranch `feature/soonTMChat` (Stand `c706513`) ist **verworfen**, bleibt als
Referenz liegen. Gründe:
- Chat lag nur im Popup. Ein Chrome-Popup schließt beim Fokusverlust, man kann nicht
gleichzeitig Video schauen und chatten. Strukturell unbrauchbar.
- Der Server speicherte bis zu 500 Klartext-Nachrichten pro Raum und schickte jedem
Joiner das Backlog.
- Verifizierter Folgebug: wer einen Raum mit >120 Nachrichten Historie betrat, wurde
**rausgeworfen**. `renderChatHistory()` feuerte eine ungebündelte `chat_read`-Quittung
pro Nachricht und riss `CHAT_READ_RATE_LIMIT` (120/10s). Reproduziert bei
`CHAT_HISTORY_LIMIT=200`, dokumentiert erlaubt sind 500.
**Lehre: kein ungebündeltes Event-pro-Nachricht-Muster. Jedes Client-Verhalten gegen die
Rate-Limits gegenrechnen, bevor es eingebaut wird.**
### Wiederverwendbar (per `git checkout feature/soonTMChat -- <pfad>`, kritisch prüfen)
| Datei | Was | Einschränkung |
|---|---|---|
| `extension/chat.js` | `escapeChatHtml`, `formatChatText`, `insertEmoji`, `createTypingTracker`, `createRemoteTypingTracker` | reine Funktionen, storage-frei, getestet. `createReceiptTracker` weglassen |
| `extension/chat.test.mjs` | Unit-Tests | Receipt-Tests weg |
| `extension/locales/*.json` | `CHAT_*`-Keys, 15 Sprachen | Receipt-Keys weg, neue Keys für Overlay nötig |
| `server/chat.js` | `sanitizeChatUsername`, `canKickPeer` | `sanitizeChatText`/`createChatMessage` greifen auf Klartext zu, bei E2E unmöglich. `parseChatHistoryLimit`, `appendChatHistory`, `canRelayReadReceipt` entfallen |
| `server/rate-limiter.js` | `checkChatMessageRate` (10/10s) | `checkChatReadRate` entfällt |
| `shared/constants.js` | `CAPABILITIES.CHAT`, Event-Namen | **muss neu hinzu.** main kennt nur `HOST_CONTROL` und `CO_HOST` (`shared/constants.js:78`, `server/index.js:174`) |
**Server-Design, das überleben soll:** Der Server vergab Message-ID, `senderId` und
`timestamp` selbst und ignorierte Client-Angaben (Spoofing-Schutz). Bleibt richtig, auch
wenn der Text jetzt Ciphertext ist.
---
## Technische Randbedingungen
### Overlay-Kontext
- `content.js` wird **programmatisch** injiziert:
`chrome.scripting.executeScript({ target: {tabId}, files: ['content.js'] })`
(`background.js:1775`). Nicht über `manifest.content_scripts`, dort steht nur
`bridge.js` für `https://sync.koalastuff.net/*`.
- `host_permissions: ["<all_urls>"]`, MV3.
- Das Overlay lebt im DOM fremder Seiten (Netflix, YouTube, Emby, Jellyfin).
**Shadow DOM ist Pflicht**, sonst bluten fremde Styles rein und umgekehrt.
- Die Theme-Variablen aus `popup.html` existieren im Page-Kontext **nicht** und müssen
in den Shadow Root injiziert werden.
- **Risiko, vorab prüfen:** Netflix-Vollbild ist ein eigener Fullscreen-Element-Kontext.
Ein Overlay im normalen DOM verschwindet dort möglicherweise. Das kann die
Positionierung grundlegend beeinflussen.
### Performance auf der Host-Seite
Das Video ist das Produkt, der Chat ist Beiwerk. Ein Overlay, das die Wiedergabe ruckeln
lässt, ist schlechter als kein Overlay.
- Nur im **Ziel-Tab** injizieren (`currentTabId` im background), nicht auf jeder Seite.
- `position: fixed`, eigener Compositing-Layer, **kein Eingriff in das Layout der
Host-Seite**. Kein Layout-Thrashing, keine erzwungenen Reflows während der Wiedergabe.
- Nachrichtenliste beim Rendern nicht unbegrenzt wachsen lassen (DOM-Knoten deckeln).
### Firefox
`scripts/build-extension.cjs:199` baut ein eigenes **Firefox-Target**, und `bridge.js`
enthält bereits Firefox-spezifisches `cloneInto`-Handling für CustomEvent-Details
(isolierte Welten in FF MV3). Overlay, Shadow DOM und WebCrypto müssen dort ebenfalls
laufen. Neue Website-zu-Extension-Felder (`chatKey`) gehen durch dieselbe
`cloneInto`-Stelle.
### Theme-System (aus `main`, verbindlich)
- `extension/theme-init.js` setzt auf `<html>`: `data-theme` (`light`/`dark`),
`data-palette` (`eucalyptus`/`cyber`/`graphite`), Klasse `theme-light`.
- Quelle: `chrome.storage.local`, Keys `themeMode` und `themePalette`, plus
`chrome.storage.onChanged` für Live-Updates. Das Overlay nutzt denselben Mechanismus
im Shadow Root.
- **Kein `prefers-color-scheme`** für Theme-Farben. Das System ist explizit, nur der
Modus `system` liest die OS-Präferenz und das erledigt `theme-init.js`.
- Tokens: `--bg`, `--card`, `--surface-alt`, `--surface-deep`, `--accent`, `--text`,
`--text-muted`, `--border-soft`, `--border-strong`, `--text-on-green`.
Text auf `--accent` immer `--text-on-green`, nie `white`.
- Getönte `oklch`-Schatten sind pro Palette hartcodiert und brauchen
`html[data-palette=...]`-Overrides. Neutrale Schwarz-Alpha-Schatten sind
palettenunabhängig und der einfachere Weg.
- Bekannte Alt-Last, nicht Aufgabe dieses Branches: `--text-on-green` auf `--accent`
erreicht in eucalyptus/light 3.76 und cyber/dark 4.24, unter WCAG AA.
### Qualitäts-Gates
- `npm run lint`, `npx vitest run`, `npm run verify` müssen grün sein.
- i18n: alle 15 Sprachen in `extension/locales/`, `en` ist Baseline-Fallback. Keine
hartcodierten UI-Strings, `getMessage(key, { placeholder })` aus `i18n.js`.
- Bei einem Release-Tag: Eintrag in `docs/CHANGELOG.md`.
- Keine Em-Dashes in nutzersichtbaren Texten.
---
## Offene Punkte (mit dem Nutzer klären, nicht selbst entscheiden)
1. **Overlay-Positionierung**: fest andockbar rechts/links, oder frei verschiebbar?
Position pro Seite in `chrome.storage.local` merken? Verhalten im Vollbild?
2. **Peers ohne Key**: Chat-UI ganz ausblenden mit Hinweis, oder anzeigen und
Nachrichten als "nicht lesbar" markieren? (Die Fälle, in denen das eintritt, stehen
oben unter Lebenszyklus.)
3. **Metadaten**: Username bleibt heute für den Server sichtbar (er sanitized ihn).
Mitverschlüsseln? Typing-Indikatoren verraten dem Server, wer wann aktiv ist.
Behalten, verschlüsseln oder streichen?
4. **Kick-Funktion**: `canKickPeer` existiert (Host/Controller-Rechte). Behalten?
5. **Nachrichtenlängen-Limit**: 500 Codepoints beibehalten, oder angesichts des
4096-B-Frames anders wählen?
---
## Vorgehen
1. Offene Punkte klären.
2. Krypto- und Linkformat-Design in `docs/CHAT.md` festhalten. Das bestehende Dokument
beschreibt das verworfene Konzept und wird neu geschrieben. Chat-Events in
`docs/PROTOCOL.md` ergänzen.
3. Website-Parser (`#j2:`, Legacy `#join:` weiter unterstützen) und Bridge-Feld
`chatKey` zuerst, weil davon die Abwärtskompatibilität hängt.
4. Dann Relay (reines Relay ohne Storage), dann Krypto im background, dann Overlay, dann
i18n, dann Tests.
5. **Verifizieren, nicht annehmen:**
- Overlay auf mindestens zwei echten Seiten in allen 6 Theme-Kombinationen, nicht nur
in einer isolierten HTML-Datei.
- Chrome **und** Firefox-Build.
- Ein Test, der beweist, dass der Key in keinem ausgehenden Relay-Payload vorkommt.
- Ein Test, der eine umetikettierte Nachricht (fremde `senderId`) als ungültig
zurückweist (AAD-Bindung).
- Alte Extension gegen neue Website: Join funktioniert weiter, Chat ist stumm.
+1 -1
View File
@@ -24,7 +24,7 @@ When you push a Git tag matching `v*` (e.g., `v2.5.1`), the GitHub Actions relea
- `README.md` (updates badge and announcement banner)
- `website/sitemap.xml` (updates `lastmod` dates)
3. **Commits and pushes** these version updates back to the `main` branch automatically with the commit message `chore(release): update versions to vX.X.X [skip ci]`.
4. **Builds the extension** for both Chrome and Firefox and publishes the zipped archives.
4. **Builds the extension** for both Chrome and Firefox and publishes the zipped archives with a `SHA256SUMS` checksum file and signed provenance attestations.
5. **Builds the website** and uploads website artifacts.
6. **Builds and publishes** the Docker image for the relay server to the GitHub Container Registry (`ghcr.io`).
-1
View File
@@ -96,4 +96,3 @@ syncserver.koalastuff.net {
encode zstd gzip
reverse_proxy KoalaSync:3000
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Synchronisiere die Videowiedergabe auf YouTube, Netflix, Emby, Jellyfin und jeder HTML5-Seite in Echtzeit mit Freunden."
"message": "Erstelle private Watch Partys und synchronisiere Videos mit Freunden auf YouTube, Netflix, Jellyfin, Emby und fast jeder Website."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends."
"message": "Create private watch parties and sync videos with friends on YouTube, Netflix, Jellyfin, Emby, and almost any HTML5 website."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Mira videos junto a tus amigos en perfecta sincronización. Compatible con Netflix, YouTube, Twitch, Prime Video, Disney+ y cualquier reproductor HTML5."
"message": "Crea fiestas privadas y sincroniza vídeos con amigos en YouTube, Netflix, Jellyfin, Emby y casi cualquier sitio HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Synchronisez la lecture vidéo sur YouTube, Netflix, Emby, Jellyfin et tout site HTML5 en temps réel avec vos amis."
"message": "Créez des watch parties privées et regardez en synchro entre amis sur YouTube, Netflix, Jellyfin, Emby et presque tout site HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Sincronizza la riproduzione video su YouTube, Netflix, Emby, Jellyfin e qualsiasi sito HTML5 in tempo reale con gli amici."
"message": "Crea watch party private e sincronizza i video con gli amici su YouTube, Netflix, Jellyfin, Emby e quasi ogni sito HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "YouTube、Netflix、Emby、Jellyfin、その他HTML5サイトでの動画再生を友達とリアルタイムで同期します。"
"message": "プライベートなウォッチパーティーを作成し、YouTube、Netflix、Jellyfin、EmbyやほぼすべてのHTML5サイトで友達と動画を同期再生できます。"
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "YouTube, Netflix, Emby, Jellyfin 모든 HTML5 사이트에서 친구들과 실시간으로 비디오 재생을 동기화하세요."
"message": "비공개 Watch Party를 만들고 YouTube, Netflix, Jellyfin, Emby 및 거의 모든 HTML5 사이트에서 친구들과 영상을 동기화하세요."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Synchroniseer video-afspelen op YouTube, Netflix, Emby, Jellyfin en elke HTML5-site in realtime met vrienden."
"message": "Maak privéwatchparty's en kijk synchroon met vrienden op YouTube, Netflix, Jellyfin, Emby en vrijwel elke HTML5-website."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Synchronizuj odtwarzanie wideo na YouTube, Netflix, Emby, Jellyfin i dowolnej stronie HTML5 w czasie rzeczywistym ze znajomymi."
"message": "Twórz prywatne wspólne seanse i synchronizuj filmy ze znajomymi na YouTube, Netflix, Jellyfin, Emby i niemal każdej stronie HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Assista a vídeos junto com seus amigos em sincronia perfeita. Compatível com Netflix, YouTube, Twitch, Prime Video, Disney+ e qualquer player HTML5."
"message": "Crie watch parties privadas e sincronize vídeos com amigos no YouTube, Netflix, Jellyfin, Emby e em quase qualquer site HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Veja vídeos em conjunto com os seus amigos em sincronia perfeita. Compatível com Netflix, YouTube, Twitch, Prime Video, Disney+ e qualquer leitor HTML5."
"message": "Crie sessões privadas e veja vídeos sincronizados com amigos no YouTube, Netflix, Jellyfin, Emby e em quase qualquer site HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Синхронизируйте воспроизведение видео на YouTube, Netflix, Emby, Jellyfin и любых HTML5-сайтах в реальном времени с друзьями."
"message": "Создавайте закрытые Watch Party и смотрите синхронно с друзьями на YouTube, Netflix, Jellyfin, Emby и почти любом сайте HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "YouTube, Netflix, Emby, Jellyfin ve herhangi bir HTML5 sitesinde video oynatmayı arkadaşlarınızla gerçek zamanlı olarak senkronize edin."
"message": "Özel izleme partileri kurun; YouTube, Netflix, Jellyfin, Emby ve çoğu HTML5 sitesinde arkadaşlarınızla senkron izleyin."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Синхронізуйте відтворення відео на YouTube, Netflix, Emby, Jellyfin і будь-якому сайті HTML5 у режимі реального часу з друзями."
"message": "Створюйте закриті Watch Party й дивіться синхронно з друзями на YouTube, Netflix, Jellyfin, Emby та майже будь-якому сайті HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "与朋友实时同步YouTube、Netflix、Emby、Jellyfin任何HTML5网站上的视频播放。"
"message": "创建私人观影派对,与好友在 YouTube、Netflix、Jellyfin、Emby 及几乎任何 HTML5 视频网站同步观看。"
}
}
+524 -94
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -11,11 +11,12 @@ document.documentElement.dataset.koalasyncInstalled = 'true';
// 2. Listen for Join Requests from the Website
window.addEventListener('KOALASYNC_JOIN_REQUEST', (e) => {
if (!e || !e.detail) return;
const { roomId, password, useCustomServer, serverUrl } = e.detail;
const { roomId, password, chatKey, useCustomServer, serverUrl } = e.detail;
chrome.runtime.sendMessage({
type: 'WEB_JOIN_REQUEST',
roomId,
password,
chatKey,
useCustomServer,
serverUrl
}).catch(() => {});
+57
View File
@@ -0,0 +1,57 @@
const ALLOWED_ACTIONS = new Set([
'play',
'pause',
'seek',
'force_sync_prepare',
'force_sync_execute',
'joined',
'left'
]);
export function createChatActivityStore(limit = 100) {
const maxEntries = Number.isInteger(limit) && limit > 0 ? limit : 100;
let entries = [];
function normalize(value) {
if (!value || !ALLOWED_ACTIONS.has(value.action) || typeof value.senderId !== 'string' || !value.senderId) {
return null;
}
const timestamp = Number(value.timestamp);
if (!Number.isFinite(timestamp)) return null;
const id = typeof value.id === 'string' && value.id
? value.id
: `${value.action}:${value.senderId}:${timestamp}`;
return {
id,
action: value.action,
senderId: value.senderId,
username: typeof value.username === 'string' ? value.username : '',
timestamp
};
}
return {
add(value) {
const entry = normalize(value);
if (!entry || entries.some(candidate => candidate.id === entry.id)) return null;
entries.push(entry);
if (entries.length > maxEntries) entries = entries.slice(-maxEntries);
return { ...entry };
},
clear() {
entries = [];
},
restore(values) {
entries = [];
for (const value of Array.isArray(values) ? values : []) {
const entry = normalize(value);
if (!entry || entries.some(candidate => candidate.id === entry.id)) continue;
entries.push(entry);
}
entries = entries.slice(-maxEntries);
},
snapshot() {
return entries.map(entry => ({ ...entry }));
}
};
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { createChatActivityStore } from './chat-activity.js';
describe('chat activity store', () => {
it('deduplicates commands and retains only the newest bounded entries', () => {
const store = createChatActivityStore(2);
expect(store.add({ action: 'play', senderId: 'a', timestamp: 1 })).toMatchObject({ id: 'play:a:1' });
expect(store.add({ action: 'play', senderId: 'a', timestamp: 1 })).toBeNull();
store.add({ action: 'pause', senderId: 'b', username: 'Bear', timestamp: 2 });
store.add({ action: 'seek', senderId: 'a', timestamp: 3 });
expect(store.snapshot()).toEqual([
{ id: 'pause:b:2', action: 'pause', senderId: 'b', username: 'Bear', timestamp: 2 },
{ id: 'seek:a:3', action: 'seek', senderId: 'a', username: '', timestamp: 3 }
]);
});
it('restores only valid activity without sharing mutable references', () => {
const store = createChatActivityStore();
store.restore([
{ id: 'joined:b:4', action: 'joined', senderId: 'b', username: 'Koala', timestamp: 4 },
{ action: 'unknown', senderId: 'b', timestamp: 5 },
{ action: 'left', senderId: '', timestamp: 6 }
]);
const snapshot = store.snapshot();
snapshot[0].username = 'Changed';
expect(store.snapshot()).toEqual([
{ id: 'joined:b:4', action: 'joined', senderId: 'b', username: 'Koala', timestamp: 4 }
]);
});
});
+122
View File
@@ -0,0 +1,122 @@
const CHAT_INFO = 'KoalaSync E2E Chat v1';
const SECRET_BYTES = 16;
const IV_BYTES = 12;
const MIN_ENCRYPTED_BYTES = 29;
const MAX_ENCRYPTED_BYTES = 2028;
let cachedKeyPromise = null;
let cachedRoomId = '';
let cachedSecret = '';
let cacheGeneration = 0;
function bytesToBase64Url(bytes) {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return globalThis.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function base64UrlToBytes(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) return null;
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
try {
const binary = globalThis.atob(padded);
const bytes = Uint8Array.from(binary, char => char.charCodeAt(0));
return bytesToBase64Url(bytes) === value ? bytes : null;
} catch (_) {
return null;
}
}
export function generateChatSecret(cryptoImpl = globalThis.crypto) {
const bytes = new Uint8Array(SECRET_BYTES);
cryptoImpl.getRandomValues(bytes);
return bytesToBase64Url(bytes);
}
export function validateChatSecret(value) {
const bytes = base64UrlToBytes(value);
return bytes?.length === SECRET_BYTES ? value : '';
}
export function countCodePoints(value) {
return [...String(value ?? '')].length;
}
export function normalizeOutgoingChatText(value) {
if (typeof value !== 'string') throw new TypeError('Chat message must be text');
const text = value.trim();
if (!text) throw new TypeError('Chat message must not be empty');
if (countCodePoints(text) > 500) throw new RangeError('Chat message exceeds 500 Unicode code points');
return text;
}
export function clearChatKeyCache() {
cacheGeneration++;
cachedKeyPromise = null;
cachedRoomId = '';
cachedSecret = '';
}
export async function deriveChatKey(roomId, secret, cryptoImpl = globalThis.crypto) {
const validSecret = validateChatSecret(secret);
if (!roomId || !validSecret) throw new TypeError('Valid roomId and chat secret are required');
if (cachedKeyPromise && cachedRoomId === roomId && cachedSecret === validSecret) return cachedKeyPromise;
const encoder = new globalThis.TextEncoder();
const generation = cacheGeneration;
const keyPromise = (async () => {
const material = await cryptoImpl.subtle.importKey(
'raw', base64UrlToBytes(validSecret), { name: 'HKDF' }, false, ['deriveKey']
);
return cryptoImpl.subtle.deriveKey({
name: 'HKDF',
hash: 'SHA-256',
salt: encoder.encode(roomId),
info: encoder.encode(CHAT_INFO)
}, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
})();
cachedKeyPromise = keyPromise;
cachedRoomId = roomId;
cachedSecret = validSecret;
try {
return await keyPromise;
} catch (error) {
if (generation === cacheGeneration && cachedKeyPromise === keyPromise) clearChatKeyCache();
throw error;
}
}
export async function encryptChatMessage({ text, roomId, senderId, secret }, cryptoImpl = globalThis.crypto) {
const plaintext = normalizeOutgoingChatText(text);
if (!senderId) throw new TypeError('senderId is required');
const key = await deriveChatKey(roomId, secret, cryptoImpl);
const encoder = new globalThis.TextEncoder();
const iv = new Uint8Array(IV_BYTES);
cryptoImpl.getRandomValues(iv);
const encrypted = new Uint8Array(await cryptoImpl.subtle.encrypt({
name: 'AES-GCM',
iv,
additionalData: encoder.encode(`${roomId}|${senderId}`)
}, key, encoder.encode(plaintext)));
const envelope = new Uint8Array(iv.length + encrypted.length);
envelope.set(iv);
envelope.set(encrypted, iv.length);
return bytesToBase64Url(envelope);
}
export async function decryptChatMessage({ ciphertext, roomId, senderId, secret }, cryptoImpl = globalThis.crypto) {
if (!senderId) throw new TypeError('senderId is required');
const bytes = base64UrlToBytes(ciphertext);
if (!bytes || bytes.length < MIN_ENCRYPTED_BYTES || bytes.length > MAX_ENCRYPTED_BYTES) {
throw new TypeError('Invalid chat ciphertext');
}
const key = await deriveChatKey(roomId, secret, cryptoImpl);
const encoder = new globalThis.TextEncoder();
const plaintext = await cryptoImpl.subtle.decrypt({
name: 'AES-GCM',
iv: bytes.slice(0, IV_BYTES),
additionalData: encoder.encode(`${roomId}|${senderId}`)
}, key, bytes.slice(IV_BYTES));
const text = new globalThis.TextDecoder('utf-8', { fatal: true }).decode(plaintext);
return normalizeOutgoingChatText(text);
}
+114
View File
@@ -0,0 +1,114 @@
import { webcrypto } from 'node:crypto';
import { Buffer } from 'node:buffer';
import { afterEach, describe, expect, it } from 'vitest';
import {
clearChatKeyCache,
countCodePoints,
decryptChatMessage,
deriveChatKey,
encryptChatMessage,
generateChatSecret,
normalizeOutgoingChatText,
validateChatSecret
} from './chat-crypto.js';
afterEach(clearChatKeyCache);
describe('chat crypto', () => {
it('generates canonical 128-bit secrets', () => {
const secret = generateChatSecret(webcrypto);
expect(secret).toMatch(/^[A-Za-z0-9_-]{22}$/);
expect(validateChatSecret(secret)).toBe(secret);
expect(validateChatSecret(`${secret}=`)).toBe('');
});
it('round-trips Unicode with a cached room key and fresh IVs', async () => {
const secret = generateChatSecret(webcrypto);
const firstKey = await deriveChatKey('ROOM-1', secret, webcrypto);
const secondKey = await deriveChatKey('ROOM-1', secret, webcrypto);
expect(secondKey).toBe(firstKey);
const input = { text: 'Hello 🐨 **world**', roomId: 'ROOM-1', senderId: 'alice', secret };
const first = await encryptChatMessage(input, webcrypto);
const second = await encryptChatMessage(input, webcrypto);
expect(second).not.toBe(first);
await expect(decryptChatMessage({ ciphertext: first, ...input }, webcrypto)).resolves.toBe(input.text);
});
it('deduplicates in-flight key derivations without restoring a cleared cache', async () => {
const secret = generateChatSecret(webcrypto);
let deriveCalls = 0;
let releaseDerive;
const delayedCrypto = {
...webcrypto,
subtle: {
importKey: (...args) => webcrypto.subtle.importKey(...args),
deriveKey: async (...args) => {
deriveCalls++;
await new Promise(resolve => { releaseDerive = resolve; });
return webcrypto.subtle.deriveKey(...args);
}
}
};
const first = deriveChatKey('ROOM-1', secret, delayedCrypto);
const second = deriveChatKey('ROOM-1', secret, delayedCrypto);
await Promise.resolve();
expect(deriveCalls).toBe(1);
clearChatKeyCache();
releaseDerive();
await Promise.all([first, second]);
const fresh = deriveChatKey('ROOM-1', secret, webcrypto);
expect(fresh).not.toBe(first);
await fresh;
});
it('rejects relabeling, cross-room replay, and the wrong secret', async () => {
const secret = generateChatSecret(webcrypto);
const ciphertext = await encryptChatMessage({ text: 'secret', roomId: 'ROOM-1', senderId: 'alice', secret }, webcrypto);
clearChatKeyCache();
await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-1', senderId: 'bob', secret }, webcrypto)).rejects.toThrow();
clearChatKeyCache();
await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-2', senderId: 'alice', secret }, webcrypto)).rejects.toThrow();
clearChatKeyCache();
await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-1', senderId: 'alice', secret: generateChatSecret(webcrypto) }, webcrypto)).rejects.toThrow();
});
it('enforces 500 Unicode code points before encryption', () => {
expect(countCodePoints('😀'.repeat(500))).toBe(500);
expect(normalizeOutgoingChatText(` ${'😀'.repeat(500)} `)).toBe('😀'.repeat(500));
expect(() => normalizeOutgoingChatText('😀'.repeat(501))).toThrow(RangeError);
expect(() => normalizeOutgoingChatText(' ')).toThrow(TypeError);
});
it('keeps the worst-case 500-codepoint payload within the relay byte bound', async () => {
const secret = generateChatSecret(webcrypto);
const ciphertext = await encryptChatMessage({
text: '😀'.repeat(500), roomId: 'ROOM-1', senderId: 'alice', secret
}, webcrypto);
const bytes = Buffer.from(ciphertext, 'base64url');
expect(bytes).toHaveLength(2028);
});
it('rejects non-canonical plaintext and malformed UTF-8 after authentication', async () => {
const secret = generateChatSecret(webcrypto);
const roomId = 'ROOM-1';
const senderId = 'alice';
const key = await deriveChatKey(roomId, secret, webcrypto);
const encoder = new globalThis.TextEncoder();
async function encryptRaw(plaintext) {
const iv = webcrypto.getRandomValues(new Uint8Array(12));
const encrypted = new Uint8Array(await webcrypto.subtle.encrypt({
name: 'AES-GCM',
iv,
additionalData: encoder.encode(`${roomId}|${senderId}`)
}, key, plaintext));
return Buffer.concat([Buffer.from(iv), Buffer.from(encrypted)]).toString('base64url');
}
const whitespace = await encryptRaw(encoder.encode(' '));
await expect(decryptChatMessage({ ciphertext: whitespace, roomId, senderId, secret }, webcrypto)).rejects.toThrow(TypeError);
const malformed = await encryptRaw(Uint8Array.of(0xff));
await expect(decryptChatMessage({ ciphertext: malformed, roomId, senderId, secret }, webcrypto)).rejects.toThrow();
});
});
+69
View File
@@ -0,0 +1,69 @@
(function exposeChatFormat(root) {
function escapeChatHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function formatChatText(value) {
return escapeChatHtml(value)
.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
}
function tokenizeChatText(value) {
const text = String(value ?? '');
const tokens = [];
const pattern = /\*\*([^*\n]+)\*\*|\*([^*\n]+)\*/g;
let cursor = 0;
let match;
while ((match = pattern.exec(text)) !== null) {
if (match.index > cursor) tokens.push({ type: 'text', text: text.slice(cursor, match.index) });
tokens.push({ type: match[1] !== undefined ? 'strong' : 'em', text: match[1] ?? match[2] });
cursor = pattern.lastIndex;
}
if (cursor < text.length) tokens.push({ type: 'text', text: text.slice(cursor) });
return tokens;
}
function splitChatText(value, maxCodePoints = 500, maxChunks = 10) {
if (!Number.isInteger(maxCodePoints) || maxCodePoints < 1 || !Number.isInteger(maxChunks) || maxChunks < 1) {
throw new TypeError('Chat chunk limits must be positive integers');
}
let remaining = [...String(value ?? '').trim()];
if (remaining.length === 0) return [];
if (remaining.length > maxCodePoints * maxChunks) {
throw new RangeError('Chat composer text exceeds the chunk limit');
}
const chunks = [];
while (remaining.length > 0) {
if (remaining.length <= maxCodePoints) {
chunks.push(remaining.join('').trim());
break;
}
const remainingSlots = maxChunks - chunks.length;
const minimumSplit = Math.max(1, remaining.length - (remainingSlots - 1) * maxCodePoints);
let splitAt = maxCodePoints;
for (let index = maxCodePoints - 1; index >= minimumSplit; index--) {
if (/\s/u.test(remaining[index])) {
splitAt = index;
break;
}
}
const chunk = remaining.slice(0, splitAt).join('').trim();
remaining = remaining.slice(splitAt);
while (remaining.length > 0 && /\s/u.test(remaining[0])) remaining.shift();
if (chunk) chunks.push(chunk);
}
return chunks;
}
root.KoalaSyncChatFormat = Object.freeze({ escapeChatHtml, formatChatText, tokenizeChatText, splitChatText });
})(globalThis);
+43
View File
@@ -0,0 +1,43 @@
import { beforeAll, describe, expect, it } from 'vitest';
beforeAll(async () => {
await import('./chat-format.js');
});
describe('chat formatting', () => {
it('escapes executable HTML before applying limited Markdown', () => {
const result = globalThis.KoalaSyncChatFormat.formatChatText('<img src=x onerror=alert(1)> **bold** *italic*');
expect(result).toBe('&lt;img src=x onerror=alert(1)&gt; <strong>bold</strong> <em>italic</em>');
expect(result).not.toContain('<img');
});
it('tokenizes only text, strong, and emphasis nodes', () => {
expect(globalThis.KoalaSyncChatFormat.tokenizeChatText('<script>x</script> **bold** *italic*')).toEqual([
{ type: 'text', text: '<script>x</script> ' },
{ type: 'strong', text: 'bold' },
{ type: 'text', text: ' ' },
{ type: 'em', text: 'italic' }
]);
});
it('splits up to 5000 Unicode code points into at most ten ordered chat-v1 messages', () => {
const split = globalThis.KoalaSyncChatFormat.splitChatText;
expect(split('hello world', 500, 10)).toEqual(['hello world']);
const emojiChunks = split('😀'.repeat(1001), 500, 10);
expect(emojiChunks.map(chunk => [...chunk].length)).toEqual([500, 500, 1]);
const maximumChunks = split('x'.repeat(5000), 500, 10);
expect(maximumChunks).toHaveLength(10);
expect(maximumChunks.every(chunk => [...chunk].length === 500)).toBe(true);
expect(maximumChunks.join('')).toBe('x'.repeat(5000));
expect(() => split('x'.repeat(5001), 500, 10)).toThrow(RangeError);
});
it('prefers whitespace boundaries without creating more chunks than the limit allows', () => {
const split = globalThis.KoalaSyncChatFormat.splitChatText;
const chunks = split(`${'a'.repeat(420)} ${'b'.repeat(179)}`, 500, 10);
expect(chunks).toEqual(['a'.repeat(420), 'b'.repeat(179)]);
});
});
+215
View File
@@ -0,0 +1,215 @@
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8');
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.js'), 'utf8');
const manifestSource = fs.readFileSync(path.join(extensionDir, 'manifest.base.json'), 'utf8');
const buildSource = fs.readFileSync(path.join(extensionDir, '..', 'scripts', 'build-extension.cjs'), 'utf8');
const localeDir = path.join(extensionDir, 'locales');
const chatKeys = [
'LABEL_CHAT_ENABLED',
'LABEL_CHAT_ENABLED_TOOLTIP',
'CHAT_TITLE',
'CHAT_LIVE_ONLY',
'CHAT_OPEN',
'CHAT_CLOSE',
'CHAT_DOCK_LEFT',
'CHAT_DOCK_RIGHT',
'CHAT_DETACHED',
'CHAT_PLACEHOLDER',
'CHAT_SEND',
'CHAT_MISSING_KEY',
'CHAT_TOO_LONG',
'CHAT_SEND_FAILED',
'CHAT_EMPTY',
'LABEL_CHAT_REACTION_DISPLAY',
'LABEL_CHAT_REACTION_DISPLAY_TOOLTIP',
'OPTION_CHAT_REACTIONS_CHAT',
'OPTION_CHAT_REACTIONS_VIDEO',
'CHAT_QUICK_REACTIONS'
];
describe('chat overlay contract', () => {
it('skips SVG and other non-HTML documents before creating the overlay host', () => {
const documentGuard = "document.documentElement?.namespaceURI !== 'http://www.w3.org/1999/xhtml'";
expect(overlaySource).toContain(documentGuard);
expect(overlaySource.indexOf(documentGuard)).toBeLessThan(overlaySource.indexOf("document.createElement('div')"));
expect(() => vm.runInNewContext(overlaySource, {
document: { documentElement: { namespaceURI: 'http://www.w3.org/2000/svg' } }
})).not.toThrow();
});
it('isolates the overlay and never renders markup as HTML', () => {
expect(overlaySource).toContain("attachShadow({ mode: 'open' })");
expect(overlaySource).not.toMatch(/\.innerHTML\s*=|insertAdjacentHTML|\.outerHTML\s*=/);
expect(overlaySource).toContain('document.createTextNode(token.text)');
});
it('injects formatting and overlay code only with the selected-tab content script', () => {
expect(backgroundSource).toContain("files: ['chat-format.js', 'chat-overlay.js', 'content.js']");
expect(overlaySource).toContain("const storageKey = `chatOverlayLayout:${location.origin}`");
expect(overlaySource).toContain('document.fullscreenElement || document.documentElement');
});
it('supports all layout and theme combinations with bounded message DOM', () => {
expect(overlaySource).toContain("['left', 'right', 'detached']");
expect(overlaySource).toContain('!layout.detachedInitialized');
expect(overlaySource).toContain('const rect = panel.getBoundingClientRect()');
expect(overlaySource).not.toContain('contentRect');
expect(overlaySource).toContain("#app[data-palette=\"cyber\"][data-theme=\"light\"]");
expect(overlaySource).toContain("#app[data-palette=\"graphite\"][data-theme=\"light\"]");
expect(overlaySource).toContain('const MAX_MESSAGES = 200');
expect(overlaySource).toContain('while (messages.querySelectorAll(\'.message\').length > MAX_MESSAGES)');
expect(overlaySource).toContain('Math.max(1, window.innerWidth)');
expect(overlaySource).toContain('min(${MIN_WIDTH}px, calc(100vw - 16px))');
});
it('blocks page playback shortcuts while the chat composer owns keyboard input', () => {
expect(overlaySource).toContain("for (const eventName of ['keydown', 'keyup', 'keypress'])");
expect(overlaySource).toContain('window.addEventListener(eventName, stopPageKeyboardShortcut, true)');
expect(overlaySource).toContain('path.includes(textarea)');
expect(overlaySource).toContain('event.stopImmediatePropagation()');
expect(overlaySource).toContain('window.removeEventListener(eventName, stopPageKeyboardShortcut, true)');
});
it('keeps the launcher draggable only in detached mode and anchors dock launchers', () => {
expect(overlaySource).toContain("launcher.addEventListener('pointerdown'");
expect(overlaySource).toMatch(/launcher\.addEventListener\('pointerdown'[\s\S]*layout\.mode !== 'detached'/);
expect(overlaySource).toContain("launcher.addEventListener('pointermove'");
expect(overlaySource).toContain('layout.launcherX = launcherDrag.x + deltaX');
expect(overlaySource).toContain('layout.launcherY = launcherDrag.y + deltaY');
expect(overlaySource).toContain('suppressLauncherClick = launcherDrag.moved');
expect(overlaySource).toContain("launcher.classList.toggle('docked-left'");
expect(overlaySource).toContain("launcher.classList.toggle('docked-right'");
});
it('reserves page space for real left and right dock modes', () => {
expect(overlaySource).toContain("const PAGE_DOCK_ATTRIBUTE = 'data-koalasync-chat-dock'");
expect(overlaySource).toContain('width: calc(100% - var(${PAGE_DOCK_WIDTH})) !important');
expect(overlaySource).toContain('margin-left: var(${PAGE_DOCK_WIDTH}) !important');
expect(overlaySource).toContain('margin-right: var(${PAGE_DOCK_WIDTH}) !important');
expect(overlaySource).toContain('html[${PAGE_DOCK_ATTRIBUTE}] > body');
expect(overlaySource).toContain('clip-path: inset(0) !important');
expect(overlaySource).toContain('contain: layout paint !important');
expect(overlaySource).toContain('> :not(#koalasync-chat-overlay-host)');
expect(overlaySource).toContain('const target = document.fullscreenElement || document.documentElement');
expect(overlaySource).toContain('pageDockTarget = target');
expect(overlaySource).not.toContain('DOCK_MIN_PAGE_WIDTH');
expect(overlaySource).toContain('applyPageDock(layout.mode, dockWidth)');
expect(overlaySource).toContain('clearPageDock()');
});
it('counts unread remote messages on the bubble and clears the badge when opened', () => {
expect(overlaySource).toContain("const unreadBadge = element('span', 'unread')");
expect(overlaySource).toContain('if (!opened && !own) setUnreadCount(unreadCount + 1)');
expect(overlaySource).toMatch(/if \(opened\) \{\s*setUnreadCount\(0\)/);
expect(overlaySource).toContain("unreadBadge.classList.toggle('visible', unreadCount > 0)");
expect(backgroundSource).toMatch(/received\.senderId !== peerId[\s\S]*showChatNotification\([\s\S]*senderPeer\.username[\s\S]*received\.senderId/);
expect(backgroundSource).toContain("chrome.notifications.create(`chat_${Date.now()}`");
expect(backgroundSource).toContain('getTabForNotification(targetTabId)');
expect(backgroundSource).toContain('getWindowForNotification(tab.windowId)');
expect(backgroundSource).toContain('shouldShowChatNotification({ enabled, targetTabId, tab, windowInfo })');
});
it('renders timestamped room activity and keeps activity notifications opt-in', () => {
expect(overlaySource).toContain("if (message?.type === 'CHAT_EVENT') appendEvent(message.event)");
expect(overlaySource).toContain("const time = element('time', 'time')");
expect(overlaySource).toContain("date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })");
expect(overlaySource).toContain('if (!opened && context.eventNotifications) setUnreadCount(unreadCount + 1)');
expect(backgroundSource).toContain("eventNotifications: localeData.browserNotifications === true");
expect(backgroundSource).toContain("sendChatActivity(message.action, peerId, timestamp)");
expect(backgroundSource).toContain("sendChatActivity(event, data.senderId, data.actionTimestamp)");
expect(backgroundSource).toContain("sendChatActivity('joined', data.peerId, Date.now())");
expect(backgroundSource).toContain("sendChatActivity('left', data.peerId, Date.now())");
expect(backgroundSource).toContain('activity: chatActivityStore.snapshot()');
expect(overlaySource).toContain('renderedActivityIds.has(event.id)');
expect(overlaySource).toContain('const MAX_RENDERED_ACTIVITY_IDS = 200');
expect(overlaySource).toContain('while (renderedActivityIdOrder.length > MAX_RENDERED_ACTIVITY_IDS)');
expect(overlaySource).toContain('renderedActivityIds.delete(renderedActivityIdOrder.shift())');
});
it('uses the KoalaSync icon and offers bounded encrypted quick reactions', () => {
expect(overlaySource).toContain("const LAUNCHER_ICON_DATA_URL = 'data:image/png;base64,");
expect(overlaySource).toContain('launcherIcon.src = LAUNCHER_ICON_DATA_URL');
expect(manifestSource).not.toContain('web_accessible_resources');
expect(overlaySource).toContain("const QUICK_REACTIONS = Object.freeze(['❤️', '😂', '😮', '😢', '👏', '🔥'])");
expect(overlaySource).toContain("messageRuntime({ type: 'CHAT_SEND', text: chunk })");
expect(overlaySource).toContain("chatReactionDisplay !== 'video'");
expect(overlaySource).toContain('MAX_REACTION_PARTICLES - reactionLayer.childElementCount');
expect(overlaySource).toContain('reducedMotion.matches');
});
it('splits a 5000-codepoint composer value into at most ten acknowledged chat-v1 messages', () => {
expect(overlaySource).toContain('const MAX_MESSAGE_CODE_POINTS = 500');
expect(overlaySource).toContain('const MAX_CHAT_CHUNKS = 10');
expect(overlaySource).toContain('MAX_MESSAGE_CODE_POINTS * MAX_CHAT_CHUNKS');
expect(overlaySource).toContain('KoalaSyncChatFormat?.splitChatText(');
expect(overlaySource).toMatch(/for \(const chunk of chunks\)[\s\S]*messageRuntime\(\{ type: 'CHAT_SEND', text: chunk \}\)/);
expect(overlaySource).toContain("textarea.value = chunks.slice(completedChunks).join('\\n')");
expect(backgroundSource).toContain('const echoPromise = chatEchoTracker.waitFor(ciphertext)');
expect(backgroundSource).toContain('chatEchoTracker.acknowledge(received.ciphertext)');
expect(backgroundSource).toContain("status: acknowledged ? 'ok' : 'unconfirmed'");
});
it('excludes test-only modules from production extension artifacts', () => {
expect(buildSource).toContain("/\\.test\\.[cm]?js$/u.test(item)");
});
it('guards async refresh/send work and clears all composer state on room reset', () => {
expect(overlaySource).toContain('generation !== refreshGeneration');
expect(overlaySource).toContain('if (sending || !context?.enabled) return');
expect(overlaySource).toContain("clearComposerValue && textarea.value.trim() === text");
expect(overlaySource).toMatch(/CHAT_RESET[\s\S]*resetComposer\(\)/);
expect(overlaySource).toContain('setTimeout(() => finish(null), timeoutMs)');
expect(backgroundSource).toContain('chatReceiveQueue = chatReceiveQueue.catch(() => {}).then');
expect(backgroundSource).toContain("status: 'rate_limited'");
expect(overlaySource).toContain('function setLocalStorage(values)');
expect(overlaySource).toMatch(/chrome\.storage\.local\.get\([\s\S]*data => \{\s*if \(destroyed\) return;/);
expect(overlaySource).toContain("if (destroyed || area !== 'local') return");
});
it('keeps chat hidden by default without discarding the room chat key', () => {
expect(popupSource).toContain('localData.chatEnabled === true');
expect(backgroundSource).toContain('chatEnabled: data.chatEnabled === true');
expect(backgroundSource).toContain('clientCapabilities: CLIENT_CAPABILITIES');
expect(overlaySource).toContain('all:initial;display:none;position:fixed');
expect(overlaySource).toContain("host.style.display = supported && optedIn ? '' : 'none'");
expect(overlaySource).toContain('supported && optedIn && hasKey && connected');
expect(popupSource).toContain("chrome.storage.local.set({ chatEnabled: elements.chatEnabled.checked })");
expect(popupSource).toMatch(/if \(isCreating\) \{[\s\S]*?type: 'CREATE_CHAT_KEY'[\s\S]*?chatKey = normalizeChatKey/);
expect(popupSource).not.toMatch(/chatEnabled[\s\S]{0,120}chatKey:\s*''/);
});
it('keeps unavailable chat controls discoverable to assistive technology', () => {
expect(overlaySource).toContain("launcher.setAttribute('aria-disabled'");
expect(overlaySource).not.toContain('launcher.disabled =');
expect(overlaySource).toContain("launcher.setAttribute('aria-describedby', launcherHint.id)");
expect(overlaySource).toContain("textarea.setAttribute('aria-describedby', 'chat-composer-count chat-composer-status')");
expect(overlaySource).toContain("status.setAttribute('role', 'status')");
});
it('creates a chat key for both generated-room entry points', () => {
expect(popupSource).toContain('let pendingRoomCreation = false');
expect(popupSource).toContain('const isCreating = pendingRoomCreation || !roomIdInput');
expect(popupSource).toMatch(/function handleCreateRoom\(\)[\s\S]*pendingRoomCreation = true[\s\S]*elements\.joinBtn\.click\(\)/);
expect(popupSource).toContain("type: 'CREATE_CHAT_KEY'");
});
it('contains every chat string in all 15 extension locales', () => {
const localeFiles = fs.readdirSync(localeDir).filter(file => file.endsWith('.json')).sort();
expect(localeFiles).toHaveLength(15);
for (const file of localeFiles) {
const messages = JSON.parse(fs.readFileSync(path.join(localeDir, file), 'utf8'));
for (const key of chatKeys) {
expect(messages[key], `${file}: ${key}`).toBeTypeOf('string');
expect(messages[key], `${file}: ${key}`).not.toBe('');
expect(messages[key], `${file}: ${key}`).not.toMatch(/[—–]/);
}
}
});
});
+998
View File
@@ -0,0 +1,998 @@
(function initKoalaSyncChatOverlay() {
if (document.documentElement?.namespaceURI !== 'http://www.w3.org/1999/xhtml') return;
if (window.koalaSyncChatOverlay?.refresh) {
window.koalaSyncChatOverlay.refresh();
return;
}
const MAX_MESSAGES = 200;
const MIN_WIDTH = 300;
const MIN_HEIGHT = 320;
const DEFAULT_WIDTH = 360;
const DEFAULT_HEIGHT = 520;
const LAUNCHER_SIZE = 48;
const EDGE_INSET = 8;
const MAX_MESSAGE_CODE_POINTS = 500;
const MAX_CHAT_CHUNKS = 10;
const MAX_COMPOSER_CODE_POINTS = MAX_MESSAGE_CODE_POINTS * MAX_CHAT_CHUNKS;
const PAGE_DOCK_ATTRIBUTE = 'data-koalasync-chat-dock';
const PAGE_DOCK_WIDTH = '--koalasync-chat-dock-width';
const QUICK_REACTIONS = Object.freeze(['❤️', '😂', '😮', '😢', '👏', '🔥']);
const MAX_REACTION_PARTICLES = 36;
const MAX_RENDERED_ACTIVITY_IDS = 200;
const LAUNCHER_ICON_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAnsSURBVGhD7VlbbBtZGfZyF7cFJBrPjGdsz8W3Tnwb23HSdM3SXZFuBNUu21C6Tanapk3dNM390sS5NGna7IUXFoTElpdVn5HgofAKSCAegG2FhLQFVruoK3gAJARiKeSg74zP+PiMnYRukXjoL/2yPXPO+b//ev5zHAg8pIf0kNqSafZ9sKurEikWe2P5/H4pEAi8RxwDOnz48HsLhe6c4/QMhxTrG6FQ7LuhkPVjNWS9pirWLVWN/1RT4zdVxfpWJlMad5yuXqwtrgOqVCrvg0ysl8+XO8vl8qfEMdsSFnCcfVoknHpFUxP/kIIRIgWjRJb0rWik846lp76SSlU+GggEHnEc51FdT52IhFO/lyWdKLJJQorLimxwjN8uy7KB9bbUUPzPppkaz2Qqn8BapVLp47puj4a1xFvBjsgW5IEV2dzSI/YP0+lCLpVKfUDE20SO43xYVazrUjDyVwgKhSwSjdjE0NMkrCVISLGoIsFg9Je2XfyqFIz+phVQVwGe2Tt+rPusY4/2pq7bXw92hG/JsrGF5+FwkiTiDjGNDFFDMTpOkiL3FMX8Tj0S/NTV9ZilqbHXYUmAHRsbJqurM+Tq1UWPa7UJUijshxI+gFCYZ/pM0v1cBy56CsZ5/PE+srIy3SRzfX2eTE+PkEg4RedrauIvuVxPoQk83KipsdtYJJ/tIWtr82RjY4FcuXKJfrLF2O+JiarrDcGisJxl5sjJk8+R8fGzZGZmhMzOjpCJiWEyNDRI0naZRCN7fYrDylNT58m1a7Um8AyDK3eRPPnEQSpXkc03SqX9UQo+nX7yI4psfR/W2N97gC6CCQwwvLCyMkXW1ua85+Dz1VOeJ/SoTebnxz2hvNKteGlpmhh6hnoAa5w9e6JpDqzOy2XPge3Qoaddj4ViP+/t7f1kIJ0u9kmS/k9D7/QG4XNxcYxUq8fIuXPPkTNnjpDh4aPk0qVRDySEaGqCupdXbDfMLLuwME6Vx1rMu7OzVSoXMs+e/TL9DixsLsYhJ2VJ/7dp2scCmpq4CSswIMwStdo4uXDhBF2EMZSYmal649jYnSwuhgabw8/HmtPTw9RgkMPkXbx4sikXMQ4eRNhpWvK1ALIbCly+POtZAgNhCViBWQKL4fvQ0JfoOBHQdtxKAZERKszTrYyGMczz+ERSI5QCkqTfgQLBjghVAgMRKuIi7Hu1OugT/iAYoGD9VjLxfX7ejRAoIElRmj+aGn8zkExmR6HAvu7PeqEhhg6/kFhawczC+GQsjtkNwwt8+PBKQDmMAb4jR464VU+NbwbUUPx7UGB+/qIXPsvLkzT2eOBw79TUcJPA3YLdzRiWE8gD3gOQe/78cVqRNjeX6diVlRnqAVWN/ySgyObr0ObyZZSrhgVZMjF+N6HDK7AbL6HyMOOxHMRzKID9APsU3aEV8y6q0A3UclWNk1OnBuuDFihgAGdxiaQWBb0b3k4BxDvveSiE6MCcavUUCWtJNwe0xM0AOj5FsX4FJWJW3itpcNnk5BkaSnNzIzuWygfJkIVCMj5+moYU8o6FWCJeoJunGrJ+ncmUS3Q3dhzn/VoofgeawT1gfsHtrHU/43Y7Hu8REfhEgiPMsXkqsnEXmJv6IUnSX0Iyo9dAv7K5ueJNFhdmvLFRI+vr/uf8+42NJZpb4jv3/dK288Gu/BpKJg0btPbBPeGvNYEHZbPoRhNvS8HoO3ARGrNUskC37aWlKd/CX/j8Ia/DtFOlOiDXzUtLk9QItDOl3W2SNnXsPcYePNjvdazxmONbH9ZGO20aaYoFmGRJfyesJu86TndCxE8pm+39dLHYO+T24G6HGQxGvBjkBdS7Qjce1bhn5eXlKd+Bhp4tFIv2PsyqbCd135nk2jV4qrE+NiycRxodr0kOHOh7ERhF3E2Uz3d/joHD5GBH2Acev91YdBUAGPYun9vHnRcaBxk8gzdbKQCDtQpVhA1vCMvqXBTx+iib7X6WtxxiTlwcCqDXZwqMjAx55ReHoQbw5kMPLMrmT06eY2FB54vgwUxJhsUw9l4V8foony8/zWuNM7GogKgM+45Q410uKgCwrfKpFUMmcsebSxXoXBXx+iiX6z3AhLIDB2vydmIkamsF3N9Ya2LinG9eK4Zh2HmYKZDPl8dEvD7CxoakZAdzhNDc3Khn6e28gTOzP/5dZhWJeWC7dcCrq7OcAu562WzpWRGvj9Lp7j1sq3ZDKEqOHRvwtvKdBLfLAT5Z+XXarbewMOGbXyh02yJeH2GHi4RTuI/xXAfLoSL09x+iluGFimAuXDjNeaD5lmJw8EiTAiJ4/nc+11uf564V7Ij+acd7IUaalvxjKytSS0g6scwsWVtzW28RDD4XFye9sWB4cWTkDHnhhVVvTLtdnr1jVmdGUBTzZRFnWwop5i/aKUAXpHFuED3auAhg/PzzyxQA+qnFxQnK+I53UKCV5XnG3NOnjzcSH/KC+t/K5Yop4mxLsmze9ANvKABGjqB75S0PxvYPxaamqrR3xw5bq02STrtEb/nYoYTn9fVL9B5pYOCLpK+vv2mThAIhxfpRpVL5kIizLSmScd0PvG59JKMaJ9PT7kWUaM3BY+5xD+fW+p2qZ02+52Fz5+YueuW6UYIb3sd1YzpdOili3JbkoH5FtDhjCMIVDHO3qAAYpRKXZLhrgtUBHFWF3/Qwd2ZmtAGe44YCBprC277WeSfS9eSoB5irIsylKJULC2NtFWAWxvtWIQNGtwlPMhm44EKlayjgshqy7mFvEjFuS4lE+hlWPtFey5L+VmODchk1fXLyvK/RE3OilZKYMzDwTBNYVC7kwt5UwfVKXT5YVWI38B+EiLMt2XaxIEvRe7JsvJHNdvfkcl1pRTaaSisTAMuxE5wImmdeOWyKSHY+5nHbxt6jctWv8l0vqLFblQr9T2J3hIy37Xw/fxcPJWTZuM1CyuO6ZxDvuLVG1WkVNkwxAC13PVY/TzTCc3l5xpsHD7GbB/peMX/rOE882ozyPiif7zEi4dTPqHd8VYPFbIyWUZTYnu7PkKee6ie23UU3P/dk5Z+DdWo1994HjN0+mSh4BsIBvqen52MinvsiXMdHo4lxRTaoEgDAGj++1IrcANyiNEs6zQFYf35+jCY37+GQEvvBf12JdiL88RdWE6/KUvQPimxsiWD9oP3K8N+PHh2g+dQ8jzaT/7Lt4oAo/4EQKkOxWAzqenI6GtmLSoU/5nwgPPD1qhIJp/6uqYnr+VzPi/gTr2kMG+c+21IUc73dv6MPlODiUmlfJpstHw+H4yuSpL8sy/qrcjB6QwpGv61I5kuZTGkkmy1X8I9kfdojdjI3rCjm7yhYrgGUZePtiJao4Y9HQdT/J3V2OofCWvKbYTX+SiKRrXJKPqSH9L+k/wCILgNgmPdaeAAAAABJRU5ErkJggg==';
const SIZE_PRESETS = Object.freeze({
compact: Object.freeze({ width: 320, height: 400 }),
standard: Object.freeze({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }),
large: Object.freeze({ width: 440, height: 640 })
});
const storageKey = `chatOverlayLayout:${location.origin}`;
const systemTheme = window.matchMedia('(prefers-color-scheme: light)');
let context = null;
let opened = false;
let destroyed = false;
let saveTimer = null;
let applyingLayout = false;
let preferencesLoaded = false;
let startStateApplied = false;
let refreshGeneration = 0;
let sendGeneration = 0;
let sending = false;
let unreadCount = 0;
let layout = {
mode: 'right',
x: 24,
y: 72,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
customWidth: DEFAULT_WIDTH,
customHeight: DEFAULT_HEIGHT,
detachedInitialized: false,
launcherX: null,
launcherY: null,
launcherInitialized: false
};
let chatPosition = 'right';
let chatSize = 'standard';
let chatStartMode = 'bubble';
let chatReactionDisplay = 'chat';
let themeMode = 'system';
let themePalette = 'eucalyptus';
let pageDockTarget = null;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const renderedActivityIds = new Set();
const renderedActivityIdOrder = [];
const host = document.createElement('div');
host.id = 'koalasync-chat-overlay-host';
host.style.cssText = 'all:initial;display:none;position:fixed;inset:0;z-index:2147483647;pointer-events:none;contain:layout style paint;transform:translateZ(0);';
const shadow = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = `
:host { all: initial; }
* { box-sizing: border-box; }
#app {
--bg: oklch(0.205 0.025 155); --card: oklch(0.30 0.035 150);
--surface-alt: oklch(0.265 0.032 150); --surface-deep: oklch(0.205 0.025 155);
--accent: oklch(0.73 0.13 155); --accent-hover: oklch(0.79 0.12 155);
--text: oklch(0.96 0.01 90); --text-muted: oklch(0.78 0.02 90);
--border-soft: oklch(0.88 0.025 145 / 0.09); --border-strong: oklch(0.39 0.038 145);
--text-on-green: oklch(0.14 0.02 90); --danger: oklch(0.63 0.18 25);
position: fixed; inset: 0; pointer-events: none; color: var(--text);
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#app[data-theme="light"] {
--bg: oklch(0.935 0.018 110); --card: oklch(0.995 0.006 100);
--surface-alt: oklch(0.91 0.022 115); --surface-deep: oklch(0.935 0.018 110);
--accent: oklch(0.52 0.13 155); --accent-hover: oklch(0.45 0.12 155);
--text: oklch(0.21 0.03 140); --text-muted: oklch(0.43 0.03 135);
--border-soft: oklch(0.28 0.035 140 / 0.10); --border-strong: oklch(0.73 0.035 125);
--text-on-green: oklch(0.16 0.03 140);
}
#app[data-palette="cyber"] {
--bg: oklch(0.185 0.04 265); --card: oklch(0.285 0.05 265);
--surface-alt: oklch(0.25 0.048 265); --surface-deep: oklch(0.185 0.04 265);
--accent: oklch(0.59 0.20 275); --accent-hover: oklch(0.69 0.17 275);
--text: oklch(0.97 0.012 265); --text-muted: oklch(0.74 0.03 265);
--border-soft: oklch(0.9 0.03 265 / 0.10); --border-strong: oklch(0.42 0.05 265);
--text-on-green: oklch(0.99 0.006 275);
}
#app[data-palette="cyber"][data-theme="light"] {
--bg: oklch(0.945 0.014 265); --card: oklch(0.995 0.005 265);
--surface-alt: oklch(0.915 0.02 265); --surface-deep: oklch(0.945 0.014 265);
--accent: oklch(0.53 0.21 275); --accent-hover: oklch(0.46 0.20 275);
--text: oklch(0.26 0.05 265); --text-muted: oklch(0.48 0.045 265);
--border-soft: oklch(0.30 0.05 265 / 0.12); --border-strong: oklch(0.79 0.03 265);
--text-on-green: oklch(0.99 0.005 275);
}
#app[data-palette="graphite"] {
--bg: oklch(0.165 0.004 260); --card: oklch(0.255 0.005 260);
--surface-alt: oklch(0.225 0.005 260); --surface-deep: oklch(0.165 0.004 260);
--accent: oklch(0.93 0.006 260); --accent-hover: oklch(0.99 0.003 260);
--text: oklch(0.97 0.003 260); --text-muted: oklch(0.72 0.005 260);
--border-soft: oklch(0.92 0.008 260 / 0.11); --border-strong: oklch(0.42 0.006 260);
--text-on-green: oklch(0.20 0.004 260);
}
#app[data-palette="graphite"][data-theme="light"] {
--bg: oklch(0.928 0.003 260); --card: oklch(1 0 0);
--surface-alt: oklch(0.90 0.004 260); --surface-deep: oklch(0.928 0.003 260);
--accent: oklch(0.28 0.006 260); --accent-hover: oklch(0.18 0.005 260);
--text: oklch(0.22 0.005 260); --text-muted: oklch(0.46 0.006 260);
--border-soft: oklch(0.20 0.006 260 / 0.13); --border-strong: oklch(0.77 0.004 260);
--text-on-green: oklch(0.98 0.002 260);
}
button, textarea { font: inherit; }
button { color: inherit; }
.launcher {
position: fixed; width: 48px; height: 48px; border: 1px solid var(--border-strong);
border-radius: 16px; background: var(--card); color: var(--text); cursor: pointer;
pointer-events: auto; box-shadow: 0 10px 28px rgb(0 0 0 / .32);
touch-action: none; user-select: none;
}
.launcher.docked-left { left: 0 !important; right: auto !important; border-left: 0; border-radius: 0 16px 16px 0; }
.launcher.docked-right { left: auto !important; right: 0 !important; border-right: 0; border-radius: 16px 0 0 16px; }
.launcher:hover:not([aria-disabled="true"]) { border-color: var(--accent); transform: translateY(-1px); }
.launcher[aria-disabled="true"] { cursor: not-allowed; opacity: .58; }
.launcher-icon { display: block; width: 36px; height: 36px; margin: auto; pointer-events: none; }
.launcher-bubble-mark {
position: absolute; right: 3px; bottom: 3px; width: 15px; height: 12px;
border: 2px solid var(--card); border-radius: 7px; background: var(--accent); pointer-events: none;
}
.launcher-bubble-mark::after {
content: ""; position: absolute; right: 0; bottom: -4px;
width: 5px; height: 5px; background: var(--accent); clip-path: polygon(0 0, 100% 0, 100% 100%);
}
.unread {
position: absolute; top: -7px; right: -7px; display: none; min-width: 22px; height: 22px;
align-items: center; justify-content: center; padding: 0 6px; border: 2px solid var(--card);
border-radius: 999px; background: var(--danger); color: white; font-size: 11px; font-weight: 800;
line-height: 1; pointer-events: none;
}
.unread.visible { display: flex; }
.panel {
position: fixed; display: none; flex-direction: column; overflow: hidden;
min-width: min(${MIN_WIDTH}px, calc(100vw - 16px)); min-height: min(${MIN_HEIGHT}px, calc(100vh - 16px)); max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px); pointer-events: auto; color: var(--text);
background: var(--card); border: 1px solid var(--border-strong); border-radius: 18px;
box-shadow: 0 18px 55px rgb(0 0 0 / .38); transform: translateZ(0);
}
.panel.open { display: flex; }
.panel.docked { border-radius: 0; max-height: 100vh; }
.header { display: flex; align-items: center; gap: 10px; padding: 12px; border-bottom: 1px solid var(--border-soft); user-select: none; }
.header.detached { cursor: move; }
.heading { min-width: 0; flex: 1; }
.title { font-size: 14px; font-weight: 760; }
.subtitle { color: var(--text-muted); font-size: 11px; }
.modes { display: flex; gap: 4px; }
.icon-button { width: 30px; height: 30px; border: 1px solid var(--border-soft); border-radius: 9px; background: var(--surface-alt); cursor: pointer; }
.icon-button:hover, .icon-button.active { border-color: var(--accent); color: var(--accent); }
.messages { flex: 1; min-height: 0; overflow-y: auto; padding: 12px; background: var(--bg); overscroll-behavior: contain; }
.empty { color: var(--text-muted); text-align: center; padding: 32px 12px; font-size: 12px; }
.message { margin-bottom: 11px; overflow-wrap: anywhere; }
.meta { display: flex; align-items: baseline; gap: 7px; margin-bottom: 2px; }
.sender { color: var(--accent); font-weight: 700; font-size: 12px; }
.time { color: var(--text-muted); font-size: 10px; }
.body { white-space: pre-wrap; color: var(--text); }
.event-message {
display: flex; align-items: center; justify-content: center; gap: 7px; margin: 8px 0;
color: var(--text-muted); font-size: 11px; text-align: center;
}
.event-message::before, .event-message::after { content: ""; flex: 1; height: 1px; background: var(--border-soft); }
.event-text { max-width: 78%; }
.composer { padding: 10px; border-top: 1px solid var(--border-soft); background: var(--card); }
.quick-reactions { display: flex; align-items: center; gap: 5px; margin-bottom: 8px; }
.reaction-button {
width: 34px; height: 30px; border: 1px solid var(--border-soft); border-radius: 9px;
background: var(--surface-alt); cursor: pointer; font-size: 18px; line-height: 1;
}
.reaction-button:hover:not(:disabled), .reaction-button:focus-visible { border-color: var(--accent); transform: translateY(-1px); }
.reaction-button:disabled { opacity: .5; cursor: wait; }
textarea { width: 100%; min-height: 62px; max-height: 132px; resize: vertical; border: 1px solid var(--border-strong); border-radius: 11px; padding: 9px; background: var(--surface-deep); color: var(--text); outline: none; }
textarea:focus { border-color: var(--accent); }
textarea::placeholder { color: var(--text-muted); }
.composer-row { display: flex; align-items: center; gap: 8px; margin-top: 7px; }
.count { flex: 1; color: var(--text-muted); font-size: 10px; }
.status { color: var(--danger); font-size: 10px; min-height: 14px; }
.send { border: 0; border-radius: 10px; padding: 8px 13px; background: var(--accent); color: var(--text-on-green); font-weight: 750; cursor: pointer; }
.send:hover:not(:disabled) { background: var(--accent-hover); }
.send:disabled { opacity: .55; cursor: wait; }
.reaction-layer { position: fixed; inset: 0; overflow: hidden; pointer-events: none; z-index: 1; }
.reaction-particle {
position: absolute; top: -12vh; font-size: clamp(24px, 4vw, 48px);
filter: drop-shadow(0 4px 7px rgb(0 0 0 / .35));
animation: reaction-fall var(--reaction-duration) cubic-bezier(.22,.62,.45,1) var(--reaction-delay) forwards;
}
.launcher, .panel { z-index: 2; }
@keyframes reaction-fall {
0% { transform: translate3d(0, -8vh, 0) rotate(var(--reaction-start-rotation)); opacity: 0; }
12% { opacity: 1; }
100% { transform: translate3d(var(--reaction-drift), 116vh, 0) rotate(var(--reaction-end-rotation)); opacity: .92; }
}
.visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 520px) { .panel { max-width: calc(100vw - 12px); } }
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
.reaction-particle { animation: none !important; display: none !important; }
}
`;
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
const app = element('div');
app.id = 'app';
const launcher = element('button', 'launcher');
launcher.type = 'button';
const launcherIcon = element('img', 'launcher-icon');
launcherIcon.src = LAUNCHER_ICON_DATA_URL;
launcherIcon.alt = '';
const launcherBubbleMark = element('span', 'launcher-bubble-mark');
launcherBubbleMark.setAttribute('aria-hidden', 'true');
const unreadBadge = element('span', 'unread');
unreadBadge.setAttribute('aria-hidden', 'true');
launcher.append(launcherIcon, launcherBubbleMark, unreadBadge);
const launcherHint = element('span', 'visually-hidden');
launcherHint.id = 'chat-launcher-hint';
const panel = element('section', 'panel');
panel.setAttribute('role', 'dialog');
const header = element('header', 'header');
const heading = element('div', 'heading');
const title = element('div', 'title');
const subtitle = element('div', 'subtitle');
heading.append(title, subtitle);
const modes = element('div', 'modes');
const leftButton = element('button', 'icon-button', '←');
const detachedButton = element('button', 'icon-button', '✥');
const rightButton = element('button', 'icon-button', '→');
const closeButton = element('button', 'icon-button', '×');
for (const button of [leftButton, detachedButton, rightButton, closeButton]) button.type = 'button';
modes.append(leftButton, detachedButton, rightButton, closeButton);
header.append(heading, modes);
const messages = element('div', 'messages');
messages.setAttribute('aria-live', 'polite');
const empty = element('div', 'empty');
messages.append(empty);
const composer = element('form', 'composer');
const quickReactions = element('div', 'quick-reactions');
quickReactions.setAttribute('role', 'group');
const reactionButtons = QUICK_REACTIONS.map(reaction => {
const button = element('button', 'reaction-button', reaction);
button.type = 'button';
button.dataset.reaction = reaction;
quickReactions.append(button);
return button;
});
const textareaLabel = element('label', 'visually-hidden');
textareaLabel.htmlFor = 'chat-composer-input';
const textarea = element('textarea');
textarea.id = 'chat-composer-input';
textarea.setAttribute('aria-describedby', 'chat-composer-count chat-composer-status');
const composerRow = element('div', 'composer-row');
const count = element('span', 'count', `0/${MAX_COMPOSER_CODE_POINTS}`);
count.id = 'chat-composer-count';
const sendButton = element('button', 'send');
sendButton.type = 'submit';
composerRow.append(count, sendButton);
const status = element('div', 'status');
status.id = 'chat-composer-status';
status.setAttribute('role', 'status');
composer.append(quickReactions, textareaLabel, textarea, composerRow, status);
panel.append(header, messages, composer);
const reactionLayer = element('div', 'reaction-layer');
reactionLayer.setAttribute('aria-hidden', 'true');
app.append(reactionLayer, launcherHint, launcher, panel);
shadow.append(style, app);
document.documentElement.append(host);
const pageDockStyle = document.createElement('style');
pageDockStyle.id = 'koalasync-chat-page-dock-style';
pageDockStyle.textContent = `
html[${PAGE_DOCK_ATTRIBUTE}] {
box-sizing: border-box !important;
width: calc(100% - var(${PAGE_DOCK_WIDTH})) !important;
max-width: calc(100% - var(${PAGE_DOCK_WIDTH})) !important;
overflow-x: clip !important;
}
html[${PAGE_DOCK_ATTRIBUTE}="left"] {
margin-left: var(${PAGE_DOCK_WIDTH}) !important;
margin-right: 0 !important;
}
html[${PAGE_DOCK_ATTRIBUTE}="right"] {
margin-left: 0 !important;
margin-right: var(${PAGE_DOCK_WIDTH}) !important;
}
html[${PAGE_DOCK_ATTRIBUTE}] > body {
width: 100% !important;
max-width: 100% !important;
min-height: 100vh !important;
overflow-x: clip !important;
clip-path: inset(0) !important;
contain: layout paint !important;
}
[${PAGE_DOCK_ATTRIBUTE}]:not(html) > :not(#koalasync-chat-overlay-host) {
box-sizing: border-box !important;
width: calc(100% - var(${PAGE_DOCK_WIDTH})) !important;
max-width: calc(100% - var(${PAGE_DOCK_WIDTH})) !important;
overflow-x: clip !important;
clip-path: inset(0) !important;
contain: layout paint !important;
}
[${PAGE_DOCK_ATTRIBUTE}="left"]:not(html) > :not(#koalasync-chat-overlay-host) {
margin-left: var(${PAGE_DOCK_WIDTH}) !important;
margin-right: 0 !important;
}
[${PAGE_DOCK_ATTRIBUTE}="right"]:not(html) > :not(#koalasync-chat-overlay-host) {
margin-left: 0 !important;
margin-right: var(${PAGE_DOCK_WIDTH}) !important;
}
`;
document.documentElement.append(pageDockStyle);
function messageRuntime(payload, timeoutMs = 5000) {
return new Promise(resolve => {
let settled = false;
const finish = response => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve(response || null);
};
const timeout = setTimeout(() => finish(null), timeoutMs);
try {
chrome.runtime.sendMessage(payload, response => {
if (chrome.runtime.lastError) finish(null);
else finish(response);
});
} catch (_) {
finish(null);
}
});
}
function setLocalStorage(values) {
if (destroyed) return;
try {
const result = chrome.storage.local.set(values);
if (result?.catch) result.catch(() => {});
} catch (_) {
// Extension context was invalidated while the page remained open.
}
}
function strings() {
return context?.strings || {};
}
function applyStrings() {
const text = strings();
title.textContent = text.title || '';
subtitle.textContent = text.liveOnly || '';
launcher.setAttribute('aria-label', text.open || '');
const unavailableHint = context?.supported && !context?.hasKey
? (text.missingKey || '')
: context?.supported && !context?.connected ? (text.sendFailed || '') : '';
launcher.title = unavailableHint || text.open || '';
launcherHint.textContent = unavailableHint;
if (unavailableHint) launcher.setAttribute('aria-describedby', launcherHint.id);
else launcher.removeAttribute('aria-describedby');
panel.setAttribute('aria-label', text.title || '');
textarea.placeholder = text.placeholder || '';
textareaLabel.textContent = text.placeholder || text.title || '';
sendButton.textContent = text.send || '';
empty.textContent = text.empty || '';
leftButton.title = text.dockLeft || '';
detachedButton.title = text.detached || '';
rightButton.title = text.dockRight || '';
closeButton.title = text.close || '';
leftButton.setAttribute('aria-label', text.dockLeft || '');
detachedButton.setAttribute('aria-label', text.detached || '');
rightButton.setAttribute('aria-label', text.dockRight || '');
closeButton.setAttribute('aria-label', text.close || '');
quickReactions.setAttribute('aria-label', text.quickReactions || text.title || '');
for (const button of reactionButtons) {
const label = `${text.quickReactions || text.title || ''} ${button.dataset.reaction}`.trim();
button.title = label;
button.setAttribute('aria-label', label);
}
applyUnreadCount();
}
function applyTheme() {
const light = themeMode === 'light' || (themeMode === 'system' && systemTheme.matches);
app.dataset.theme = light ? 'light' : 'dark';
app.dataset.palette = ['eucalyptus', 'cyber', 'graphite'].includes(themePalette) ? themePalette : 'eucalyptus';
}
function viewportBounds() {
return { width: Math.max(1, window.innerWidth), height: Math.max(1, window.innerHeight) };
}
function normalizePosition(value) {
return ['left', 'right', 'detached'].includes(value) ? value : 'right';
}
function normalizeSize(value) {
return ['compact', 'standard', 'large', 'custom'].includes(value) ? value : 'standard';
}
function preferredSize() {
return SIZE_PRESETS[chatSize] || {
width: Number(layout.customWidth) || DEFAULT_WIDTH,
height: Number(layout.customHeight) || DEFAULT_HEIGHT
};
}
function clampLauncher() {
const viewport = viewportBounds();
const maxX = Math.max(0, viewport.width - LAUNCHER_SIZE - EDGE_INSET);
const maxY = Math.max(0, viewport.height - LAUNCHER_SIZE - EDGE_INSET);
layout.launcherX = Math.min(Math.max(Number(layout.launcherX) || EDGE_INSET, EDGE_INSET), maxX);
layout.launcherY = Math.min(Math.max(Number(layout.launcherY) || EDGE_INSET, EDGE_INSET), maxY);
}
function initializeLauncher() {
if (layout.launcherInitialized) return;
const viewport = viewportBounds();
layout.launcherX = chatPosition === 'left'
? Math.min(16, Math.max(EDGE_INSET, viewport.width - LAUNCHER_SIZE - EDGE_INSET))
: Math.max(EDGE_INSET, viewport.width - LAUNCHER_SIZE - 16);
layout.launcherY = Math.max(EDGE_INSET, Math.round(viewport.height / 2 - LAUNCHER_SIZE / 2));
layout.launcherInitialized = true;
clampLauncher();
}
function clearPageDock() {
const targets = new Set([pageDockTarget, document.documentElement].filter(Boolean));
for (const target of targets) {
target.removeAttribute(PAGE_DOCK_ATTRIBUTE);
target.style.removeProperty(PAGE_DOCK_WIDTH);
}
pageDockTarget = null;
}
function applyPageDock(side, width) {
if (!opened) {
clearPageDock();
return;
}
const target = document.fullscreenElement || document.documentElement;
if (pageDockTarget && pageDockTarget !== target) clearPageDock();
pageDockTarget = target;
target.setAttribute(PAGE_DOCK_ATTRIBUTE, side);
target.style.setProperty(PAGE_DOCK_WIDTH, `${width}px`);
}
function applyUnreadCount() {
unreadBadge.textContent = unreadCount > 99 ? '99+' : String(unreadCount);
unreadBadge.classList.toggle('visible', unreadCount > 0);
const openLabel = strings().open || '';
launcher.setAttribute('aria-label', unreadCount > 0 ? `${openLabel} (${unreadCount})` : openLabel);
}
function setUnreadCount(next) {
unreadCount = Math.max(0, Number.isFinite(next) ? Math.trunc(next) : 0);
applyUnreadCount();
}
function clampDetached() {
const viewport = viewportBounds();
const maxWidth = Math.max(1, Math.min(600, viewport.width - 16));
const maxHeight = Math.max(1, viewport.height - 16);
const horizontalInset = Math.min(8, Math.max(0, Math.floor((viewport.width - 1) / 2)));
const verticalInset = Math.min(8, Math.max(0, Math.floor((viewport.height - 1) / 2)));
layout.width = Math.min(Math.max(Number(layout.width) || DEFAULT_WIDTH, Math.min(MIN_WIDTH, maxWidth)), maxWidth);
layout.height = Math.min(Math.max(Number(layout.height) || DEFAULT_HEIGHT, Math.min(MIN_HEIGHT, maxHeight)), maxHeight);
layout.x = Math.min(Math.max(Number(layout.x) || horizontalInset, horizontalInset), Math.max(horizontalInset, viewport.width - layout.width - horizontalInset));
layout.y = Math.min(Math.max(Number(layout.y) || verticalInset, verticalInset), Math.max(verticalInset, viewport.height - layout.height - verticalInset));
}
function saveLayout() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveTimer = null;
setLocalStorage({ [storageKey]: layout });
}, 150);
}
function applyLayout() {
if (destroyed) return;
applyingLayout = true;
const size = preferredSize();
launcher.classList.toggle('docked-left', layout.mode === 'left');
launcher.classList.toggle('docked-right', layout.mode === 'right');
if (layout.mode === 'detached') {
initializeLauncher();
clampLauncher();
launcher.style.left = `${layout.launcherX}px`;
launcher.style.right = 'auto';
launcher.style.top = `${layout.launcherY}px`;
} else {
launcher.style.left = layout.mode === 'left' ? '0' : 'auto';
launcher.style.right = layout.mode === 'right' ? '0' : 'auto';
launcher.style.top = `${Math.max(0, Math.round(window.innerHeight / 2 - LAUNCHER_SIZE / 2))}px`;
}
panel.style.right = 'auto';
panel.style.left = 'auto';
panel.style.bottom = 'auto';
panel.style.resize = 'none';
panel.classList.toggle('docked', layout.mode !== 'detached');
header.classList.toggle('detached', layout.mode === 'detached');
leftButton.classList.toggle('active', layout.mode === 'left');
detachedButton.classList.toggle('active', layout.mode === 'detached');
rightButton.classList.toggle('active', layout.mode === 'right');
if (layout.mode === 'detached') {
clearPageDock();
clampDetached();
panel.style.left = `${layout.x}px`;
panel.style.top = `${layout.y}px`;
panel.style.width = `${layout.width}px`;
panel.style.height = `${layout.height}px`;
panel.style.resize = 'both';
} else {
const viewport = viewportBounds();
const dockWidth = Math.max(1, Math.min(size.width, viewport.width - 16));
panel.style.top = '0';
panel.style.bottom = '0';
panel.style.width = `${dockWidth}px`;
panel.style.height = `${viewport.height}px`;
panel.style[layout.mode] = '0';
applyPageDock(layout.mode, dockWidth);
}
globalThis.queueMicrotask(() => { applyingLayout = false; });
}
function setMode(mode, persistPreference = true) {
mode = normalizePosition(mode);
if (mode === 'detached' && !layout.detachedInitialized) {
const size = preferredSize();
layout.x = Math.max(8, Math.round((window.innerWidth - size.width) / 2));
layout.y = Math.max(8, Math.round((window.innerHeight - size.height) / 2));
layout.detachedInitialized = true;
}
chatPosition = mode;
layout.mode = mode;
applyLayout();
saveLayout();
if (persistPreference) setLocalStorage({ chatPosition: mode });
}
function setSize(size, persistPreference = true) {
chatSize = normalizeSize(size);
const preset = SIZE_PRESETS[chatSize];
if (preset) {
layout.width = preset.width;
layout.height = preset.height;
} else {
layout.width = Number(layout.customWidth) || DEFAULT_WIDTH;
layout.height = Number(layout.customHeight) || DEFAULT_HEIGHT;
}
if (layout.mode === 'detached') clampDetached();
applyLayout();
saveLayout();
if (persistPreference) setLocalStorage({ chatSize });
}
function setOpened(next) {
opened = !!next && !!context?.enabled;
panel.classList.toggle('open', opened);
launcher.style.display = opened ? 'none' : '';
if (opened) {
setUnreadCount(0);
textarea.focus();
messages.scrollTop = messages.scrollHeight;
}
applyLayout();
}
function applyContext(next) {
const previousRoomId = context?.roomId;
context = next || null;
const supported = !!context?.supported;
const optedIn = !!context?.enabled;
const hasKey = !!context?.hasKey;
const connected = !!context?.connected;
context = context ? { ...context, enabled: supported && optedIn && hasKey && connected } : null;
if (previousRoomId && previousRoomId !== context?.roomId) {
clearMessages();
startStateApplied = false;
}
host.style.display = supported && optedIn ? '' : 'none';
launcher.setAttribute('aria-disabled', String(!context?.enabled));
if (!optedIn) startStateApplied = false;
if (!context?.enabled) {
setOpened(false);
} else if (preferencesLoaded && !startStateApplied) {
startStateApplied = true;
setOpened(chatStartMode === 'open');
}
applyStrings();
applyLayout();
if (context?.enabled && Array.isArray(context.activity)) {
for (const event of context.activity) appendEvent(event);
}
}
async function refresh() {
if (destroyed) return;
const generation = ++refreshGeneration;
const next = await messageRuntime({ type: 'GET_CHAT_CONTEXT' });
if (destroyed || generation !== refreshGeneration) return;
applyContext(next);
}
function appendMessage(message) {
if (!context?.enabled || !message || typeof message.text !== 'string') return;
if (empty.isConnected) empty.remove();
const wrapper = element('article', 'message');
const meta = element('div', 'meta');
const own = message.senderId === context.peerId;
if (!opened && !own) setUnreadCount(unreadCount + 1);
const sender = element('span', 'sender', own ? (strings().you || '') : (message.username || message.senderId || ''));
const time = element('time', 'time');
const date = new Date(message.timestamp);
time.textContent = Number.isFinite(date.getTime()) ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
meta.append(sender, time);
const body = element('div', 'body');
const tokens = globalThis.KoalaSyncChatFormat?.tokenizeChatText(message.text) || [{ type: 'text', text: message.text }];
for (const token of tokens) {
const node = token.type === 'strong' ? document.createElement('strong') : token.type === 'em' ? document.createElement('em') : document.createTextNode(token.text);
if (node.nodeType === 1) node.textContent = token.text;
body.append(node);
}
wrapper.append(meta, body);
messages.append(wrapper);
while (messages.querySelectorAll('.message').length > MAX_MESSAGES) messages.querySelector('.message')?.remove();
messages.scrollTop = messages.scrollHeight;
if (QUICK_REACTIONS.includes(message.text.trim())) triggerReactionRain(message.text.trim());
}
function appendEvent(event) {
if (!context?.enabled || !event || typeof event.action !== 'string') return;
if (typeof event.id === 'string' && renderedActivityIds.has(event.id)) return;
const own = event.senderId === context.peerId;
const displayName = own ? (strings().you || '') : (event.username || event.senderId || '');
const labels = {
play: strings().eventPlay,
pause: strings().eventPause,
seek: strings().eventSeek,
force_sync_prepare: strings().eventForcePrepare,
force_sync_execute: strings().eventForceExecute
};
let eventText = '';
if (event.action === 'joined' || event.action === 'left') {
const template = event.action === 'joined' ? strings().eventJoined : strings().eventLeft;
eventText = (template || '{name}').replace('{name}', displayName);
} else if (labels[event.action]) {
eventText = (strings().eventAction || '{name} {action}')
.replace('{name}', displayName)
.replace('{action}', labels[event.action]);
}
if (!eventText) return;
if (typeof event.id === 'string') {
renderedActivityIds.add(event.id);
renderedActivityIdOrder.push(event.id);
while (renderedActivityIdOrder.length > MAX_RENDERED_ACTIVITY_IDS) {
renderedActivityIds.delete(renderedActivityIdOrder.shift());
}
}
if (empty.isConnected) empty.remove();
if (!opened && context.eventNotifications) setUnreadCount(unreadCount + 1);
const wrapper = element('article', 'message event-message');
const body = element('span', 'event-text', eventText);
const time = element('time', 'time');
const date = new Date(event.timestamp);
time.textContent = Number.isFinite(date.getTime()) ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
wrapper.append(body, time);
messages.append(wrapper);
while (messages.querySelectorAll('.message').length > MAX_MESSAGES) messages.querySelector('.message')?.remove();
messages.scrollTop = messages.scrollHeight;
}
function clearMessages() {
messages.replaceChildren(empty);
renderedActivityIds.clear();
renderedActivityIdOrder.length = 0;
setUnreadCount(0);
}
function triggerReactionRain(reaction) {
if (chatReactionDisplay !== 'video' || reducedMotion.matches || !QUICK_REACTIONS.includes(reaction)) return;
const available = Math.max(0, MAX_REACTION_PARTICLES - reactionLayer.childElementCount);
const particleCount = Math.min(10, available);
for (let index = 0; index < particleCount; index++) {
const particle = element('span', 'reaction-particle', reaction);
particle.style.left = `${4 + Math.random() * 88}%`;
particle.style.setProperty('--reaction-duration', `${2.8 + Math.random() * 1.6}s`);
particle.style.setProperty('--reaction-delay', `${Math.random() * .7}s`);
particle.style.setProperty('--reaction-drift', `${-45 + Math.random() * 90}px`);
particle.style.setProperty('--reaction-start-rotation', `${-35 + Math.random() * 70}deg`);
particle.style.setProperty('--reaction-end-rotation', `${-160 + Math.random() * 320}deg`);
particle.addEventListener('animationend', () => particle.remove(), { once: true });
reactionLayer.append(particle);
}
}
function updateComposerState() {
const length = [...textarea.value].length;
count.textContent = `${length}/${MAX_COMPOSER_CODE_POINTS}`;
count.style.color = length > MAX_COMPOSER_CODE_POINTS ? 'var(--danger)' : '';
status.textContent = length > MAX_COMPOSER_CODE_POINTS ? (strings().tooLong || '') : '';
return length;
}
function resetComposer() {
sendGeneration++;
sending = false;
textarea.value = '';
updateComposerState();
status.textContent = '';
sendButton.disabled = false;
for (const button of reactionButtons) button.disabled = false;
}
let launcherDrag = null;
let suppressLauncherClick = false;
launcher.addEventListener('click', event => {
if (suppressLauncherClick && event.detail !== 0) {
suppressLauncherClick = false;
return;
}
suppressLauncherClick = false;
setOpened(true);
});
launcher.addEventListener('pointerdown', event => {
if (layout.mode !== 'detached') return;
launcherDrag = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
x: layout.launcherX,
y: layout.launcherY,
moved: false
};
launcher.setPointerCapture(event.pointerId);
});
launcher.addEventListener('pointermove', event => {
if (!launcherDrag || event.pointerId !== launcherDrag.pointerId) return;
const deltaX = event.clientX - launcherDrag.startX;
const deltaY = event.clientY - launcherDrag.startY;
if (Math.hypot(deltaX, deltaY) >= 4) launcherDrag.moved = true;
layout.launcherX = launcherDrag.x + deltaX;
layout.launcherY = launcherDrag.y + deltaY;
clampLauncher();
launcher.style.left = `${layout.launcherX}px`;
launcher.style.top = `${layout.launcherY}px`;
});
launcher.addEventListener('pointerup', event => {
if (!launcherDrag || event.pointerId !== launcherDrag.pointerId) return;
suppressLauncherClick = launcherDrag.moved;
launcherDrag = null;
saveLayout();
});
launcher.addEventListener('pointercancel', () => {
launcherDrag = null;
suppressLauncherClick = false;
});
closeButton.addEventListener('click', () => setOpened(false));
leftButton.addEventListener('click', () => setMode('left'));
detachedButton.addEventListener('click', () => setMode('detached'));
rightButton.addEventListener('click', () => setMode('right'));
textarea.addEventListener('input', updateComposerState);
const stopPageKeyboardShortcut = event => {
const path = typeof event.composedPath === 'function' ? event.composedPath() : [];
if (!path.includes(textarea) && !path.includes(composer)) return;
if (event.type === 'keydown' && event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
if (!sending) composer.requestSubmit();
}
event.stopImmediatePropagation();
};
for (const eventName of ['keydown', 'keyup', 'keypress']) {
window.addEventListener(eventName, stopPageKeyboardShortcut, true);
}
async function sendChatText(text, clearComposerValue = false) {
if (sending || !context?.enabled) return;
text = String(text || '').trim();
if (!text) return;
if ([...text].length > MAX_COMPOSER_CODE_POINTS) {
status.textContent = strings().tooLong || '';
return;
}
let chunks;
try {
chunks = globalThis.KoalaSyncChatFormat?.splitChatText(
text,
MAX_MESSAGE_CODE_POINTS,
MAX_CHAT_CHUNKS
);
} catch (_) {
status.textContent = strings().tooLong || '';
return;
}
if (!Array.isArray(chunks) || chunks.length === 0) return;
const generation = ++sendGeneration;
sending = true;
sendButton.disabled = true;
for (const button of reactionButtons) button.disabled = true;
status.textContent = '';
let completedChunks = 0;
let response = null;
for (const chunk of chunks) {
response = await messageRuntime({ type: 'CHAT_SEND', text: chunk });
if (destroyed || generation !== sendGeneration) return;
if (response?.status !== 'ok') break;
completedChunks++;
}
sending = false;
sendButton.disabled = false;
for (const button of reactionButtons) button.disabled = false;
if (completedChunks === chunks.length) {
if (clearComposerValue && textarea.value.trim() === text) {
textarea.value = '';
updateComposerState();
}
} else {
if (clearComposerValue && textarea.value.trim() === text) {
textarea.value = chunks.slice(completedChunks).join('\n');
updateComposerState();
}
status.textContent = response?.status === 'too_long' ? (strings().tooLong || '') : (strings().sendFailed || '');
}
}
composer.addEventListener('submit', async event => {
event.preventDefault();
await sendChatText(textarea.value, true);
});
for (const button of reactionButtons) {
button.addEventListener('click', () => sendChatText(button.dataset.reaction));
}
let drag = null;
header.addEventListener('pointerdown', event => {
if (layout.mode !== 'detached' || event.target.closest('button')) return;
drag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, x: layout.x, y: layout.y };
header.setPointerCapture(event.pointerId);
});
header.addEventListener('pointermove', event => {
if (!drag || event.pointerId !== drag.pointerId) return;
layout.x = drag.x + event.clientX - drag.startX;
layout.y = drag.y + event.clientY - drag.startY;
clampDetached();
applyLayout();
});
header.addEventListener('pointerup', event => {
if (!drag || event.pointerId !== drag.pointerId) return;
drag = null;
saveLayout();
});
const resizeObserver = new globalThis.ResizeObserver(() => {
if (applyingLayout || layout.mode !== 'detached') return;
const rect = panel.getBoundingClientRect();
if (!opened || !rect?.width || !rect.height) return;
if (Math.abs(rect.width - layout.width) < 1 && Math.abs(rect.height - layout.height) < 1) return;
layout.width = rect.width;
layout.height = rect.height;
layout.customWidth = rect.width;
layout.customHeight = rect.height;
if (chatSize !== 'custom') {
chatSize = 'custom';
setLocalStorage({ chatSize });
}
clampDetached();
saveLayout();
});
resizeObserver.observe(panel);
function moveIntoFullscreen() {
const parent = document.fullscreenElement || document.documentElement;
if (parent && host.parentNode !== parent) parent.append(host);
globalThis.requestAnimationFrame(applyLayout);
}
function handleResize() {
clampLauncher();
applyLayout();
saveLayout();
if (layout.mode === 'detached') {
clampDetached();
}
}
function handleSystemTheme() {
if (themeMode === 'system') applyTheme();
}
function handleStorage(changes, area) {
if (destroyed || area !== 'local') return;
if (changes.themeMode) themeMode = changes.themeMode.newValue || 'system';
if (changes.themePalette) themePalette = changes.themePalette.newValue || 'eucalyptus';
if (changes.themeMode || changes.themePalette) applyTheme();
if (changes.chatPosition) setMode(changes.chatPosition.newValue, false);
if (changes.chatSize) setSize(changes.chatSize.newValue, false);
if (changes.chatStartMode) {
chatStartMode = changes.chatStartMode.newValue === 'open' ? 'open' : 'bubble';
if (context?.enabled) {
startStateApplied = true;
setOpened(chatStartMode === 'open');
}
}
if (changes.chatReactionDisplay) {
chatReactionDisplay = changes.chatReactionDisplay.newValue === 'video' ? 'video' : 'chat';
}
if (changes.locale) refresh();
}
function handleRuntime(message) {
if (message?.type === 'CHAT_MESSAGE') appendMessage(message.message);
if (message?.type === 'CHAT_EVENT') appendEvent(message.event);
if (message?.type === 'CHAT_CONTEXT_UPDATE' || message?.type === 'CONNECTION_STATUS') refresh();
if (message?.type === 'CHAT_RESET') {
clearMessages();
resetComposer();
refresh();
}
if (message?.type === 'CHAT_DESTROY' || message?.type === 'TARGET_DEACTIVATE') destroy();
}
function destroy() {
if (destroyed) return;
destroyed = true;
refreshGeneration++;
sendGeneration++;
if (saveTimer) clearTimeout(saveTimer);
resizeObserver.disconnect();
clearPageDock();
pageDockStyle.remove();
for (const eventName of ['keydown', 'keyup', 'keypress']) {
window.removeEventListener(eventName, stopPageKeyboardShortcut, true);
}
document.removeEventListener('fullscreenchange', moveIntoFullscreen);
window.removeEventListener('resize', handleResize);
systemTheme.removeEventListener('change', handleSystemTheme);
try { chrome.storage.onChanged.removeListener(handleStorage); } catch (_) { /* invalidated context */ }
try { chrome.runtime.onMessage.removeListener(handleRuntime); } catch (_) { /* invalidated context */ }
host.remove();
window.koalaSyncChatOverlay = null;
}
document.addEventListener('fullscreenchange', moveIntoFullscreen);
window.addEventListener('resize', handleResize, { passive: true });
systemTheme.addEventListener('change', handleSystemTheme);
chrome.storage.onChanged.addListener(handleStorage);
chrome.runtime.onMessage.addListener(handleRuntime);
chrome.storage.local.get([storageKey, 'themeMode', 'themePalette', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay'], data => {
if (destroyed) return;
const storedLayout = data[storageKey];
if (storedLayout && typeof storedLayout === 'object') {
layout = { ...layout, ...storedLayout };
layout.customWidth = Number(storedLayout.customWidth) || Number(storedLayout.width) || DEFAULT_WIDTH;
layout.customHeight = Number(storedLayout.customHeight) || Number(storedLayout.height) || DEFAULT_HEIGHT;
}
chatPosition = normalizePosition(data.chatPosition);
chatSize = normalizeSize(data.chatSize);
chatStartMode = data.chatStartMode === 'open' ? 'open' : 'bubble';
chatReactionDisplay = data.chatReactionDisplay === 'video' ? 'video' : 'chat';
layout.mode = chatPosition;
if (layout.mode === 'detached') layout.detachedInitialized = true;
const preset = SIZE_PRESETS[chatSize];
if (preset) {
layout.width = preset.width;
layout.height = preset.height;
}
themeMode = data.themeMode || 'system';
themePalette = data.themePalette || 'eucalyptus';
preferencesLoaded = true;
applyTheme();
applyLayout();
refresh();
});
window.koalaSyncChatOverlay = { refresh, destroy };
})();
+111
View File
@@ -0,0 +1,111 @@
export const MAX_ROOM_ID_LENGTH = 64;
export const CHAT_SEND_LIMIT = 10;
export const CHAT_SEND_WINDOW_MS = 10000;
export const CHAT_ECHO_TIMEOUT_MS = 4000;
export function normalizeRoomId(value) {
if (typeof value !== 'string') return '';
return value.trim().replace(/[^a-zA-Z0-9\-]/g, '').slice(0, MAX_ROOM_ID_LENGTH);
}
export function createChatSendLimiter({
limit = CHAT_SEND_LIMIT,
windowMs = CHAT_SEND_WINDOW_MS,
now = () => Date.now()
} = {}) {
let timestamps = [];
return {
take() {
const current = now();
timestamps = timestamps.filter(timestamp => current - timestamp < windowMs);
if (timestamps.length >= limit) {
return {
allowed: false,
retryAfterMs: Math.max(1, windowMs - (current - timestamps[0]))
};
}
timestamps.push(current);
return { allowed: true, retryAfterMs: 0 };
},
reset() {
timestamps = [];
}
};
}
export function createChatEchoTracker({
timeoutMs = CHAT_ECHO_TIMEOUT_MS,
setTimer = globalThis.setTimeout,
clearTimer = globalThis.clearTimeout
} = {}) {
const pending = new Map();
function settle(ciphertext, acknowledged) {
const entry = pending.get(ciphertext);
if (!entry) return false;
pending.delete(ciphertext);
clearTimer(entry.timer);
entry.resolve(acknowledged);
return true;
}
return {
waitFor(ciphertext) {
if (typeof ciphertext !== 'string' || !ciphertext) return Promise.resolve(false);
settle(ciphertext, false);
return new Promise(resolve => {
const timer = setTimer(() => {
pending.delete(ciphertext);
resolve(false);
}, timeoutMs);
pending.set(ciphertext, { resolve, timer });
});
},
acknowledge(ciphertext) {
return settle(ciphertext, true);
},
cancel(ciphertext) {
return settle(ciphertext, false);
},
reset() {
for (const ciphertext of [...pending.keys()]) settle(ciphertext, false);
}
};
}
export function shouldShowChatNotification({ enabled, targetTabId, tab, windowInfo }) {
if (!enabled) return false;
const normalizedTargetTabId = Number(targetTabId);
const targetIsFocused = Number.isInteger(normalizedTargetTabId)
&& tab?.id === normalizedTargetTabId
&& tab.active === true
&& Number.isInteger(tab.windowId)
&& windowInfo?.id === tab.windowId
&& windowInfo.focused === true;
return !targetIsFocused;
}
export function createLatestTaskQueue() {
let generation = 0;
let tail = Promise.resolve();
return {
invalidate() {
generation++;
},
async run(task) {
const requestGeneration = ++generation;
const previous = tail;
let release;
tail = new Promise(resolve => { release = resolve; });
await previous.catch(() => {});
const isCurrent = () => requestGeneration === generation;
try {
return await task(isCurrent);
} finally {
release();
}
}
};
}
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import { CHAT_MESSAGE_RATE_LIMIT } from '../server/rate-limiter.js';
import {
CHAT_ECHO_TIMEOUT_MS,
CHAT_SEND_LIMIT,
createChatEchoTracker,
createChatSendLimiter,
createLatestTaskQueue,
MAX_ROOM_ID_LENGTH,
normalizeRoomId,
shouldShowChatNotification
} from './chat-session.js';
describe('chat session boundaries', () => {
it('matches the relay room ID sanitizer and length limit', () => {
expect(normalizeRoomId(' ROOM_!42 ')).toBe('ROOM42');
expect(normalizeRoomId('A'.repeat(MAX_ROOM_ID_LENGTH + 10))).toBe('A'.repeat(MAX_ROOM_ID_LENGTH));
expect(normalizeRoomId(null)).toBe('');
});
it('allows ten-message client bursts without exceeding the relay threshold', () => {
expect(CHAT_SEND_LIMIT).toBe(CHAT_MESSAGE_RATE_LIMIT);
let current = 1000;
const limiter = createChatSendLimiter({ now: () => current });
for (let index = 0; index < CHAT_SEND_LIMIT; index++) {
expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 });
}
expect(limiter.take()).toEqual({ allowed: false, retryAfterMs: 10000 });
current += 10000;
expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 });
limiter.reset();
expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 });
});
it('waits for the matching relay echo and clears pending sends on reset or timeout', async () => {
const timers = new Map();
let nextTimerId = 1;
const tracker = createChatEchoTracker({
setTimer(callback, timeoutMs) {
const id = nextTimerId++;
timers.set(id, { callback, timeoutMs });
return id;
},
clearTimer(id) {
timers.delete(id);
}
});
const acknowledged = tracker.waitFor('ciphertext-1');
expect(tracker.acknowledge('ciphertext-1')).toBe(true);
await expect(acknowledged).resolves.toBe(true);
const reset = tracker.waitFor('ciphertext-2');
tracker.reset();
await expect(reset).resolves.toBe(false);
const timedOut = tracker.waitFor('ciphertext-3');
const timeout = [...timers.values()][0];
expect(timeout.timeoutMs).toBe(CHAT_ECHO_TIMEOUT_MS);
timeout.callback();
await expect(timedOut).resolves.toBe(false);
});
it('suppresses chat notifications only while the target tab and its window are focused', () => {
const focused = {
enabled: true,
targetTabId: 7,
tab: { id: 7, active: true, windowId: 3 },
windowInfo: { id: 3, focused: true }
};
expect(shouldShowChatNotification(focused)).toBe(false);
expect(shouldShowChatNotification({ ...focused, windowInfo: { id: 3, focused: false } })).toBe(true);
expect(shouldShowChatNotification({ ...focused, tab: { id: 7, active: false, windowId: 3 } })).toBe(true);
expect(shouldShowChatNotification({ ...focused, targetTabId: 8 })).toBe(true);
expect(shouldShowChatNotification({ ...focused, enabled: false })).toBe(false);
});
it('serializes join work and prevents an older request from winning storage races', async () => {
const queue = createLatestTaskQueue();
let releaseFirst;
const writes = [];
const first = queue.run(async isCurrent => {
await new Promise(resolve => { releaseFirst = resolve; });
if (isCurrent()) writes.push('first');
return { status: isCurrent() ? 'ok' : 'superseded' };
});
await new Promise(resolve => setTimeout(resolve, 0));
const second = queue.run(async isCurrent => {
if (isCurrent()) writes.push('second');
return { status: 'ok' };
});
releaseFirst();
await expect(first).resolves.toEqual({ status: 'superseded' });
await expect(second).resolves.toEqual({ status: 'ok' });
expect(writes).toEqual(['second']);
});
});
+11
View File
@@ -0,0 +1,11 @@
export function buildChatRelayPayload(ciphertext) {
return { ciphertext };
}
export function encodeSocketEvent(event, data, forbiddenSecret = '') {
const payload = JSON.stringify([event, data]);
if (forbiddenSecret && payload.includes(forbiddenSecret)) {
throw new Error('Refusing to send chat secret to relay');
}
return `42${payload}`;
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js';
describe('chat wire boundary', () => {
const secret = 'R5Ti1nxp0crfAFHf3gVncw';
it('sends only ciphertext for chat messages', () => {
expect(buildChatRelayPayload('ciphertext')).toEqual({ ciphertext: 'ciphertext' });
});
it('rejects the secret anywhere in any outgoing relay event', () => {
for (const [event, payload] of [
['join_room', { roomId: 'ROOM', password: 'PASS', chatKey: secret }],
['peer_status', { status: 'heartbeat', nested: { secret } }],
['chat_message', { ciphertext: `prefix-${secret}-suffix` }]
]) {
expect(() => encodeSocketEvent(event, payload, secret)).toThrow('Refusing to send chat secret');
}
expect(encodeSocketEvent('join_room', { roomId: 'ROOM', password: 'PASS' }, secret)).not.toContain(secret);
});
});
+289 -131
View File
@@ -12,7 +12,34 @@
// Injection Guard: Check if already injected AND context is valid
try {
try {
if (window.koalaSyncInjected && chrome.runtime.id) {
return;
}
} catch (_e) {
// Context invalidated, proceed with re-injection
}
window.koalaSyncInjected = true;
let destroyed = false;
const lifecycleTimeouts = new Set();
const seekPollTimers = new Set();
const attachedVideos = new Set();
function scheduleLifecycleTimeout(callback, delay) {
const timer = setTimeout(() => {
lifecycleTimeouts.delete(timer);
if (!destroyed) callback();
}, delay);
lifecycleTimeouts.add(timer);
return timer;
}
function runtimeMessage(message, callback) {
if (destroyed) return Promise.resolve(undefined);
@@ -62,15 +89,16 @@
// Each entry is a per-type timer (key = 'playing'|'paused'|'seek').
// While a timer exists, matching native events are consumed and not relayed.
// Timers self-clean after 300ms if the native event never fires.
let _suppressTimers = {};
function _setSuppress(state) {
// 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 = {};
if (_suppressTimers[state]) clearTimeout(_suppressTimers[state]);
_suppressTimers[state] = setTimeout(() => {
@@ -210,17 +238,20 @@
if (!Number.isFinite(targetTime)) return targetTime;
const siteTimeline = getSiteQuirkTimeline(video);
if (!siteTimeline) return targetTime;
if (!siteTimeline) return targetTime;
const nativeTarget = siteTimeline.nativeStart + targetTime * siteTimeline.nativeScale;
const max = Number.isFinite(siteTimeline.end) ? siteTimeline.end : nativeTarget;
const min = Number.isFinite(siteTimeline.start) ? siteTimeline.start : 0;
return Math.max(min, Math.min(max, nativeTarget));
}
function shouldUsePageApiSeek() {
function shouldUsePageApiSeek() {
return window.KOALA_PAGE_API_SEEK_ENABLED === true &&
typeof window.koalaFindPageApiSeekProvider === 'function' &&
!!window.koalaFindPageApiSeekProvider(window.location.hostname);
return window.KOALA_PAGE_API_SEEK_ENABLED === true &&
typeof window.koalaFindPageApiSeekProvider === 'function' &&
!!window.koalaFindPageApiSeekProvider(window.location.hostname);
}
function seekVideo(video, targetTime) {
// Prefer a precise page-level seek API when available (Netflix, Disney+);
// for those players the DOM/button seek path is imprecise or impossible.
if (shouldUsePageApiSeek()) {
expectedSeekTime = targetTime;
@@ -234,8 +265,9 @@
// --- Play/Pause Coalescing (leading + trailing) ---
// Media players (HLS/DASH, ad insertion, ABR/quality switches, source swaps,
// page teardown) fire bursts of native play/pause events within a few hundred
// ms. Relaying each as a distinct command spams peers and the relay.
@@ -320,9 +352,9 @@
// --- Host Control Mode (guest-side) ---
}
// When a room is in 'host-only' mode and we're a guest, a deliberate local
});
// pause/seek must not drive the room (background/server already drop it). Here
// we handle the *local* UX: snap back to the host's position, or — if the user
@@ -355,7 +387,7 @@
body: 'Only the host can control playback in this room. Keep watching together, or watch on your own?',
stay: 'Stay in sync',
solo: 'Watch on my own',
@@ -457,7 +489,7 @@
}
// Snap the local player back to the host's current position/state.
@@ -466,7 +498,7 @@
if (hcmDesynced) return; // user opted out — never yank them back automatically
hcmSnapBackCooldownUntil = Date.now() + HCM_SNAP_BACK_COOLDOWN_MS;
const video = findVideo();
if (!video) return;
@@ -480,14 +512,16 @@
// Adopt the host's play/pause state — but ONLY if we actually know it.
// Defaulting to PLAY when the state is unknown would auto-resume a paused
if (target && Number.isFinite(target.targetTime)) {
tryMediaAction(EVENTS.SEEK, { targetTime: target.targetTime });
}
// video against the host's real state (H-1).
if (target && target.playbackState === 'paused') {
tryMediaAction(EVENTS.PAUSE);
} else if (target && target.playbackState === 'playing') {
tryMediaAction(EVENTS.PLAY);
}
@@ -518,12 +552,12 @@
else reportLog('Host-only: resync requested but host state unavailable', 'warn');
return;
let attempts = 0;
}
hcmSnapBackToHost(res.target);
});
};
@@ -786,7 +820,7 @@
hcmShowBadge();
}
hcmDesynced = true;
function hcmExitDesync() {
@@ -810,7 +844,7 @@
}
hcmRemoveBadge();
function hcmShowBadge() {
@@ -923,36 +957,52 @@
// Scan likely media hosts even when light-DOM videos exist; many players
function reportLog(message, level = 'info') {
chrome.runtime.sendMessage({ type: 'LOG', message, level }).catch(() => {});
}
// 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
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');
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;
@@ -1019,7 +1069,7 @@
...safeSettings,
compressor: {
...DEFAULT_AUDIO_SETTINGS.compressor,
@@ -1037,7 +1087,7 @@
};
}
@@ -1045,15 +1095,15 @@
if (!audioCtx) {
try {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) return null;
audioCtx = new AudioContextClass({ latencyHint: 'interactive' });
} catch (e) {
reportLog(`Audio Processing unavailable: ${e.message}`, 'warn');
@@ -1071,7 +1121,7 @@
return audioCtx;
}
@@ -1082,7 +1132,7 @@
audioCtx.close().catch(() => {});
audioCtx = null;
if (!audioCtx) {
}
audioChains = new WeakMap();
@@ -1265,14 +1315,16 @@
: 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"
chain.compressor.release.value = params.release ?? 0.300;
// Returns null if no episode pattern found.
// --- SHARED_EPISODE_UTILS_INJECT_START ---
// This block is automatically replaced by /scripts/build-extension.cjs
@@ -1280,7 +1332,7 @@
if (!title || typeof title !== 'string') return null;
rampGain(chain.dryGain, 0, t);
const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i);
if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`;
@@ -1308,8 +1360,9 @@
if (idA || idB) return false;
// Extract a canonical episode identifier from a title string.
return titleA === titleB;
}
// --- SHARED_EPISODE_UTILS_INJECT_END ---
@@ -1373,42 +1426,64 @@
function onEpisodeTransition(newTitle) {
return idA !== idB; // Both parseable → only block if different
// Debounce: prevent duplicate fires from multiple signals
if (episodeTransitionDebounce) return;
episodeTransitionDebounce = setTimeout(() => {
function checkEpisodeTransition() {
const currentTitle = getMediaTitle();
episodeTransitionDebounce = null;
}, 2000);
reportLog(`Episode transition detected: "${newTitle}"`, 'info');
// EC-12: a new episode dissolves any solo/desync state — the guest rejoins
// the room for the fresh content rather than staying stuck on the old one.
if (hcmDesynced) hcmReset();
const current = video ? getSyncCurrentTime(video) : null;
// 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)
&& extractEpisodeId(currentTitle) !== null
// 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.
runtimeMessage({
type: 'EPISODE_CHANGED',
payload: { newTitle }
}).catch(() => {});
}
onEpisodeTransition(currentTitle);
}
// Always track the latest known title
if (currentTitle) lastKnownMediaTitle = currentTitle;
function checkAndReportLobbyReady(expectedTitle) {
const video = findVideo();
const currentTitle = getMediaTitle();
const current = video ? getSyncCurrentTime(video) : null;
if (video && currentTitle && sameEpisode(currentTitle, expectedTitle)
&& current !== null && video.readyState >= 1) {
if (current >= 5) {
expectedSeekTime = 0;
video.currentTime = 0;
}
// Match! Pause at start and report ready.
if (!video.paused) {
_setSuppress('paused');
video.pause();
@@ -1475,8 +1550,9 @@
}
video.pause();
function stopLobbyPoll() {
_pendingLobbyTitle = null;
@@ -1492,9 +1568,10 @@
}
return true;
}
function getPlayerActionFixes() {
return [
{
name: 'youtube-player-buttons',
urls: ['youtube.com'],
playPauseButtonSelector: '.ytp-play-button'
@@ -1509,8 +1586,9 @@
function getActivePlayerActionFix() {
return getPlayerActionFixes().find(fix => matchesPlayerUrls(fix.urls)) || null;
// NOTE: Do NOT pause here. Three callers reach this function:
}
function tryPlayerActionFix(fix, action, video, data) {
if (!fix) return false;
const button = document.querySelector(fix.playPauseButtonSelector);
if (!button) return false;
@@ -1532,8 +1610,9 @@
if (!video) return;
lobbyPollTimer = setInterval(() => {
if (action === EVENTS.SEEK) {
const target = data ? (data.targetTime !== undefined ? data.targetTime : data.currentTime) : undefined;
if (!Number.isFinite(target)) {
@@ -1552,12 +1631,13 @@
const actionFix = getActivePlayerActionFix();
if (tryPlayerActionFix(actionFix, action, video, data)) {
return;
lobbyPollTimer = null;
}
// Fallback for native HTML5
if (action === EVENTS.PLAY) {
_setSuppress('playing');
video.play().catch((e) => {
reportLog(`Playback prevented: ${e.message}`, 'warn');
@@ -1579,18 +1659,22 @@
} catch (e) {
reportLog(`Media Action Error: ${e.message}`, 'error');
function tryPlayerActionFix(fix, action, video, data) {
if (!fix) return false;
const button = document.querySelector(fix.playPauseButtonSelector);
if (!button) return false;
}
}
// --- Helper: Wait until video is ready for playback (buffered & seeked) ---
function pollSeekReady(targetTime, timeoutMs = 8000) {
button.click();
}
if (action === EVENTS.SEEK) {
seekVideo(video, data.targetTime, data.delta);
return new Promise((resolve) => {
const interval = 150;
let elapsed = 0;
const timer = setInterval(() => {
if (destroyed) {
clearInterval(timer);
seekPollTimers.delete(timer);
resolve(false);
return;
@@ -1599,30 +1683,104 @@
if (!video) {
clearInterval(timer);
seekPollTimers.delete(timer);
if (action === EVENTS.SEEK) {
resolve(false);
return;
}
if (!Number.isFinite(target)) {
reportLog(`Media Action Error: Invalid seek payload - ${JSON.stringify(data)}`, 'error');
elapsed += interval;
const current = getSyncCurrentTime(video);
const timeDiff = current !== null ? Math.abs(current - targetTime) : Infinity;
const ready = video.readyState >= 3 && timeDiff < 2.0;
if (ready) {
}
data = { ...data, targetTime: target };
clearInterval(timer);
seekPollTimers.delete(timer);
resolve(true);
} else if (elapsed >= timeoutMs) {
clearInterval(timer);
seekPollTimers.delete(timer);
resolve(false);
}
}, interval);
seekPollTimers.add(timer);
});
}
// Listen for commands from background.js
function handleRuntimeMessage(message, sender, sendResponse) {
if (!message) return;
if (message.type === 'TARGET_DEACTIVATE') {
destroyContentScript();
sendResponse({ ok: true });
return true;
}
if (destroyed) return;
if (message.action === 'get_current_time') {
const video = findVideo();
sendResponse({ currentTime: video ? getSyncCurrentTime(video) : null });
return true;
}
if (message.action === 'APPLY_AUDIO_SETTINGS') {
_audioProcessingAllowed = true;
_audioSettings = mergeAudioSettings(message.settings);
const video = findVideo();
if (video) applyAudioSettings(video, _audioSettings);
sendResponse({ ok: true });
return true;
}
if (message.action === 'RESET_AUDIO_PROCESSING') {
_audioProcessingAllowed = false;
bypassCurrentAudioProcessing();
sendResponse({ ok: true });
return true;
}
// Host Control Mode: room mode/role changed.
if (message.type === 'CONTROL_MODE') {
const wasGated = hcmIsGuestGated();
const prevHostPeerId = hcmHostPeerId;
hcmControlMode = message.controlMode || 'everyone';
hcmAmController = !!message.amController;
hcmHostPeerId = message.hostPeerId || null;
// Reset guest-side state when leaving the gated state, OR when the
// host identity changes (room switch, host-leave fallback, missed
try {
// teardown broadcast) — clears stale desync so a rejoin starts clean (H-3).
const hostChanged = prevHostPeerId !== null && hcmHostPeerId !== prevHostPeerId;
return;
}
if ((wasGated && !hcmIsGuestGated()) || hostChanged) hcmReset();
sendResponse({ ok: true });
@@ -1631,8 +1789,8 @@
}
reportLog(`Playback prevented: ${e.message}`, 'warn');
// Host Control Mode: background blocked our local action — handle UX locally.
if (message.type === 'HOST_BLOCKED') {
@@ -1647,8 +1805,8 @@
// Background asks for an immediate state push (e.g. the first peer just
} catch (e) {
// joined while we were solo) so the newcomer syncs without waiting.
if (message.type === 'REQUEST_HEARTBEAT') {
+60 -3
View File
@@ -134,7 +134,7 @@
"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_1_TEXT": "Schaut Videos gemeinsam, egal wo ihr seid. Hier ist eine kurze Einführung.",
"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",
@@ -204,7 +204,7 @@
"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",
"FOOTER_SUPPORT": "Support KoalaSync",
"FOOTER_SUPPORT": "KoalaSync unterstützen",
"FOOTER_REVIEW": "★ Bewerten",
"FOOTER_SUPPORT_PROMPT": "Gefällt dir KoalaSync? Hinterlasse eine Bewertung!",
"LABEL_AUDIO_PROCESSING": "Audio-Verarbeitung",
@@ -245,5 +245,62 @@
"LABEL_SETTINGS_GROUP_PROFILE": "Profil & Aussehen",
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Ändere deinen Benutzernamen, dein Design und deine Spracheinstellungen.",
"LABEL_SETTINGS_GROUP_SYNC": "Wiedergabe & Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Passe Wiedergabe-, Benachrichtigungs- und Audioeinstellungen an."
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Passe Wiedergabe-, Benachrichtigungs- und Audioeinstellungen an.",
"LABEL_CHAT_ENABLED": "Raum-Chat aktivieren",
"LABEL_CHAT_ENABLED_TOOLTIP": "Verschlüsselten Raum-Chat im ausgewählten Video-Tab anzeigen.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Darstellung und Position des verschlüsselten Raum-Chats festlegen.",
"CHAT_SETTINGS_NOTE": "Optional und Ende-zu-Ende verschlüsselt. Nachrichten sind nur live sichtbar und werden vom Relay nicht gespeichert.",
"LABEL_CHAT_POSITION": "Position",
"LABEL_CHAT_POSITION_TOOLTIP": "Lege fest, wo der Chat am ausgewählten Player angezeigt wird.",
"LABEL_CHAT_SIZE": "Overlay-Größe",
"LABEL_CHAT_SIZE_TOOLTIP": "Wähle die Standardgröße des Chat-Overlays.",
"OPTION_CHAT_SIZE_COMPACT": "Kompakt",
"OPTION_CHAT_SIZE_STANDARD": "Standard",
"OPTION_CHAT_SIZE_LARGE": "Groß",
"OPTION_CHAT_SIZE_CUSTOM": "Letzte eigene Größe",
"LABEL_CHAT_START_MODE": "Startansicht",
"LABEL_CHAT_START_MODE_TOOLTIP": "Lege fest, ob der Chat als schwebende Blase oder geöffnetes Overlay startet.",
"OPTION_CHAT_START_BUBBLE": "Schwebende Chatblase",
"OPTION_CHAT_START_OPEN": "Geöffnetes Overlay",
"LABEL_CHAT_REACTION_DISPLAY": "Schnellreaktionen",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Lege fest, ob Schnellreaktionen nur im Chat oder zusätzlich über dem Video erscheinen.",
"OPTION_CHAT_REACTIONS_CHAT": "Nur im Chat",
"OPTION_CHAT_REACTIONS_VIDEO": "Chat und Video",
"CHAT_QUICK_REACTIONS": "Schnellreaktionen",
"CHAT_TITLE": "Raum-Chat",
"CHAT_LIVE_ONLY": "Nur live. Kein Verlauf.",
"CHAT_OPEN": "Raum-Chat öffnen",
"CHAT_CLOSE": "Chat schließen",
"CHAT_DOCK_LEFT": "Chat links andocken",
"CHAT_DOCK_RIGHT": "Chat rechts andocken",
"CHAT_DETACHED": "Chat frei positionieren",
"CHAT_PLACEHOLDER": "Nachricht schreiben...",
"CHAT_SEND": "Senden",
"CHAT_MISSING_KEY": "Chat ist nur über einen aktuellen Einladungslink verfügbar.",
"CHAT_TOO_LONG": "Maximal 5000 Zeichen.",
"CHAT_SEND_FAILED": "Nachricht konnte nicht gesendet werden.",
"CHAT_EMPTY": "Noch keine Nachrichten. Der Chat ist nur live.",
"LABEL_CHAT_NOTIFICATIONS": "Chat-Benachrichtigungen",
"LABEL_CHAT_NOTIFICATIONS_TOOLTIP": "Zeigt neue Raum-Chatnachrichten als Systembenachrichtigung, wenn der Video-Tab nicht fokussiert ist.",
"LABEL_EVENT_NOTIFICATIONS": "Wiedergabe- & Raumbenachrichtigungen",
"LABEL_EVENT_NOTIFICATIONS_TOOLTIP": "Zeigt Systembenachrichtigungen, wenn jemand beitritt, den Raum verlässt, abspielt oder pausiert.",
"DEBUG_SECTION_VIDEO_DETECTION": "Videoerkennung",
"DEBUG_VIDEO_NOT_FOUND": "KEIN VIDEOELEMENT GEFUNDEN",
"DEBUG_SECTION_MEDIA_SESSION": "Media Session API",
"DEBUG_VIDEO_HINT": "Läuft gerade ein Video? KoalaSync erkennt Browser-Videoelemente, nachdem das Medium geladen wurde.",
"DEBUG_SECTION_PLAYBACK": "Wiedergabe",
"DEBUG_STATE_PAUSED": "PAUSIERT",
"DEBUG_STATE_PLAYING": "LÄUFT",
"DEBUG_SECTION_PROPERTIES": "Eigenschaften",
"DEBUG_SECTION_DIMENSIONS": "Abmessungen",
"DEBUG_DIMENSIONS_HINT": "0×0 = Videoelement ist ausgeblendet oder noch nicht geladen",
"DEBUG_SECTION_MEDIA_ERROR": "Medienfehler",
"DEBUG_SECTION_DETECTION": "Erkennung",
"DEBUG_SECTION_IDENTIFICATION": "Identifikation",
"DEBUG_SECTION_MEDIA_SOURCE": "Medienquelle",
"DEBUG_SECTION_DATA_ATTRIBUTES": "Datenattribute",
"DEBUG_YES": "JA",
"DEBUG_NO": "NEIN",
"DEBUG_YES_CHECKED": "JA (geprüft)",
"DEBUG_NA": "k. A."
}
+59 -2
View File
@@ -134,7 +134,7 @@
"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_1_TEXT": "Watch videos together wherever you are. Here is 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",
@@ -245,5 +245,62 @@
"LABEL_SETTINGS_GROUP_PROFILE": "Profile & Appearance",
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Change your username, theme, and language preferences.",
"LABEL_SETTINGS_GROUP_SYNC": "Playback & Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Customize playback, notifications, and audio preferences."
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Customize playback, notifications, and audio preferences.",
"LABEL_CHAT_ENABLED": "Enable Room Chat",
"LABEL_CHAT_ENABLED_TOOLTIP": "Show encrypted room chat on the selected video tab.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configure encrypted room chat appearance and placement.",
"CHAT_SETTINGS_NOTE": "Optional and end-to-end encrypted. Messages are live only and are not stored by the relay.",
"LABEL_CHAT_POSITION": "Position",
"LABEL_CHAT_POSITION_TOOLTIP": "Choose where chat is attached to the selected player.",
"LABEL_CHAT_SIZE": "Overlay size",
"LABEL_CHAT_SIZE_TOOLTIP": "Choose the default size of the chat overlay.",
"OPTION_CHAT_SIZE_COMPACT": "Compact",
"OPTION_CHAT_SIZE_STANDARD": "Standard",
"OPTION_CHAT_SIZE_LARGE": "Large",
"OPTION_CHAT_SIZE_CUSTOM": "Last custom size",
"LABEL_CHAT_START_MODE": "Start as",
"LABEL_CHAT_START_MODE_TOOLTIP": "Choose whether chat starts as a floating bubble or an open overlay.",
"OPTION_CHAT_START_BUBBLE": "Floating bubble",
"OPTION_CHAT_START_OPEN": "Open overlay",
"LABEL_CHAT_REACTION_DISPLAY": "Quick reactions",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Choose whether quick reactions stay in chat or also fall over the video.",
"OPTION_CHAT_REACTIONS_CHAT": "Chat only",
"OPTION_CHAT_REACTIONS_VIDEO": "Chat and video",
"CHAT_QUICK_REACTIONS": "Quick reactions",
"CHAT_TITLE": "Room Chat",
"CHAT_LIVE_ONLY": "Live only. No history.",
"CHAT_OPEN": "Open room chat",
"CHAT_CLOSE": "Close chat",
"CHAT_DOCK_LEFT": "Dock chat left",
"CHAT_DOCK_RIGHT": "Dock chat right",
"CHAT_DETACHED": "Detach chat",
"CHAT_PLACEHOLDER": "Write a message...",
"CHAT_SEND": "Send",
"CHAT_MISSING_KEY": "Chat is available only through a current invite link.",
"CHAT_TOO_LONG": "Maximum 5000 characters.",
"CHAT_SEND_FAILED": "Message could not be sent.",
"CHAT_EMPTY": "No messages yet. Chat is live only.",
"LABEL_CHAT_NOTIFICATIONS": "Chat Notifications",
"LABEL_CHAT_NOTIFICATIONS_TOOLTIP": "Shows a system notification for new room-chat messages when the video tab is not focused.",
"LABEL_EVENT_NOTIFICATIONS": "Playback & Room Notifications",
"LABEL_EVENT_NOTIFICATIONS_TOOLTIP": "Shows system notifications when someone joins, leaves, plays, or pauses.",
"DEBUG_SECTION_VIDEO_DETECTION": "Video Detection",
"DEBUG_VIDEO_NOT_FOUND": "NO VIDEO ELEMENT FOUND",
"DEBUG_SECTION_MEDIA_SESSION": "Media Session API",
"DEBUG_VIDEO_HINT": "Is a video currently playing? KoalaSync detects browser video elements after the media has loaded.",
"DEBUG_SECTION_PLAYBACK": "Playback",
"DEBUG_STATE_PAUSED": "PAUSED",
"DEBUG_STATE_PLAYING": "PLAYING",
"DEBUG_SECTION_PROPERTIES": "Properties",
"DEBUG_SECTION_DIMENSIONS": "Dimensions",
"DEBUG_DIMENSIONS_HINT": "0×0 = video element is hidden or not loaded yet",
"DEBUG_SECTION_MEDIA_ERROR": "Media Error",
"DEBUG_SECTION_DETECTION": "Detection",
"DEBUG_SECTION_IDENTIFICATION": "Identification",
"DEBUG_SECTION_MEDIA_SOURCE": "Media Source",
"DEBUG_SECTION_DATA_ATTRIBUTES": "Data Attributes",
"DEBUG_YES": "YES",
"DEBUG_NO": "NO",
"DEBUG_YES_CHECKED": "YES (checked)",
"DEBUG_NA": "n/a"
}
+60 -3
View File
@@ -134,7 +134,7 @@
"BTN_ONBOARDING_NEXT": "Siguiente",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Ir al siguiente paso",
"ONBOARDING_1_TITLE": "¡Bienvenido a KoalaSync!",
"ONBOARDING_1_TEXT": "Mira videos junto a tus amigos en perfecta sincronización, sin importar dónde estén. ¡Hagamos un recorrido rápido!",
"ONBOARDING_1_TEXT": "Ved vídeos juntos estéis donde estéis. Aquí tienes 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",
@@ -204,7 +204,7 @@
"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",
"FOOTER_SUPPORT": "Support KoalaSync",
"FOOTER_SUPPORT": "Apoyar KoalaSync",
"FOOTER_REVIEW": "★ Valorar",
"FOOTER_SUPPORT_PROMPT": "¿Te gusta KoalaSync? ¡Deja una reseña!",
"LABEL_AUDIO_PROCESSING": "Procesamiento de audio",
@@ -245,5 +245,62 @@
"LABEL_SETTINGS_GROUP_PROFILE": "Perfil y Apariencia",
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Cambia tu nombre de usuario, tema y preferencias de idioma.",
"LABEL_SETTINGS_GROUP_SYNC": "Reproducción y Sincronización",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personaliza la reproducción, las notificaciones y las preferencias de audio."
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personaliza la reproducción, las notificaciones y las preferencias de audio.",
"LABEL_CHAT_ENABLED": "Activar chat de la sala",
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostrar el chat cifrado de la sala en la pestaña de vídeo seleccionada.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configura la apariencia y la posición del chat cifrado de la sala.",
"CHAT_SETTINGS_NOTE": "Opcional y cifrado de extremo a extremo. Los mensajes solo se muestran en directo y el relé no los almacena.",
"LABEL_CHAT_POSITION": "Posición",
"LABEL_CHAT_POSITION_TOOLTIP": "Elige dónde se acopla el chat al reproductor seleccionado.",
"LABEL_CHAT_SIZE": "Tamaño de la superposición",
"LABEL_CHAT_SIZE_TOOLTIP": "Elige el tamaño predeterminado de la superposición del chat.",
"OPTION_CHAT_SIZE_COMPACT": "Compacto",
"OPTION_CHAT_SIZE_STANDARD": "Estándar",
"OPTION_CHAT_SIZE_LARGE": "Grande",
"OPTION_CHAT_SIZE_CUSTOM": "Último tamaño personalizado",
"LABEL_CHAT_START_MODE": "Iniciar como",
"LABEL_CHAT_START_MODE_TOOLTIP": "Elige si el chat comienza como una burbuja flotante o una superposición abierta.",
"OPTION_CHAT_START_BUBBLE": "Burbuja flotante",
"OPTION_CHAT_START_OPEN": "Superposición abierta",
"LABEL_CHAT_REACTION_DISPLAY": "Reacciones rápidas",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Elige si las reacciones rápidas aparecen solo en el chat o también sobre el vídeo.",
"OPTION_CHAT_REACTIONS_CHAT": "Solo chat",
"OPTION_CHAT_REACTIONS_VIDEO": "Chat y vídeo",
"CHAT_QUICK_REACTIONS": "Reacciones rápidas",
"CHAT_TITLE": "Chat de la sala",
"CHAT_LIVE_ONLY": "Solo en directo. Sin historial.",
"CHAT_OPEN": "Abrir chat de la sala",
"CHAT_CLOSE": "Cerrar chat",
"CHAT_DOCK_LEFT": "Acoplar chat a la izquierda",
"CHAT_DOCK_RIGHT": "Acoplar chat a la derecha",
"CHAT_DETACHED": "Separar chat",
"CHAT_PLACEHOLDER": "Escribe un mensaje...",
"CHAT_SEND": "Enviar",
"CHAT_MISSING_KEY": "El chat solo está disponible mediante un enlace de invitación actual.",
"CHAT_TOO_LONG": "Máximo 5000 caracteres.",
"CHAT_SEND_FAILED": "No se pudo enviar el mensaje.",
"CHAT_EMPTY": "Aún no hay mensajes. El chat solo es en directo.",
"LABEL_CHAT_NOTIFICATIONS": "Notificaciones del chat",
"LABEL_CHAT_NOTIFICATIONS_TOOLTIP": "Muestra una notificación del sistema para mensajes nuevos cuando la pestaña de vídeo no está activa.",
"LABEL_EVENT_NOTIFICATIONS": "Notificaciones de reproducción y sala",
"LABEL_EVENT_NOTIFICATIONS_TOOLTIP": "Muestra notificaciones cuando alguien entra, sale, reproduce o pausa.",
"DEBUG_SECTION_VIDEO_DETECTION": "Detección de vídeo",
"DEBUG_VIDEO_NOT_FOUND": "NO SE ENCONTRÓ NINGÚN ELEMENTO DE VÍDEO",
"DEBUG_SECTION_MEDIA_SESSION": "API Media Session",
"DEBUG_VIDEO_HINT": "¿Hay un vídeo reproduciéndose? KoalaSync detecta elementos de vídeo cuando el contenido está cargado.",
"DEBUG_SECTION_PLAYBACK": "Reproducción",
"DEBUG_STATE_PAUSED": "EN PAUSA",
"DEBUG_STATE_PLAYING": "REPRODUCIENDO",
"DEBUG_SECTION_PROPERTIES": "Propiedades",
"DEBUG_SECTION_DIMENSIONS": "Dimensiones",
"DEBUG_DIMENSIONS_HINT": "0×0 = el elemento de vídeo está oculto o aún no se ha cargado",
"DEBUG_SECTION_MEDIA_ERROR": "Error multimedia",
"DEBUG_SECTION_DETECTION": "Detección",
"DEBUG_SECTION_IDENTIFICATION": "Identificación",
"DEBUG_SECTION_MEDIA_SOURCE": "Fuente multimedia",
"DEBUG_SECTION_DATA_ATTRIBUTES": "Atributos de datos",
"DEBUG_YES": "SÍ",
"DEBUG_NO": "NO",
"DEBUG_YES_CHECKED": "SÍ (comprobado)",
"DEBUG_NA": "n/d"
}
+60 -3
View File
@@ -134,7 +134,7 @@
"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_1_TEXT": "Regardez des vidéos ensemble où que vous soyez. Voici une présentation rapide.",
"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",
@@ -204,7 +204,7 @@
"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",
"FOOTER_SUPPORT": "Support KoalaSync",
"FOOTER_SUPPORT": "Soutenir KoalaSync",
"FOOTER_REVIEW": "★ Évaluer",
"FOOTER_SUPPORT_PROMPT": "Tu aimes KoalaSync? Laisse un avis!",
"LABEL_AUDIO_PROCESSING": "Traitement audio",
@@ -245,5 +245,62 @@
"LABEL_SETTINGS_GROUP_PROFILE": "Profil & Apparence",
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Modifiez votre nom d'utilisateur, votre thème et vos préférences linguistiques.",
"LABEL_SETTINGS_GROUP_SYNC": "Lecture & Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personnalisez la lecture, les notifications et les préférences audio."
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personnalisez la lecture, les notifications et les préférences audio.",
"LABEL_CHAT_ENABLED": "Activer le chat du salon",
"LABEL_CHAT_ENABLED_TOOLTIP": "Afficher le chat chiffré du salon dans l'onglet vidéo sélectionné.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configurez l'apparence et l'emplacement du chat chiffré du salon.",
"CHAT_SETTINGS_NOTE": "Facultatif et chiffré de bout en bout. Les messages sont uniquement en direct et ne sont pas stockés par le relais.",
"LABEL_CHAT_POSITION": "Position",
"LABEL_CHAT_POSITION_TOOLTIP": "Choisissez où le chat est ancré au lecteur sélectionné.",
"LABEL_CHAT_SIZE": "Taille de la fenêtre",
"LABEL_CHAT_SIZE_TOOLTIP": "Choisissez la taille par défaut de la fenêtre de chat.",
"OPTION_CHAT_SIZE_COMPACT": "Compacte",
"OPTION_CHAT_SIZE_STANDARD": "Standard",
"OPTION_CHAT_SIZE_LARGE": "Grande",
"OPTION_CHAT_SIZE_CUSTOM": "Dernière taille personnalisée",
"LABEL_CHAT_START_MODE": "Affichage initial",
"LABEL_CHAT_START_MODE_TOOLTIP": "Choisissez si le chat démarre sous forme de bulle flottante ou de fenêtre ouverte.",
"OPTION_CHAT_START_BUBBLE": "Bulle flottante",
"OPTION_CHAT_START_OPEN": "Fenêtre ouverte",
"LABEL_CHAT_REACTION_DISPLAY": "Réactions rapides",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Choisissez si les réactions rapides restent dans le chat ou tombent aussi sur la vidéo.",
"OPTION_CHAT_REACTIONS_CHAT": "Chat uniquement",
"OPTION_CHAT_REACTIONS_VIDEO": "Chat et vidéo",
"CHAT_QUICK_REACTIONS": "Réactions rapides",
"CHAT_TITLE": "Chat du salon",
"CHAT_LIVE_ONLY": "En direct uniquement. Aucun historique.",
"CHAT_OPEN": "Ouvrir le chat du salon",
"CHAT_CLOSE": "Fermer le chat",
"CHAT_DOCK_LEFT": "Ancrer le chat à gauche",
"CHAT_DOCK_RIGHT": "Ancrer le chat à droite",
"CHAT_DETACHED": "Détacher le chat",
"CHAT_PLACEHOLDER": "Écrire un message...",
"CHAT_SEND": "Envoyer",
"CHAT_MISSING_KEY": "Le chat est disponible uniquement via un lien d'invitation actuel.",
"CHAT_TOO_LONG": "5000 caractères maximum.",
"CHAT_SEND_FAILED": "Le message n'a pas pu être envoyé.",
"CHAT_EMPTY": "Aucun message. Le chat est uniquement en direct.",
"LABEL_CHAT_NOTIFICATIONS": "Notifications du chat",
"LABEL_CHAT_NOTIFICATIONS_TOOLTIP": "Affiche une notification système pour les nouveaux messages lorsque longlet vidéo nest pas actif.",
"LABEL_EVENT_NOTIFICATIONS": "Notifications de lecture et de salon",
"LABEL_EVENT_NOTIFICATIONS_TOOLTIP": "Affiche des notifications quand une personne arrive, part, lance ou met en pause.",
"DEBUG_SECTION_VIDEO_DETECTION": "Détection vidéo",
"DEBUG_VIDEO_NOT_FOUND": "AUCUN ÉLÉMENT VIDÉO DÉTECTÉ",
"DEBUG_SECTION_MEDIA_SESSION": "API Media Session",
"DEBUG_VIDEO_HINT": "Une vidéo est-elle en cours de lecture ? KoalaSync détecte les éléments vidéo une fois le média chargé.",
"DEBUG_SECTION_PLAYBACK": "Lecture",
"DEBUG_STATE_PAUSED": "EN PAUSE",
"DEBUG_STATE_PLAYING": "EN LECTURE",
"DEBUG_SECTION_PROPERTIES": "Propriétés",
"DEBUG_SECTION_DIMENSIONS": "Dimensions",
"DEBUG_DIMENSIONS_HINT": "0×0 = l’élément vidéo est masqué ou pas encore chargé",
"DEBUG_SECTION_MEDIA_ERROR": "Erreur multimédia",
"DEBUG_SECTION_DETECTION": "Détection",
"DEBUG_SECTION_IDENTIFICATION": "Identification",
"DEBUG_SECTION_MEDIA_SOURCE": "Source multimédia",
"DEBUG_SECTION_DATA_ATTRIBUTES": "Attributs de données",
"DEBUG_YES": "OUI",
"DEBUG_NO": "NON",
"DEBUG_YES_CHECKED": "OUI (vérifié)",
"DEBUG_NA": "n/d"
}
+61 -4
View File
@@ -34,7 +34,7 @@
"BTN_REFRESH_TOOLTIP": "Aggiorna l'elenco delle stanze",
"PUBLIC_ROOMS_REFRESHING": "Aggiornamento...",
"BTN_REFRESH_COOLDOWN": "ATTENDI {seconds}s",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Riprova tra {seconds}s.",
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Lelenco delle stanze è in attesa. Riprova tra {seconds}s.",
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Aggiornamento in corso. Disponibile tra {seconds}s.",
"LABEL_ACTIVE_ROOM": "Stanza Attiva",
"LABEL_ACTIVE_ROOM_TOOLTIP": "La stanza a cui sei connesso",
@@ -134,7 +134,7 @@
"BTN_ONBOARDING_NEXT": "Avanti",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Vai al passaggio successivo",
"ONBOARDING_1_TITLE": "Benvenuto in KoalaSync!",
"ONBOARDING_1_TEXT": "Guarda i video insieme ai tuoi amici in perfetta sincronia. Facciamo un breve tour!",
"ONBOARDING_1_TEXT": "Guarda video insieme ovunque vi troviate. Ecco una breve panoramica.",
"ONBOARDING_2_TITLE": "1. Crea una Stanza",
"ONBOARDING_2_TEXT": "Inizia da qui. Crea una stanza e condividi il link con i tuoi amici.",
"ONBOARDING_3_TITLE": "2. Seleziona Video",
@@ -204,7 +204,7 @@
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Tempo scaduto: caricamento incompleto per alcuni partecipanti",
"LOBBY_CANCEL_USER": "Annullato dall'utente",
"NOTIF_ERROR_TITLE": "Errore KoalaSync",
"FOOTER_SUPPORT": "Support KoalaSync",
"FOOTER_SUPPORT": "Sostieni KoalaSync",
"FOOTER_REVIEW": "★ Valutaci",
"FOOTER_SUPPORT_PROMPT": "Ti piace KoalaSync? Lascia una recensione!",
"LABEL_AUDIO_PROCESSING": "Elaborazione Audio",
@@ -245,5 +245,62 @@
"LABEL_SETTINGS_GROUP_PROFILE": "Profilo & Aspetto",
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Modifica il tuo nome utente, tema e preferenze di lingua.",
"LABEL_SETTINGS_GROUP_SYNC": "Riproduzione & Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalizza la riproduzione, le notifiche e le preferenze audio."
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalizza la riproduzione, le notifiche e le preferenze audio.",
"LABEL_CHAT_ENABLED": "Attiva chat della stanza",
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostra la chat crittografata della stanza nella scheda video selezionata.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configura l'aspetto e la posizione della chat crittografata della stanza.",
"CHAT_SETTINGS_NOTE": "Opzionale e crittografata end-to-end. I messaggi sono solo in diretta e non vengono memorizzati dal relay.",
"LABEL_CHAT_POSITION": "Posizione",
"LABEL_CHAT_POSITION_TOOLTIP": "Scegli dove agganciare la chat al lettore selezionato.",
"LABEL_CHAT_SIZE": "Dimensione overlay",
"LABEL_CHAT_SIZE_TOOLTIP": "Scegli la dimensione predefinita dell'overlay della chat.",
"OPTION_CHAT_SIZE_COMPACT": "Compatto",
"OPTION_CHAT_SIZE_STANDARD": "Standard",
"OPTION_CHAT_SIZE_LARGE": "Grande",
"OPTION_CHAT_SIZE_CUSTOM": "Ultima dimensione personalizzata",
"LABEL_CHAT_START_MODE": "Avvio come",
"LABEL_CHAT_START_MODE_TOOLTIP": "Scegli se la chat si avvia come bolla mobile o overlay aperto.",
"OPTION_CHAT_START_BUBBLE": "Bolla fluttuante",
"OPTION_CHAT_START_OPEN": "Overlay aperto",
"LABEL_CHAT_REACTION_DISPLAY": "Reazioni rapide",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Scegli se le reazioni rapide restano nella chat o cadono anche sul video.",
"OPTION_CHAT_REACTIONS_CHAT": "Solo chat",
"OPTION_CHAT_REACTIONS_VIDEO": "Chat e video",
"CHAT_QUICK_REACTIONS": "Reazioni rapide",
"CHAT_TITLE": "Chat della stanza",
"CHAT_LIVE_ONLY": "Solo in diretta. Nessuna cronologia.",
"CHAT_OPEN": "Apri la chat della stanza",
"CHAT_CLOSE": "Chiudi chat",
"CHAT_DOCK_LEFT": "Aggancia chat a sinistra",
"CHAT_DOCK_RIGHT": "Aggancia chat a destra",
"CHAT_DETACHED": "Sgancia chat",
"CHAT_PLACEHOLDER": "Scrivi un messaggio...",
"CHAT_SEND": "Invia",
"CHAT_MISSING_KEY": "La chat è disponibile solo tramite un link di invito attuale.",
"CHAT_TOO_LONG": "Massimo 5000 caratteri.",
"CHAT_SEND_FAILED": "Impossibile inviare il messaggio.",
"CHAT_EMPTY": "Nessun messaggio. La chat è solo in diretta.",
"LABEL_CHAT_NOTIFICATIONS": "Notifiche chat",
"LABEL_CHAT_NOTIFICATIONS_TOOLTIP": "Mostra una notifica di sistema per i nuovi messaggi quando la scheda video non è attiva.",
"LABEL_EVENT_NOTIFICATIONS": "Notifiche di riproduzione e stanza",
"LABEL_EVENT_NOTIFICATIONS_TOOLTIP": "Mostra notifiche quando qualcuno entra, esce, avvia o mette in pausa.",
"DEBUG_SECTION_VIDEO_DETECTION": "Rilevamento video",
"DEBUG_VIDEO_NOT_FOUND": "NESSUN ELEMENTO VIDEO TROVATO",
"DEBUG_SECTION_MEDIA_SESSION": "API Media Session",
"DEBUG_VIDEO_HINT": "È in riproduzione un video? KoalaSync rileva gli elementi video dopo il caricamento del contenuto.",
"DEBUG_SECTION_PLAYBACK": "Riproduzione",
"DEBUG_STATE_PAUSED": "IN PAUSA",
"DEBUG_STATE_PLAYING": "IN RIPRODUZIONE",
"DEBUG_SECTION_PROPERTIES": "Proprietà",
"DEBUG_SECTION_DIMENSIONS": "Dimensioni",
"DEBUG_DIMENSIONS_HINT": "0×0 = elemento video nascosto o non ancora caricato",
"DEBUG_SECTION_MEDIA_ERROR": "Errore multimediale",
"DEBUG_SECTION_DETECTION": "Rilevamento",
"DEBUG_SECTION_IDENTIFICATION": "Identificazione",
"DEBUG_SECTION_MEDIA_SOURCE": "Sorgente multimediale",
"DEBUG_SECTION_DATA_ATTRIBUTES": "Attributi dati",
"DEBUG_YES": "SÌ",
"DEBUG_NO": "NO",
"DEBUG_YES_CHECKED": "SÌ (verificato)",
"DEBUG_NA": "n/d"
}
+60 -3
View File
@@ -134,7 +134,7 @@
"BTN_ONBOARDING_NEXT": "次へ",
"BTN_ONBOARDING_NEXT_TOOLTIP": "次のステップに進む",
"ONBOARDING_1_TITLE": "KoalaSyncへようこそ!",
"ONBOARDING_1_TEXT": "どこにいても、完璧に同期してビデオを一緒に楽しめます。簡単なツアーを始めましょう!",
"ONBOARDING_1_TEXT": "離れた場所から一緒に動画を視聴できます。簡単に機能を紹介します。",
"ONBOARDING_2_TITLE": "1. ルームの作成",
"ONBOARDING_2_TEXT": "ここからスタート。ルームを作成し、招待リンクを友達に共有します。",
"ONBOARDING_3_TITLE": "2. ビデオの選択",
@@ -204,7 +204,7 @@
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "タイムアウト — 一部のメンバーがエピソードを読み込めませんでした",
"LOBBY_CANCEL_USER": "ユーザーによってキャンセルされました",
"NOTIF_ERROR_TITLE": "KoalaSyncエラー",
"FOOTER_SUPPORT": "Support KoalaSync",
"FOOTER_SUPPORT": "KoalaSyncを支援",
"FOOTER_REVIEW": "★ 評価する",
"FOOTER_SUPPORT_PROMPT": "KoalaSyncはいかが?レビューを書いてください!",
"LABEL_AUDIO_PROCESSING": "オーディオ処理",
@@ -245,5 +245,62 @@
"LABEL_SETTINGS_GROUP_PROFILE": "プロフィールと外観",
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "ユーザー名、テーマ、言語設定を変更します。",
"LABEL_SETTINGS_GROUP_SYNC": "再生と同期",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "再生、通知、オーディオ設定をカスタマイズします。"
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "再生、通知、オーディオ設定をカスタマイズします。",
"LABEL_CHAT_ENABLED": "ルームチャットを有効化",
"LABEL_CHAT_ENABLED_TOOLTIP": "選択した動画タブに暗号化されたルームチャットを表示します。",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "暗号化されたルームチャットの外観と位置を設定します。",
"CHAT_SETTINGS_NOTE": "任意で利用でき、エンドツーエンドで暗号化されます。メッセージはリアルタイムのみで、リレーには保存されません。",
"LABEL_CHAT_POSITION": "位置",
"LABEL_CHAT_POSITION_TOOLTIP": "選択したプレーヤーのどこにチャットを配置するか選択します。",
"LABEL_CHAT_SIZE": "オーバーレイサイズ",
"LABEL_CHAT_SIZE_TOOLTIP": "チャットオーバーレイの標準サイズを選択します。",
"OPTION_CHAT_SIZE_COMPACT": "コンパクト",
"OPTION_CHAT_SIZE_STANDARD": "標準",
"OPTION_CHAT_SIZE_LARGE": "大",
"OPTION_CHAT_SIZE_CUSTOM": "前回のカスタムサイズ",
"LABEL_CHAT_START_MODE": "開始表示",
"LABEL_CHAT_START_MODE_TOOLTIP": "フローティングバブルまたは開いたオーバーレイで開始するか選択します。",
"OPTION_CHAT_START_BUBBLE": "フローティングバブル",
"OPTION_CHAT_START_OPEN": "開いたオーバーレイ",
"LABEL_CHAT_REACTION_DISPLAY": "クイックリアクション",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "クイックリアクションをチャットだけに表示するか、動画上にも降らせるかを選択します。",
"OPTION_CHAT_REACTIONS_CHAT": "チャットのみ",
"OPTION_CHAT_REACTIONS_VIDEO": "チャットと動画",
"CHAT_QUICK_REACTIONS": "クイックリアクション",
"CHAT_TITLE": "ルームチャット",
"CHAT_LIVE_ONLY": "ライブのみ。履歴はありません。",
"CHAT_OPEN": "ルームチャットを開く",
"CHAT_CLOSE": "チャットを閉じる",
"CHAT_DOCK_LEFT": "チャットを左に固定",
"CHAT_DOCK_RIGHT": "チャットを右に固定",
"CHAT_DETACHED": "チャットを切り離す",
"CHAT_PLACEHOLDER": "メッセージを入力...",
"CHAT_SEND": "送信",
"CHAT_MISSING_KEY": "チャットは最新の招待リンクからのみ利用できます。",
"CHAT_TOO_LONG": "最大5000文字です。",
"CHAT_SEND_FAILED": "メッセージを送信できませんでした。",
"CHAT_EMPTY": "まだメッセージはありません。チャットはライブのみです。",
"LABEL_CHAT_NOTIFICATIONS": "チャット通知",
"LABEL_CHAT_NOTIFICATIONS_TOOLTIP": "動画タブがアクティブでないとき、新しいルームチャットをシステム通知で表示します。",
"LABEL_EVENT_NOTIFICATIONS": "再生とルームの通知",
"LABEL_EVENT_NOTIFICATIONS_TOOLTIP": "参加、退出、再生、一時停止をシステム通知で表示します。",
"DEBUG_SECTION_VIDEO_DETECTION": "動画検出",
"DEBUG_VIDEO_NOT_FOUND": "動画要素が見つかりません",
"DEBUG_SECTION_MEDIA_SESSION": "Media Session API",
"DEBUG_VIDEO_HINT": "動画を再生していますか?メディアの読み込み後にKoalaSyncが動画要素を検出します。",
"DEBUG_SECTION_PLAYBACK": "再生",
"DEBUG_STATE_PAUSED": "一時停止",
"DEBUG_STATE_PLAYING": "再生中",
"DEBUG_SECTION_PROPERTIES": "プロパティ",
"DEBUG_SECTION_DIMENSIONS": "サイズ",
"DEBUG_DIMENSIONS_HINT": "0×0 = 動画要素が非表示、または未読み込みです",
"DEBUG_SECTION_MEDIA_ERROR": "メディアエラー",
"DEBUG_SECTION_DETECTION": "検出",
"DEBUG_SECTION_IDENTIFICATION": "識別情報",
"DEBUG_SECTION_MEDIA_SOURCE": "メディアソース",
"DEBUG_SECTION_DATA_ATTRIBUTES": "データ属性",
"DEBUG_YES": "はい",
"DEBUG_NO": "いいえ",
"DEBUG_YES_CHECKED": "はい(確認済み)",
"DEBUG_NA": "なし"
}
+60 -3
View File
@@ -134,7 +134,7 @@
"BTN_ONBOARDING_NEXT": "다음",
"BTN_ONBOARDING_NEXT_TOOLTIP": "다음 단계로 이동",
"ONBOARDING_1_TITLE": "KoalaSync에 오신 것을 환영합니다!",
"ONBOARDING_1_TEXT": "어디에 있든 완벽하게 동기화된 비디오를 함께 시청하세요. 간단히 둘러볼까요!",
"ONBOARDING_1_TEXT": "어디서든 함께 비디오를 시청하세요. 주요 기능을 간단히 살펴봅니다.",
"ONBOARDING_2_TITLE": "1. 방 만들기",
"ONBOARDING_2_TEXT": "여기서 시작하세요. 방을 만들고 친구들에게 초대 링크를 공유하세요.",
"ONBOARDING_3_TITLE": "2. 비디오 선택",
@@ -204,7 +204,7 @@
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "시간 초과 — 모든 피어가 에피소드를 로드하지 못했습니다",
"LOBBY_CANCEL_USER": "사용자에 의해 취소됨",
"NOTIF_ERROR_TITLE": "KoalaSync 오류",
"FOOTER_SUPPORT": "Support KoalaSync",
"FOOTER_SUPPORT": "KoalaSync 후원",
"FOOTER_REVIEW": "★ 평가하기",
"FOOTER_SUPPORT_PROMPT": "KoalaSync가 마음에 드세요? 리뷰를 남겨주세요!",
"LABEL_AUDIO_PROCESSING": "오디오 처리",
@@ -245,5 +245,62 @@
"LABEL_SETTINGS_GROUP_PROFILE": "프로필 및 테마",
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "사용자 이름, 테마 및 언어 설정을 변경합니다.",
"LABEL_SETTINGS_GROUP_SYNC": "재생 및 동기화",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "재생, 알림 및 오디오 설정을 맞춤설정합니다."
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "재생, 알림 및 오디오 설정을 맞춤설정합니다.",
"LABEL_CHAT_ENABLED": "방 채팅 사용",
"LABEL_CHAT_ENABLED_TOOLTIP": "선택한 동영상 탭에 암호화된 방 채팅을 표시합니다.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "암호화된 방 채팅의 모양과 위치를 설정합니다.",
"CHAT_SETTINGS_NOTE": "선택 기능이며 종단 간 암호화됩니다. 메시지는 실시간으로만 표시되고 릴레이에 저장되지 않습니다.",
"LABEL_CHAT_POSITION": "위치",
"LABEL_CHAT_POSITION_TOOLTIP": "선택한 플레이어에서 채팅을 고정할 위치를 선택합니다.",
"LABEL_CHAT_SIZE": "오버레이 크기",
"LABEL_CHAT_SIZE_TOOLTIP": "채팅 오버레이의 기본 크기를 선택합니다.",
"OPTION_CHAT_SIZE_COMPACT": "작게",
"OPTION_CHAT_SIZE_STANDARD": "표준",
"OPTION_CHAT_SIZE_LARGE": "크게",
"OPTION_CHAT_SIZE_CUSTOM": "마지막 사용자 지정 크기",
"LABEL_CHAT_START_MODE": "시작 화면",
"LABEL_CHAT_START_MODE_TOOLTIP": "플로팅 버블 또는 열린 오버레이로 시작할지 선택합니다.",
"OPTION_CHAT_START_BUBBLE": "플로팅 채팅 버블",
"OPTION_CHAT_START_OPEN": "열린 오버레이",
"LABEL_CHAT_REACTION_DISPLAY": "빠른 반응",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "빠른 반응을 채팅에만 표시하거나 동영상 위에도 떨어지게 할지 선택합니다.",
"OPTION_CHAT_REACTIONS_CHAT": "채팅만",
"OPTION_CHAT_REACTIONS_VIDEO": "채팅 및 동영상",
"CHAT_QUICK_REACTIONS": "빠른 반응",
"CHAT_TITLE": "방 채팅",
"CHAT_LIVE_ONLY": "실시간 전용. 기록 없음.",
"CHAT_OPEN": "방 채팅 열기",
"CHAT_CLOSE": "채팅 닫기",
"CHAT_DOCK_LEFT": "채팅을 왼쪽에 고정",
"CHAT_DOCK_RIGHT": "채팅을 오른쪽에 고정",
"CHAT_DETACHED": "채팅 분리",
"CHAT_PLACEHOLDER": "메시지를 입력하세요...",
"CHAT_SEND": "보내기",
"CHAT_MISSING_KEY": "채팅은 최신 초대 링크를 통해서만 사용할 수 있습니다.",
"CHAT_TOO_LONG": "최대 5000자입니다.",
"CHAT_SEND_FAILED": "메시지를 보낼 수 없습니다.",
"CHAT_EMPTY": "아직 메시지가 없습니다. 채팅은 실시간 전용입니다.",
"LABEL_CHAT_NOTIFICATIONS": "채팅 알림",
"LABEL_CHAT_NOTIFICATIONS_TOOLTIP": "비디오 탭이 활성 상태가 아닐 때 새 방 채팅 메시지를 시스템 알림으로 표시합니다.",
"LABEL_EVENT_NOTIFICATIONS": "재생 및 방 알림",
"LABEL_EVENT_NOTIFICATIONS_TOOLTIP": "참여, 퇴장, 재생 또는 일시정지를 시스템 알림으로 표시합니다.",
"DEBUG_SECTION_VIDEO_DETECTION": "비디오 감지",
"DEBUG_VIDEO_NOT_FOUND": "비디오 요소를 찾지 못했습니다",
"DEBUG_SECTION_MEDIA_SESSION": "Media Session API",
"DEBUG_VIDEO_HINT": "비디오가 재생 중인가요? 미디어가 로드된 후 KoalaSync가 비디오 요소를 감지합니다.",
"DEBUG_SECTION_PLAYBACK": "재생",
"DEBUG_STATE_PAUSED": "일시정지",
"DEBUG_STATE_PLAYING": "재생 중",
"DEBUG_SECTION_PROPERTIES": "속성",
"DEBUG_SECTION_DIMENSIONS": "크기",
"DEBUG_DIMENSIONS_HINT": "0×0 = 비디오 요소가 숨겨져 있거나 아직 로드되지 않음",
"DEBUG_SECTION_MEDIA_ERROR": "미디어 오류",
"DEBUG_SECTION_DETECTION": "감지",
"DEBUG_SECTION_IDENTIFICATION": "식별 정보",
"DEBUG_SECTION_MEDIA_SOURCE": "미디어 소스",
"DEBUG_SECTION_DATA_ATTRIBUTES": "데이터 속성",
"DEBUG_YES": "예",
"DEBUG_NO": "아니요",
"DEBUG_YES_CHECKED": "예(확인됨)",
"DEBUG_NA": "없음"
}
+61 -4
View File
@@ -82,7 +82,7 @@
"LABEL_LAST_ACTIVITY": "Laatste activiteitsstatus",
"LABEL_LAST_ACTIVITY_TOOLTIP": "Toont de meest recente afspeel-, pauzeer- of zoekopdracht",
"NO_RECENT_COMMANDS": "Geen recente commando's",
"LOBBY_HEADER": "EPISODE LOBBY",
"LOBBY_HEADER": "AFLEVERINGSLOBBY",
"LOBBY_WAITING_FOR": "🎬 Wachten op: \"{title}\"",
"LOBBY_WAITING_PEERS": "Wachten op deelnemers...",
"BTN_SKIP_PLAY": "Overslaan & toch afspelen",
@@ -134,7 +134,7 @@
"BTN_ONBOARDING_NEXT": "Volgende",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Ga naar de volgende stap",
"ONBOARDING_1_TITLE": "Welkom bij KoalaSync!",
"ONBOARDING_1_TEXT": "Bekijk video's samen in perfecte synchronisatie — waar u ook bent. Laten we een snelle rondleiding nemen!",
"ONBOARDING_1_TEXT": "Bekijk samen videos, waar je ook bent. Hier volgt een korte rondleiding.",
"ONBOARDING_2_TITLE": "1. Kamer maken",
"ONBOARDING_2_TEXT": "Begin hier. Maak een kamer en deel de uitnodigingslink met uw vrienden.",
"ONBOARDING_3_TITLE": "2. Selecteer video",
@@ -204,7 +204,7 @@
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Time-out — niet alle deelnemers hebben de aflevering geladen",
"LOBBY_CANCEL_USER": "Geannuleerd door gebruiker",
"NOTIF_ERROR_TITLE": "KoalaSync-fout",
"FOOTER_SUPPORT": "Support KoalaSync",
"FOOTER_SUPPORT": "Steun KoalaSync",
"FOOTER_REVIEW": "★ Beoordelen",
"FOOTER_SUPPORT_PROMPT": "Vind je KoalaSync leuk? Laat een review achter!",
"LABEL_AUDIO_PROCESSING": "Audioverwerking",
@@ -245,5 +245,62 @@
"LABEL_SETTINGS_GROUP_PROFILE": "Profiel & Weergave",
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Wijzig uw gebruikersnaam, thema en taalvoorkeuren.",
"LABEL_SETTINGS_GROUP_SYNC": "Afspelen & Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Pas afspelen, meldingen en audiovoorkeuren aan."
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Pas afspelen, meldingen en audiovoorkeuren aan.",
"LABEL_CHAT_ENABLED": "Kamerchat inschakelen",
"LABEL_CHAT_ENABLED_TOOLTIP": "Versleutelde kamerchat tonen in het geselecteerde videotabblad.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Pas het uiterlijk en de positie van de versleutelde kamerchat aan.",
"CHAT_SETTINGS_NOTE": "Optioneel en end-to-end versleuteld. Berichten zijn alleen live en worden niet door de relay opgeslagen.",
"LABEL_CHAT_POSITION": "Positie",
"LABEL_CHAT_POSITION_TOOLTIP": "Kies waar de chat aan de geselecteerde speler wordt gekoppeld.",
"LABEL_CHAT_SIZE": "Overlaygrootte",
"LABEL_CHAT_SIZE_TOOLTIP": "Kies de standaardgrootte van de chatoverlay.",
"OPTION_CHAT_SIZE_COMPACT": "Compact",
"OPTION_CHAT_SIZE_STANDARD": "Standaard",
"OPTION_CHAT_SIZE_LARGE": "Groot",
"OPTION_CHAT_SIZE_CUSTOM": "Laatste aangepaste grootte",
"LABEL_CHAT_START_MODE": "Start als",
"LABEL_CHAT_START_MODE_TOOLTIP": "Kies of de chat start als zwevende knop of als geopende overlay.",
"OPTION_CHAT_START_BUBBLE": "Zwevende chatknop",
"OPTION_CHAT_START_OPEN": "Geopende overlay",
"LABEL_CHAT_REACTION_DISPLAY": "Snelle reacties",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Kies of snelle reacties alleen in de chat of ook over de video verschijnen.",
"OPTION_CHAT_REACTIONS_CHAT": "Alleen chat",
"OPTION_CHAT_REACTIONS_VIDEO": "Chat en video",
"CHAT_QUICK_REACTIONS": "Snelle reacties",
"CHAT_TITLE": "Kamerchat",
"CHAT_LIVE_ONLY": "Alleen live. Geen geschiedenis.",
"CHAT_OPEN": "Kamerchat openen",
"CHAT_CLOSE": "Chat sluiten",
"CHAT_DOCK_LEFT": "Chat links vastzetten",
"CHAT_DOCK_RIGHT": "Chat rechts vastzetten",
"CHAT_DETACHED": "Chat losmaken",
"CHAT_PLACEHOLDER": "Schrijf een bericht...",
"CHAT_SEND": "Verzenden",
"CHAT_MISSING_KEY": "Chat is alleen beschikbaar via een actuele uitnodigingslink.",
"CHAT_TOO_LONG": "Maximaal 5000 tekens.",
"CHAT_SEND_FAILED": "Bericht kon niet worden verzonden.",
"CHAT_EMPTY": "Nog geen berichten. Chat is alleen live.",
"LABEL_CHAT_NOTIFICATIONS": "Chatmeldingen",
"LABEL_CHAT_NOTIFICATIONS_TOOLTIP": "Toont een systeemmelding voor nieuwe chatberichten wanneer het videotabblad niet actief is.",
"LABEL_EVENT_NOTIFICATIONS": "Afspeel- en kamermeldingen",
"LABEL_EVENT_NOTIFICATIONS_TOOLTIP": "Toont meldingen wanneer iemand deelneemt, vertrekt, afspeelt of pauzeert.",
"DEBUG_SECTION_VIDEO_DETECTION": "Videodetectie",
"DEBUG_VIDEO_NOT_FOUND": "GEEN VIDEO-ELEMENT GEVONDEN",
"DEBUG_SECTION_MEDIA_SESSION": "Media Session API",
"DEBUG_VIDEO_HINT": "Wordt er een video afgespeeld? KoalaSync detecteert video-elementen nadat de media is geladen.",
"DEBUG_SECTION_PLAYBACK": "Afspelen",
"DEBUG_STATE_PAUSED": "GEPAUZEERD",
"DEBUG_STATE_PLAYING": "WORDT AFGESPEELD",
"DEBUG_SECTION_PROPERTIES": "Eigenschappen",
"DEBUG_SECTION_DIMENSIONS": "Afmetingen",
"DEBUG_DIMENSIONS_HINT": "0×0 = video-element is verborgen of nog niet geladen",
"DEBUG_SECTION_MEDIA_ERROR": "Mediafout",
"DEBUG_SECTION_DETECTION": "Detectie",
"DEBUG_SECTION_IDENTIFICATION": "Identificatie",
"DEBUG_SECTION_MEDIA_SOURCE": "Mediabron",
"DEBUG_SECTION_DATA_ATTRIBUTES": "Data-attributen",
"DEBUG_YES": "JA",
"DEBUG_NO": "NEE",
"DEBUG_YES_CHECKED": "JA (gecontroleerd)",
"DEBUG_NA": "n.v.t."
}
+60 -3
View File
@@ -134,7 +134,7 @@
"BTN_ONBOARDING_NEXT": "Dalej",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Przejdź do następnego kroku",
"ONBOARDING_1_TITLE": "Witaj w KoalaSync!",
"ONBOARDING_1_TEXT": "Oglądaj filmy w idealnej synchronizacji — bez względu na to, gdzie jesteś. Zróbmy szybką wycieczkę!",
"ONBOARDING_1_TEXT": "Oglądajcie filmy razem, gdziekolwiek jesteście. Oto krótkie wprowadzenie.",
"ONBOARDING_2_TITLE": "1. Utwórz pokój",
"ONBOARDING_2_TEXT": "Zacznij tutaj. Utwórz pokój i udostępnij link zaproszenia znajomym.",
"ONBOARDING_3_TITLE": "2. Wybierz wideo",
@@ -204,7 +204,7 @@
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Limit czasu — nie wszyscy uczestnicy załadowali odcinek",
"LOBBY_CANCEL_USER": "Anulowane przez użytkownika",
"NOTIF_ERROR_TITLE": "Błąd KoalaSync",
"FOOTER_SUPPORT": "Support KoalaSync",
"FOOTER_SUPPORT": "Wesprzyj KoalaSync",
"FOOTER_REVIEW": "★ Oceń",
"FOOTER_SUPPORT_PROMPT": "Podoba Ci się KoalaSync? Zostaw recenzję!",
"LABEL_AUDIO_PROCESSING": "Przetwarzanie dźwięku",
@@ -245,5 +245,62 @@
"LABEL_SETTINGS_GROUP_PROFILE": "Profil i Wygląd",
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Zmień swoją nazwę użytkownika, motyw i preferencje językowe.",
"LABEL_SETTINGS_GROUP_SYNC": "Odtwarzanie i Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Dostosuj odtwarzanie, powiadomienia i preferencje audio."
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Dostosuj odtwarzanie, powiadomienia i preferencje audio.",
"LABEL_CHAT_ENABLED": "Włącz czat pokoju",
"LABEL_CHAT_ENABLED_TOOLTIP": "Pokaż zaszyfrowany czat pokoju na wybranej karcie wideo.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Skonfiguruj wygląd i położenie zaszyfrowanego czatu pokoju.",
"CHAT_SETTINGS_NOTE": "Opcjonalny i szyfrowany od końca do końca. Wiadomości są tylko na żywo i nie są przechowywane przez serwer pośredniczący.",
"LABEL_CHAT_POSITION": "Położenie",
"LABEL_CHAT_POSITION_TOOLTIP": "Wybierz, gdzie czat ma być przypięty do wybranego odtwarzacza.",
"LABEL_CHAT_SIZE": "Rozmiar nakładki",
"LABEL_CHAT_SIZE_TOOLTIP": "Wybierz domyślny rozmiar nakładki czatu.",
"OPTION_CHAT_SIZE_COMPACT": "Kompaktowy",
"OPTION_CHAT_SIZE_STANDARD": "Standardowy",
"OPTION_CHAT_SIZE_LARGE": "Duży",
"OPTION_CHAT_SIZE_CUSTOM": "Ostatni własny rozmiar",
"LABEL_CHAT_START_MODE": "Uruchom jako",
"LABEL_CHAT_START_MODE_TOOLTIP": "Wybierz, czy czat ma startować jako pływający przycisk, czy otwarta nakładka.",
"OPTION_CHAT_START_BUBBLE": "Pływający przycisk",
"OPTION_CHAT_START_OPEN": "Otwarta nakładka",
"LABEL_CHAT_REACTION_DISPLAY": "Szybkie reakcje",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Wybierz, czy szybkie reakcje mają być tylko na czacie, czy również spadać na wideo.",
"OPTION_CHAT_REACTIONS_CHAT": "Tylko czat",
"OPTION_CHAT_REACTIONS_VIDEO": "Czat i wideo",
"CHAT_QUICK_REACTIONS": "Szybkie reakcje",
"CHAT_TITLE": "Czat pokoju",
"CHAT_LIVE_ONLY": "Tylko na żywo. Bez historii.",
"CHAT_OPEN": "Otwórz czat pokoju",
"CHAT_CLOSE": "Zamknij czat",
"CHAT_DOCK_LEFT": "Przypnij czat po lewej",
"CHAT_DOCK_RIGHT": "Przypnij czat po prawej",
"CHAT_DETACHED": "Odłącz czat",
"CHAT_PLACEHOLDER": "Napisz wiadomość...",
"CHAT_SEND": "Wyślij",
"CHAT_MISSING_KEY": "Czat jest dostępny tylko przez aktualny link zaproszenia.",
"CHAT_TOO_LONG": "Maksymalnie 5000 znaków.",
"CHAT_SEND_FAILED": "Nie udało się wysłać wiadomości.",
"CHAT_EMPTY": "Brak wiadomości. Czat działa tylko na żywo.",
"LABEL_CHAT_NOTIFICATIONS": "Powiadomienia czatu",
"LABEL_CHAT_NOTIFICATIONS_TOOLTIP": "Pokazuje powiadomienie systemowe o nowej wiadomości, gdy karta wideo nie jest aktywna.",
"LABEL_EVENT_NOTIFICATIONS": "Powiadomienia o odtwarzaniu i pokoju",
"LABEL_EVENT_NOTIFICATIONS_TOOLTIP": "Pokazuje powiadomienia, gdy ktoś dołącza, wychodzi, odtwarza lub wstrzymuje.",
"DEBUG_SECTION_VIDEO_DETECTION": "Wykrywanie wideo",
"DEBUG_VIDEO_NOT_FOUND": "NIE ZNALEZIONO ELEMENTU WIDEO",
"DEBUG_SECTION_MEDIA_SESSION": "API Media Session",
"DEBUG_VIDEO_HINT": "Czy wideo jest odtwarzane? KoalaSync wykrywa elementy wideo po załadowaniu multimediów.",
"DEBUG_SECTION_PLAYBACK": "Odtwarzanie",
"DEBUG_STATE_PAUSED": "WSTRZYMANE",
"DEBUG_STATE_PLAYING": "ODTWARZANIE",
"DEBUG_SECTION_PROPERTIES": "Właściwości",
"DEBUG_SECTION_DIMENSIONS": "Wymiary",
"DEBUG_DIMENSIONS_HINT": "0×0 = element wideo jest ukryty lub jeszcze niezaładowany",
"DEBUG_SECTION_MEDIA_ERROR": "Błąd multimediów",
"DEBUG_SECTION_DETECTION": "Wykrywanie",
"DEBUG_SECTION_IDENTIFICATION": "Identyfikacja",
"DEBUG_SECTION_MEDIA_SOURCE": "Źródło multimediów",
"DEBUG_SECTION_DATA_ATTRIBUTES": "Atrybuty danych",
"DEBUG_YES": "TAK",
"DEBUG_NO": "NIE",
"DEBUG_YES_CHECKED": "TAK (sprawdzono)",
"DEBUG_NA": "brak"
}
+60 -3
View File
@@ -134,7 +134,7 @@
"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 com seus amigos em perfeita sincronia, não importa onde estejam. Vamos fazer um tour rápido!",
"ONBOARDING_1_TEXT": "Assista a vídeos com outras pessoas, onde vocês estiverem. Veja uma apresentação rápida.",
"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",
@@ -204,7 +204,7 @@
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Tempo limite — nem todos carregaram o episódio",
"LOBBY_CANCEL_USER": "Cancelado pelo usuário",
"NOTIF_ERROR_TITLE": "Erro no KoalaSync",
"FOOTER_SUPPORT": "Support KoalaSync",
"FOOTER_SUPPORT": "Apoie o KoalaSync",
"FOOTER_REVIEW": "★ Avaliar",
"FOOTER_SUPPORT_PROMPT": "Gostou do KoalaSync? Deixe uma avaliação!",
"LABEL_AUDIO_PROCESSING": "Processamento de áudio",
@@ -245,5 +245,62 @@
"LABEL_SETTINGS_GROUP_PROFILE": "Perfil e Aparência",
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Altere o seu nome de usuário, tema e preferências de idioma.",
"LABEL_SETTINGS_GROUP_SYNC": "Reprodução e Sincronização",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio."
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio.",
"LABEL_CHAT_ENABLED": "Ativar chat da sala",
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostrar o chat criptografado da sala na guia de vídeo selecionada.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configure a aparência e a posição do chat criptografado da sala.",
"CHAT_SETTINGS_NOTE": "Opcional e criptografado de ponta a ponta. As mensagens são apenas ao vivo e não são armazenadas pelo relay.",
"LABEL_CHAT_POSITION": "Posição",
"LABEL_CHAT_POSITION_TOOLTIP": "Escolha onde o chat fica fixado no player selecionado.",
"LABEL_CHAT_SIZE": "Tamanho da sobreposição",
"LABEL_CHAT_SIZE_TOOLTIP": "Escolha o tamanho padrão da sobreposição do chat.",
"OPTION_CHAT_SIZE_COMPACT": "Compacto",
"OPTION_CHAT_SIZE_STANDARD": "Padrão",
"OPTION_CHAT_SIZE_LARGE": "Grande",
"OPTION_CHAT_SIZE_CUSTOM": "Último tamanho personalizado",
"LABEL_CHAT_START_MODE": "Iniciar como",
"LABEL_CHAT_START_MODE_TOOLTIP": "Escolha se o chat começa como uma bolha flutuante ou uma sobreposição aberta.",
"OPTION_CHAT_START_BUBBLE": "Bolha flutuante",
"OPTION_CHAT_START_OPEN": "Sobreposição aberta",
"LABEL_CHAT_REACTION_DISPLAY": "Reações rápidas",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Escolha se as reações rápidas ficam apenas no chat ou também caem sobre o vídeo.",
"OPTION_CHAT_REACTIONS_CHAT": "Somente chat",
"OPTION_CHAT_REACTIONS_VIDEO": "Chat e vídeo",
"CHAT_QUICK_REACTIONS": "Reações rápidas",
"CHAT_TITLE": "Chat da sala",
"CHAT_LIVE_ONLY": "Somente ao vivo. Sem histórico.",
"CHAT_OPEN": "Abrir chat da sala",
"CHAT_CLOSE": "Fechar chat",
"CHAT_DOCK_LEFT": "Fixar chat à esquerda",
"CHAT_DOCK_RIGHT": "Fixar chat à direita",
"CHAT_DETACHED": "Desanexar chat",
"CHAT_PLACEHOLDER": "Escreva uma mensagem...",
"CHAT_SEND": "Enviar",
"CHAT_MISSING_KEY": "O chat está disponível apenas por um link de convite atual.",
"CHAT_TOO_LONG": "Máximo de 5000 caracteres.",
"CHAT_SEND_FAILED": "Não foi possível enviar a mensagem.",
"CHAT_EMPTY": "Ainda não há mensagens. O chat é somente ao vivo.",
"LABEL_CHAT_NOTIFICATIONS": "Notificações do chat",
"LABEL_CHAT_NOTIFICATIONS_TOOLTIP": "Mostra uma notificação do sistema para novas mensagens quando a aba do vídeo não está ativa.",
"LABEL_EVENT_NOTIFICATIONS": "Notificações de reprodução e sala",
"LABEL_EVENT_NOTIFICATIONS_TOOLTIP": "Mostra notificações quando alguém entra, sai, reproduz ou pausa.",
"DEBUG_SECTION_VIDEO_DETECTION": "Detecção de vídeo",
"DEBUG_VIDEO_NOT_FOUND": "NENHUM ELEMENTO DE VÍDEO ENCONTRADO",
"DEBUG_SECTION_MEDIA_SESSION": "API Media Session",
"DEBUG_VIDEO_HINT": "Há um vídeo sendo reproduzido? O KoalaSync detecta elementos de vídeo após o carregamento da mídia.",
"DEBUG_SECTION_PLAYBACK": "Reprodução",
"DEBUG_STATE_PAUSED": "PAUSADO",
"DEBUG_STATE_PLAYING": "REPRODUZINDO",
"DEBUG_SECTION_PROPERTIES": "Propriedades",
"DEBUG_SECTION_DIMENSIONS": "Dimensões",
"DEBUG_DIMENSIONS_HINT": "0×0 = elemento de vídeo oculto ou ainda não carregado",
"DEBUG_SECTION_MEDIA_ERROR": "Erro de mídia",
"DEBUG_SECTION_DETECTION": "Detecção",
"DEBUG_SECTION_IDENTIFICATION": "Identificação",
"DEBUG_SECTION_MEDIA_SOURCE": "Fonte de mídia",
"DEBUG_SECTION_DATA_ATTRIBUTES": "Atributos de dados",
"DEBUG_YES": "SIM",
"DEBUG_NO": "NÃO",
"DEBUG_YES_CHECKED": "SIM (verificado)",
"DEBUG_NA": "n/d"
}
+60 -3
View File
@@ -134,7 +134,7 @@
"BTN_ONBOARDING_NEXT": "Seguinte",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Ir para o passo seguinte",
"ONBOARDING_1_TITLE": "Bem-vindo ao KoalaSync!",
"ONBOARDING_1_TEXT": "Veja vídeos em conjunto com os seus amigos em sincronia perfeita. Vamos fazer uma breve visita guiada!",
"ONBOARDING_1_TEXT": "Vê vídeos com outras pessoas, onde quer que estejam. Aqui tens uma breve apresentação.",
"ONBOARDING_2_TITLE": "1. Criar uma Sala",
"ONBOARDING_2_TEXT": "Comece aqui. Crie uma sala e partilhe o link de convite com os seus amigos.",
"ONBOARDING_3_TITLE": "2. Selecionar Vídeo",
@@ -204,7 +204,7 @@
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Tempo limite — alguns participantes não carregaram o episódio",
"LOBBY_CANCEL_USER": "Cancelado pelo utilizador",
"NOTIF_ERROR_TITLE": "Erro no KoalaSync",
"FOOTER_SUPPORT": "Support KoalaSync",
"FOOTER_SUPPORT": "Apoiar o KoalaSync",
"FOOTER_REVIEW": "★ Avaliar",
"FOOTER_SUPPORT_PROMPT": "Gostas do KoalaSync? Deixa uma avaliação!",
"LABEL_AUDIO_PROCESSING": "Processamento de Áudio",
@@ -245,5 +245,62 @@
"LABEL_SETTINGS_GROUP_PROFILE": "Perfil e Aparência",
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Altere o seu nome de utilizador, tema e preferências de idioma.",
"LABEL_SETTINGS_GROUP_SYNC": "Reprodução e Sincronização",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio."
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio.",
"LABEL_CHAT_ENABLED": "Ativar chat da sala",
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostrar o chat encriptado da sala no separador de vídeo selecionado.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configure o aspeto e a posição do chat encriptado da sala.",
"CHAT_SETTINGS_NOTE": "Opcional e encriptado de ponta a ponta. As mensagens são apenas em direto e não são guardadas pelo relay.",
"LABEL_CHAT_POSITION": "Posição",
"LABEL_CHAT_POSITION_TOOLTIP": "Escolha onde o chat fica fixado no leitor selecionado.",
"LABEL_CHAT_SIZE": "Tamanho da sobreposição",
"LABEL_CHAT_SIZE_TOOLTIP": "Escolha o tamanho predefinido da sobreposição do chat.",
"OPTION_CHAT_SIZE_COMPACT": "Compacto",
"OPTION_CHAT_SIZE_STANDARD": "Padrão",
"OPTION_CHAT_SIZE_LARGE": "Grande",
"OPTION_CHAT_SIZE_CUSTOM": "Último tamanho personalizado",
"LABEL_CHAT_START_MODE": "Iniciar como",
"LABEL_CHAT_START_MODE_TOOLTIP": "Escolha se o chat começa como uma bolha flutuante ou uma sobreposição aberta.",
"OPTION_CHAT_START_BUBBLE": "Bolha flutuante",
"OPTION_CHAT_START_OPEN": "Sobreposição aberta",
"LABEL_CHAT_REACTION_DISPLAY": "Reações rápidas",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Escolha se as reações rápidas ficam apenas no chat ou também caem sobre o vídeo.",
"OPTION_CHAT_REACTIONS_CHAT": "Apenas chat",
"OPTION_CHAT_REACTIONS_VIDEO": "Chat e vídeo",
"CHAT_QUICK_REACTIONS": "Reações rápidas",
"CHAT_TITLE": "Chat da sala",
"CHAT_LIVE_ONLY": "Apenas em direto. Sem histórico.",
"CHAT_OPEN": "Abrir chat da sala",
"CHAT_CLOSE": "Fechar chat",
"CHAT_DOCK_LEFT": "Fixar chat à esquerda",
"CHAT_DOCK_RIGHT": "Fixar chat à direita",
"CHAT_DETACHED": "Desanexar chat",
"CHAT_PLACEHOLDER": "Escreva uma mensagem...",
"CHAT_SEND": "Enviar",
"CHAT_MISSING_KEY": "O chat está disponível apenas através de um link de convite atual.",
"CHAT_TOO_LONG": "Máximo de 5000 caracteres.",
"CHAT_SEND_FAILED": "Não foi possível enviar a mensagem.",
"CHAT_EMPTY": "Ainda não há mensagens. O chat funciona apenas em direto.",
"LABEL_CHAT_NOTIFICATIONS": "Notificações do chat",
"LABEL_CHAT_NOTIFICATIONS_TOOLTIP": "Mostra uma notificação do sistema para novas mensagens quando o separador do vídeo não está ativo.",
"LABEL_EVENT_NOTIFICATIONS": "Notificações de reprodução e sala",
"LABEL_EVENT_NOTIFICATIONS_TOOLTIP": "Mostra notificações quando alguém entra, sai, reproduz ou pausa.",
"DEBUG_SECTION_VIDEO_DETECTION": "Deteção de vídeo",
"DEBUG_VIDEO_NOT_FOUND": "NENHUM ELEMENTO DE VÍDEO ENCONTRADO",
"DEBUG_SECTION_MEDIA_SESSION": "API Media Session",
"DEBUG_VIDEO_HINT": "Está algum vídeo em reprodução? O KoalaSync deteta elementos de vídeo após o carregamento do conteúdo.",
"DEBUG_SECTION_PLAYBACK": "Reprodução",
"DEBUG_STATE_PAUSED": "EM PAUSA",
"DEBUG_STATE_PLAYING": "A REPRODUZIR",
"DEBUG_SECTION_PROPERTIES": "Propriedades",
"DEBUG_SECTION_DIMENSIONS": "Dimensões",
"DEBUG_DIMENSIONS_HINT": "0×0 = elemento de vídeo oculto ou ainda não carregado",
"DEBUG_SECTION_MEDIA_ERROR": "Erro multimédia",
"DEBUG_SECTION_DETECTION": "Deteção",
"DEBUG_SECTION_IDENTIFICATION": "Identificação",
"DEBUG_SECTION_MEDIA_SOURCE": "Fonte multimédia",
"DEBUG_SECTION_DATA_ATTRIBUTES": "Atributos de dados",
"DEBUG_YES": "SIM",
"DEBUG_NO": "NÃO",
"DEBUG_YES_CHECKED": "SIM (verificado)",
"DEBUG_NA": "n/d"
}
+60 -3
View File
@@ -134,7 +134,7 @@
"BTN_ONBOARDING_NEXT": "Далее",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Перейти к следующему шагу",
"ONBOARDING_1_TITLE": "Добро пожаловать в KoalaSync!",
"ONBOARDING_1_TEXT": "Смотрите видео в идеальной синхронизации вместе где бы вы ни находились. Давайте проведем небольшую экскурсию!",
"ONBOARDING_1_TEXT": "Смотрите видео вместе, где бы вы ни находились. Вот краткий обзор.",
"ONBOARDING_2_TITLE": "1. Создайте комнату",
"ONBOARDING_2_TEXT": "Начните здесь. Создайте комнату и поделитесь ссылкой-приглашением со своими друзьями.",
"ONBOARDING_3_TITLE": "2. Выберите видео",
@@ -204,7 +204,7 @@
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Время ожидания истекло — не все участники загрузили серию",
"LOBBY_CANCEL_USER": "Отменено пользователем",
"NOTIF_ERROR_TITLE": "Ошибка KoalaSync",
"FOOTER_SUPPORT": "Support KoalaSync",
"FOOTER_SUPPORT": "Поддержать KoalaSync",
"FOOTER_REVIEW": "★ Оценить",
"FOOTER_SUPPORT_PROMPT": "Нравится KoalaSync? Оставьте отзыв!",
"LABEL_AUDIO_PROCESSING": "Обработка звука",
@@ -245,5 +245,62 @@
"LABEL_SETTINGS_GROUP_PROFILE": "Профиль и Оформление",
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Измените имя пользователя, тему и настройки языка.",
"LABEL_SETTINGS_GROUP_SYNC": "Воспроизведение и Синхронизация",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Настройте воспроизведение, уведомления и параметры звука."
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Настройте воспроизведение, уведомления и параметры звука.",
"LABEL_CHAT_ENABLED": "Включить чат комнаты",
"LABEL_CHAT_ENABLED_TOOLTIP": "Показывать зашифрованный чат комнаты на выбранной вкладке с видео.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Настройте внешний вид и расположение зашифрованного чата комнаты.",
"CHAT_SETTINGS_NOTE": "Необязательный чат со сквозным шифрованием. Сообщения доступны только в реальном времени и не сохраняются ретранслятором.",
"LABEL_CHAT_POSITION": "Положение",
"LABEL_CHAT_POSITION_TOOLTIP": "Выберите, где чат будет закреплён на выбранном проигрывателе.",
"LABEL_CHAT_SIZE": "Размер окна",
"LABEL_CHAT_SIZE_TOOLTIP": "Выберите стандартный размер окна чата.",
"OPTION_CHAT_SIZE_COMPACT": "Компактный",
"OPTION_CHAT_SIZE_STANDARD": "Стандартный",
"OPTION_CHAT_SIZE_LARGE": "Большой",
"OPTION_CHAT_SIZE_CUSTOM": "Последний пользовательский размер",
"LABEL_CHAT_START_MODE": "Начальный вид",
"LABEL_CHAT_START_MODE_TOOLTIP": "Выберите, запускать чат как плавающую кнопку или открытое окно.",
"OPTION_CHAT_START_BUBBLE": "Плавающая кнопка",
"OPTION_CHAT_START_OPEN": "Открытое окно",
"LABEL_CHAT_REACTION_DISPLAY": "Быстрые реакции",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Выберите, показывать быстрые реакции только в чате или также поверх видео.",
"OPTION_CHAT_REACTIONS_CHAT": "Только чат",
"OPTION_CHAT_REACTIONS_VIDEO": "Чат и видео",
"CHAT_QUICK_REACTIONS": "Быстрые реакции",
"CHAT_TITLE": "Чат комнаты",
"CHAT_LIVE_ONLY": "Только в реальном времени. Без истории.",
"CHAT_OPEN": "Открыть чат комнаты",
"CHAT_CLOSE": "Закрыть чат",
"CHAT_DOCK_LEFT": "Закрепить чат слева",
"CHAT_DOCK_RIGHT": "Закрепить чат справа",
"CHAT_DETACHED": "Открепить чат",
"CHAT_PLACEHOLDER": "Напишите сообщение...",
"CHAT_SEND": "Отправить",
"CHAT_MISSING_KEY": "Чат доступен только по актуальной ссылке-приглашению.",
"CHAT_TOO_LONG": "Не более 5000 символов.",
"CHAT_SEND_FAILED": "Не удалось отправить сообщение.",
"CHAT_EMPTY": "Сообщений пока нет. Чат работает только в реальном времени.",
"LABEL_CHAT_NOTIFICATIONS": "Уведомления чата",
"LABEL_CHAT_NOTIFICATIONS_TOOLTIP": "Показывает системное уведомление о новых сообщениях, когда вкладка с видео не активна.",
"LABEL_EVENT_NOTIFICATIONS": "Уведомления о воспроизведении и комнате",
"LABEL_EVENT_NOTIFICATIONS_TOOLTIP": "Показывает уведомления, когда кто-то входит, выходит, запускает или приостанавливает видео.",
"DEBUG_SECTION_VIDEO_DETECTION": "Обнаружение видео",
"DEBUG_VIDEO_NOT_FOUND": "ВИДЕОЭЛЕМЕНТ НЕ НАЙДЕН",
"DEBUG_SECTION_MEDIA_SESSION": "API Media Session",
"DEBUG_VIDEO_HINT": "Видео сейчас воспроизводится? KoalaSync обнаруживает видеоэлементы после загрузки контента.",
"DEBUG_SECTION_PLAYBACK": "Воспроизведение",
"DEBUG_STATE_PAUSED": "ПАУЗА",
"DEBUG_STATE_PLAYING": "ВОСПРОИЗВЕДЕНИЕ",
"DEBUG_SECTION_PROPERTIES": "Свойства",
"DEBUG_SECTION_DIMENSIONS": "Размеры",
"DEBUG_DIMENSIONS_HINT": "0×0 = видеоэлемент скрыт или ещё не загружен",
"DEBUG_SECTION_MEDIA_ERROR": "Ошибка медиа",
"DEBUG_SECTION_DETECTION": "Обнаружение",
"DEBUG_SECTION_IDENTIFICATION": "Идентификация",
"DEBUG_SECTION_MEDIA_SOURCE": "Источник медиа",
"DEBUG_SECTION_DATA_ATTRIBUTES": "Атрибуты данных",
"DEBUG_YES": "ДА",
"DEBUG_NO": "НЕТ",
"DEBUG_YES_CHECKED": "ДА (проверено)",
"DEBUG_NA": "н/д"
}
+60 -3
View File
@@ -134,7 +134,7 @@
"BTN_ONBOARDING_NEXT": "İleri",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Bir sonraki adıma git",
"ONBOARDING_1_TITLE": "KoalaSync'e Hoş Geldiniz!",
"ONBOARDING_1_TEXT": "Nerede olursanız olun videoları mükemmel bir senkronizasyonla birlikte izleyin. Hızlı bir tura çıkalım!",
"ONBOARDING_1_TEXT": "Nerede olursanız olun videoları birlikte izleyin. İşte kısa bir tanıtım.",
"ONBOARDING_2_TITLE": "1. Oda Oluştur",
"ONBOARDING_2_TEXT": "Buradan başlayın. Bir oda oluşturun ve davet bağlantısını arkadaşlarınızla paylaşın.",
"ONBOARDING_3_TITLE": "2. Video Seç",
@@ -204,7 +204,7 @@
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Zaman aşımı — tüm bağlantılar bölümü yüklemedi",
"LOBBY_CANCEL_USER": "Kullanıcı tarafından iptal edildi",
"NOTIF_ERROR_TITLE": "KoalaSync Hatası",
"FOOTER_SUPPORT": "Support KoalaSync",
"FOOTER_SUPPORT": "KoalaSynci destekle",
"FOOTER_REVIEW": "★ Değerlendir",
"FOOTER_SUPPORT_PROMPT": "KoalaSync'i beğendin mi? Bir yorum bırak!",
"LABEL_AUDIO_PROCESSING": "Ses işleme",
@@ -245,5 +245,62 @@
"LABEL_SETTINGS_GROUP_PROFILE": "Profil ve Görünüm",
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Kullanıcı adınızı, temanızı ve dil tercihlerinizi değiştirin.",
"LABEL_SETTINGS_GROUP_SYNC": "Oynatma ve Eşitleme",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Oynatma, bildirim ve ses tercihlerini özelleştirin."
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Oynatma, bildirim ve ses tercihlerini özelleştirin.",
"LABEL_CHAT_ENABLED": "Oda sohbetini etkinleştir",
"LABEL_CHAT_ENABLED_TOOLTIP": "Şifreli oda sohbetini seçili video sekmesinde göster.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Şifreli oda sohbetinin görünümünü ve konumunu yapılandırın.",
"CHAT_SETTINGS_NOTE": "İsteğe bağlı ve uçtan uca şifrelidir. Mesajlar yalnızca canlıdır ve aktarıcı tarafından saklanmaz.",
"LABEL_CHAT_POSITION": "Konum",
"LABEL_CHAT_POSITION_TOOLTIP": "Sohbetin seçili oynatıcıda nereye sabitleneceğini seçin.",
"LABEL_CHAT_SIZE": "Kaplama boyutu",
"LABEL_CHAT_SIZE_TOOLTIP": "Sohbet kaplamasının varsayılan boyutunu seçin.",
"OPTION_CHAT_SIZE_COMPACT": "Kompakt",
"OPTION_CHAT_SIZE_STANDARD": "Standart",
"OPTION_CHAT_SIZE_LARGE": "Büyük",
"OPTION_CHAT_SIZE_CUSTOM": "Son özel boyut",
"LABEL_CHAT_START_MODE": "Başlangıç görünümü",
"LABEL_CHAT_START_MODE_TOOLTIP": "Sohbetin kayan balon veya açık kaplama olarak başlamasını seçin.",
"OPTION_CHAT_START_BUBBLE": "Kayan sohbet balonu",
"OPTION_CHAT_START_OPEN": "Açık kaplama",
"LABEL_CHAT_REACTION_DISPLAY": "Hızlı tepkiler",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Hızlı tepkilerin yalnızca sohbette mi yoksa videonun üzerinde de mi görüneceğini seçin.",
"OPTION_CHAT_REACTIONS_CHAT": "Yalnızca sohbet",
"OPTION_CHAT_REACTIONS_VIDEO": "Sohbet ve video",
"CHAT_QUICK_REACTIONS": "Hızlı tepkiler",
"CHAT_TITLE": "Oda Sohbeti",
"CHAT_LIVE_ONLY": "Yalnızca canlı. Geçmiş yok.",
"CHAT_OPEN": "Oda sohbetini aç",
"CHAT_CLOSE": "Sohbeti kapat",
"CHAT_DOCK_LEFT": "Sohbeti sola sabitle",
"CHAT_DOCK_RIGHT": "Sohbeti sağa sabitle",
"CHAT_DETACHED": "Sohbeti ayır",
"CHAT_PLACEHOLDER": "Bir mesaj yazın...",
"CHAT_SEND": "Gönder",
"CHAT_MISSING_KEY": "Sohbet yalnızca güncel bir davet bağlantısıyla kullanılabilir.",
"CHAT_TOO_LONG": "En fazla 5000 karakter.",
"CHAT_SEND_FAILED": "Mesaj gönderilemedi.",
"CHAT_EMPTY": "Henüz mesaj yok. Sohbet yalnızca canlıdır.",
"LABEL_CHAT_NOTIFICATIONS": "Sohbet bildirimleri",
"LABEL_CHAT_NOTIFICATIONS_TOOLTIP": "Video sekmesi etkin değilken yeni oda mesajlarını sistem bildirimi olarak gösterir.",
"LABEL_EVENT_NOTIFICATIONS": "Oynatma ve oda bildirimleri",
"LABEL_EVENT_NOTIFICATIONS_TOOLTIP": "Biri katıldığında, ayrıldığında, oynattığında veya duraklattığında bildirim gösterir.",
"DEBUG_SECTION_VIDEO_DETECTION": "Video algılama",
"DEBUG_VIDEO_NOT_FOUND": "VİDEO ÖĞESİ BULUNAMADI",
"DEBUG_SECTION_MEDIA_SESSION": "Media Session API",
"DEBUG_VIDEO_HINT": "Şu anda video oynatılıyor mu? KoalaSync medya yüklendikten sonra video öğelerini algılar.",
"DEBUG_SECTION_PLAYBACK": "Oynatma",
"DEBUG_STATE_PAUSED": "DURAKLATILDI",
"DEBUG_STATE_PLAYING": "OYNATILIYOR",
"DEBUG_SECTION_PROPERTIES": "Özellikler",
"DEBUG_SECTION_DIMENSIONS": "Boyutlar",
"DEBUG_DIMENSIONS_HINT": "0×0 = video öğesi gizli veya henüz yüklenmedi",
"DEBUG_SECTION_MEDIA_ERROR": "Medya hatası",
"DEBUG_SECTION_DETECTION": "Algılama",
"DEBUG_SECTION_IDENTIFICATION": "Tanımlama",
"DEBUG_SECTION_MEDIA_SOURCE": "Medya kaynağı",
"DEBUG_SECTION_DATA_ATTRIBUTES": "Veri öznitelikleri",
"DEBUG_YES": "EVET",
"DEBUG_NO": "HAYIR",
"DEBUG_YES_CHECKED": "EVET (kontrol edildi)",
"DEBUG_NA": "yok"
}
+60 -3
View File
@@ -134,7 +134,7 @@
"BTN_ONBOARDING_NEXT": "Далі",
"BTN_ONBOARDING_NEXT_TOOLTIP": "Перейти до наступного кроку",
"ONBOARDING_1_TITLE": "Ласкаво просимо до KoalaSync!",
"ONBOARDING_1_TEXT": "Дивіться відео разом у ідеальній синхронізації — де б ви не були. Давайте зробимо короткий тур!",
"ONBOARDING_1_TEXT": "Дивіться відео разом, де б ви не були. Ось короткий огляд.",
"ONBOARDING_2_TITLE": "1. Створіть кімнату",
"ONBOARDING_2_TEXT": "Почніть тут. Створіть кімнату та поділіться посиланням запрошення з друзями.",
"ONBOARDING_3_TITLE": "2. Виберіть Відео",
@@ -204,7 +204,7 @@
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "Тайм-аут — не всі вузли завантажили епізод",
"LOBBY_CANCEL_USER": "Скасовано користувачем",
"NOTIF_ERROR_TITLE": "Помилка KoalaSync",
"FOOTER_SUPPORT": "Support KoalaSync",
"FOOTER_SUPPORT": "Підтримати KoalaSync",
"FOOTER_REVIEW": "★ Оцініть нас",
"FOOTER_SUPPORT_PROMPT": "Подобається KoalaSync? Залиште відгук!",
"LABEL_AUDIO_PROCESSING": "Обробка аудіо",
@@ -245,5 +245,62 @@
"LABEL_SETTINGS_GROUP_PROFILE": "Профіль та Вигляд",
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Змініть ім'я користувача, тему та мовні налаштування.",
"LABEL_SETTINGS_GROUP_SYNC": "Відтворення та Синхронізація",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Налаштуйте відтворення, сповіщення та параметри звуку."
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Налаштуйте відтворення, сповіщення та параметри звуку.",
"LABEL_CHAT_ENABLED": "Увімкнути чат кімнати",
"LABEL_CHAT_ENABLED_TOOLTIP": "Показувати зашифрований чат кімнати на вибраній вкладці з відео.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Налаштуйте вигляд і розташування зашифрованого чату кімнати.",
"CHAT_SETTINGS_NOTE": "Необов'язковий чат із наскрізним шифруванням. Повідомлення доступні лише наживо й не зберігаються ретранслятором.",
"LABEL_CHAT_POSITION": "Розташування",
"LABEL_CHAT_POSITION_TOOLTIP": "Виберіть, де чат буде закріплено на вибраному програвачі.",
"LABEL_CHAT_SIZE": "Розмір вікна",
"LABEL_CHAT_SIZE_TOOLTIP": "Виберіть стандартний розмір вікна чату.",
"OPTION_CHAT_SIZE_COMPACT": "Компактний",
"OPTION_CHAT_SIZE_STANDARD": "Стандартний",
"OPTION_CHAT_SIZE_LARGE": "Великий",
"OPTION_CHAT_SIZE_CUSTOM": "Останній власний розмір",
"LABEL_CHAT_START_MODE": "Початковий вигляд",
"LABEL_CHAT_START_MODE_TOOLTIP": "Виберіть, запускати чат як плаваючу кнопку чи відкрите вікно.",
"OPTION_CHAT_START_BUBBLE": "Плаваюча кнопка",
"OPTION_CHAT_START_OPEN": "Відкрите вікно",
"LABEL_CHAT_REACTION_DISPLAY": "Швидкі реакції",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "Виберіть, показувати швидкі реакції лише в чаті чи також поверх відео.",
"OPTION_CHAT_REACTIONS_CHAT": "Лише чат",
"OPTION_CHAT_REACTIONS_VIDEO": "Чат і відео",
"CHAT_QUICK_REACTIONS": "Швидкі реакції",
"CHAT_TITLE": "Чат кімнати",
"CHAT_LIVE_ONLY": "Лише наживо. Без історії.",
"CHAT_OPEN": "Відкрити чат кімнати",
"CHAT_CLOSE": "Закрити чат",
"CHAT_DOCK_LEFT": "Закріпити чат ліворуч",
"CHAT_DOCK_RIGHT": "Закріпити чат праворуч",
"CHAT_DETACHED": "Від'єднати чат",
"CHAT_PLACEHOLDER": "Напишіть повідомлення...",
"CHAT_SEND": "Надіслати",
"CHAT_MISSING_KEY": "Чат доступний лише за актуальним посиланням-запрошенням.",
"CHAT_TOO_LONG": "Не більше 5000 символів.",
"CHAT_SEND_FAILED": "Не вдалося надіслати повідомлення.",
"CHAT_EMPTY": "Повідомлень ще немає. Чат працює лише наживо.",
"LABEL_CHAT_NOTIFICATIONS": "Сповіщення чату",
"LABEL_CHAT_NOTIFICATIONS_TOOLTIP": "Показує системне сповіщення про нові повідомлення, коли вкладка з відео неактивна.",
"LABEL_EVENT_NOTIFICATIONS": "Сповіщення про відтворення та кімнату",
"LABEL_EVENT_NOTIFICATIONS_TOOLTIP": "Показує сповіщення, коли хтось приєднується, виходить, запускає або призупиняє відео.",
"DEBUG_SECTION_VIDEO_DETECTION": "Виявлення відео",
"DEBUG_VIDEO_NOT_FOUND": "ВІДЕОЕЛЕМЕНТ НЕ ЗНАЙДЕНО",
"DEBUG_SECTION_MEDIA_SESSION": "API Media Session",
"DEBUG_VIDEO_HINT": "Відео зараз відтворюється? KoalaSync виявляє відеоелементи після завантаження медіа.",
"DEBUG_SECTION_PLAYBACK": "Відтворення",
"DEBUG_STATE_PAUSED": "ПРИЗУПИНЕНО",
"DEBUG_STATE_PLAYING": "ВІДТВОРЮЄТЬСЯ",
"DEBUG_SECTION_PROPERTIES": "Властивості",
"DEBUG_SECTION_DIMENSIONS": "Розміри",
"DEBUG_DIMENSIONS_HINT": "0×0 = відеоелемент прихований або ще не завантажений",
"DEBUG_SECTION_MEDIA_ERROR": "Помилка медіа",
"DEBUG_SECTION_DETECTION": "Виявлення",
"DEBUG_SECTION_IDENTIFICATION": "Ідентифікація",
"DEBUG_SECTION_MEDIA_SOURCE": "Джерело медіа",
"DEBUG_SECTION_DATA_ATTRIBUTES": "Атрибути даних",
"DEBUG_YES": "ТАК",
"DEBUG_NO": "НІ",
"DEBUG_YES_CHECKED": "ТАК (перевірено)",
"DEBUG_NA": "н/д"
}
+60 -3
View File
@@ -134,7 +134,7 @@
"BTN_ONBOARDING_NEXT": "下一个",
"BTN_ONBOARDING_NEXT_TOOLTIP": "进入下一步",
"ONBOARDING_1_TITLE": "欢迎来到KoalaSync",
"ONBOARDING_1_TEXT": "无论身在何处,都能完美同步地一起看视频。让我们快速浏览一下吧!",
"ONBOARDING_1_TEXT": "无论身在何处,都可以一起看视频。下面是快速介绍。",
"ONBOARDING_2_TITLE": "1. 创建房间",
"ONBOARDING_2_TEXT": "从这里开始。创建一个房间并与您的朋友分享邀请链接。",
"ONBOARDING_3_TITLE": "2. 选择视频",
@@ -204,7 +204,7 @@
"LOBBY_CANCEL_TIMEOUT_PEERS_LOAD": "超时——并非所有同行都加载了该剧集",
"LOBBY_CANCEL_USER": "已被用户取消",
"NOTIF_ERROR_TITLE": "KoalaSync 错误",
"FOOTER_SUPPORT": "Support KoalaSync",
"FOOTER_SUPPORT": "支持 KoalaSync",
"FOOTER_REVIEW": "★ 评价我们",
"FOOTER_SUPPORT_PROMPT": "喜欢 KoalaSync 吗?留下评论!",
"LABEL_AUDIO_PROCESSING": "音频处理",
@@ -245,5 +245,62 @@
"LABEL_SETTINGS_GROUP_PROFILE": "个人资料与外观",
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "更改您的用户名、主题和语言首选项。",
"LABEL_SETTINGS_GROUP_SYNC": "播放与同步",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "自定义播放、通知和音频首选项。"
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "自定义播放、通知和音频首选项。",
"LABEL_CHAT_ENABLED": "启用房间聊天",
"LABEL_CHAT_ENABLED_TOOLTIP": "在选定的视频标签页中显示加密的房间聊天。",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "配置加密房间聊天的外观和位置。",
"CHAT_SETTINGS_NOTE": "可选并采用端到端加密。消息仅实时显示,且不会由中继服务器存储。",
"LABEL_CHAT_POSITION": "位置",
"LABEL_CHAT_POSITION_TOOLTIP": "选择聊天在当前播放器上的固定位置。",
"LABEL_CHAT_SIZE": "浮层大小",
"LABEL_CHAT_SIZE_TOOLTIP": "选择聊天浮层的默认大小。",
"OPTION_CHAT_SIZE_COMPACT": "紧凑",
"OPTION_CHAT_SIZE_STANDARD": "标准",
"OPTION_CHAT_SIZE_LARGE": "大",
"OPTION_CHAT_SIZE_CUSTOM": "上次自定义大小",
"LABEL_CHAT_START_MODE": "启动方式",
"LABEL_CHAT_START_MODE_TOOLTIP": "选择以悬浮气泡或打开的浮层启动聊天。",
"OPTION_CHAT_START_BUBBLE": "悬浮聊天气泡",
"OPTION_CHAT_START_OPEN": "打开浮层",
"LABEL_CHAT_REACTION_DISPLAY": "快捷回应",
"LABEL_CHAT_REACTION_DISPLAY_TOOLTIP": "选择快捷回应仅显示在聊天中,或同时飘落在视频上。",
"OPTION_CHAT_REACTIONS_CHAT": "仅聊天",
"OPTION_CHAT_REACTIONS_VIDEO": "聊天和视频",
"CHAT_QUICK_REACTIONS": "快捷回应",
"CHAT_TITLE": "房间聊天",
"CHAT_LIVE_ONLY": "仅实时显示,无历史记录。",
"CHAT_OPEN": "打开房间聊天",
"CHAT_CLOSE": "关闭聊天",
"CHAT_DOCK_LEFT": "将聊天固定在左侧",
"CHAT_DOCK_RIGHT": "将聊天固定在右侧",
"CHAT_DETACHED": "浮动聊天",
"CHAT_PLACEHOLDER": "输入消息...",
"CHAT_SEND": "发送",
"CHAT_MISSING_KEY": "聊天仅可通过当前邀请链接使用。",
"CHAT_TOO_LONG": "最多5000个字符。",
"CHAT_SEND_FAILED": "无法发送消息。",
"CHAT_EMPTY": "暂无消息。聊天仅实时显示。",
"LABEL_CHAT_NOTIFICATIONS": "聊天通知",
"LABEL_CHAT_NOTIFICATIONS_TOOLTIP": "视频标签页未激活时,以系统通知显示新的房间聊天消息。",
"LABEL_EVENT_NOTIFICATIONS": "播放与房间通知",
"LABEL_EVENT_NOTIFICATIONS_TOOLTIP": "有人加入、离开、播放或暂停时显示系统通知。",
"DEBUG_SECTION_VIDEO_DETECTION": "视频检测",
"DEBUG_VIDEO_NOT_FOUND": "未找到视频元素",
"DEBUG_SECTION_MEDIA_SESSION": "Media Session API",
"DEBUG_VIDEO_HINT": "当前是否正在播放视频?媒体加载后,KoalaSync 才能检测视频元素。",
"DEBUG_SECTION_PLAYBACK": "播放",
"DEBUG_STATE_PAUSED": "已暂停",
"DEBUG_STATE_PLAYING": "播放中",
"DEBUG_SECTION_PROPERTIES": "属性",
"DEBUG_SECTION_DIMENSIONS": "尺寸",
"DEBUG_DIMENSIONS_HINT": "0×0 = 视频元素已隐藏或尚未加载",
"DEBUG_SECTION_MEDIA_ERROR": "媒体错误",
"DEBUG_SECTION_DETECTION": "检测",
"DEBUG_SECTION_IDENTIFICATION": "标识",
"DEBUG_SECTION_MEDIA_SOURCE": "媒体来源",
"DEBUG_SECTION_DATA_ATTRIBUTES": "数据属性",
"DEBUG_YES": "是",
"DEBUG_NO": "否",
"DEBUG_YES_CHECKED": "是(已检查)",
"DEBUG_NA": "无"
}
+4 -3
View File
@@ -1,9 +1,10 @@
{
"manifest_version": 3,
"default_locale": "en",
"name": "KoalaSync",
"version": "2.6.3",
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
"name": "__MSG_appName__",
"short_name": "KoalaSync",
"version": "3.0.1",
"description": "__MSG_appDesc__",
"permissions": [
"storage",
"tabs",
+69 -3
View File
@@ -1215,6 +1215,12 @@
line-height: 1.45;
text-align: left;
}
[data-chat-setting] {
transition: opacity 0.2s;
}
[data-chat-setting].is-disabled {
opacity: 0.5;
}
.settings-issue-link {
margin: 8px 0 2px;
padding-top: 9px;
@@ -1371,7 +1377,7 @@
<!-- Room credentials — distinct group below the server picker. -->
<div class="form-group">
<label for="roomId" 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">
<input type="text" id="roomId" maxlength="64" 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 for="password" title="Optional password to restrict room access" data-i18n="LABEL_PASSWORD" data-i18n-title="LABEL_PASSWORD_TOOLTIP">Password (Optional)</label>
@@ -1615,6 +1621,66 @@
</div>
</details>
<details class="form-group" name="settings-accordion">
<summary title="Configure encrypted room chat appearance and placement." data-i18n="CHAT_TITLE" data-i18n-title="LABEL_SETTINGS_GROUP_CHAT_TOOLTIP">Room Chat</summary>
<div class="details-content">
<p class="settings-note" data-i18n="CHAT_SETTINGS_NOTE">Optional and end-to-end encrypted. Messages are live only and are not stored by the relay.</p>
<div class="settings-row">
<label for="chatEnabled" title="Show encrypted room chat on the selected video tab." data-i18n="LABEL_CHAT_ENABLED" data-i18n-title="LABEL_CHAT_ENABLED_TOOLTIP">Enable Room Chat</label>
<label class="toggle-switch">
<input type="checkbox" id="chatEnabled">
<span class="slider"></span>
</label>
</div>
<div class="settings-row" data-chat-setting>
<label for="chatNotifications" title="Shows a system notification for new room-chat messages when the video tab is not focused." data-i18n="LABEL_CHAT_NOTIFICATIONS" data-i18n-title="LABEL_CHAT_NOTIFICATIONS_TOOLTIP">Chat Notifications</label>
<label class="toggle-switch">
<input type="checkbox" id="chatNotifications">
<span class="slider"></span>
</label>
</div>
<div class="settings-row" data-chat-setting>
<label for="chatPosition" title="Choose where chat is attached to the selected player." data-i18n="LABEL_CHAT_POSITION" data-i18n-title="LABEL_CHAT_POSITION_TOOLTIP">Position</label>
<span class="settings-select">
<select id="chatPosition" class="settings-control">
<option value="right" data-i18n="CHAT_DOCK_RIGHT">Dock chat right</option>
<option value="left" data-i18n="CHAT_DOCK_LEFT">Dock chat left</option>
<option value="detached" data-i18n="CHAT_DETACHED">Detach chat</option>
</select>
</span>
</div>
<div class="settings-row" data-chat-setting>
<label for="chatSize" title="Choose the default size of the chat overlay." data-i18n="LABEL_CHAT_SIZE" data-i18n-title="LABEL_CHAT_SIZE_TOOLTIP">Overlay size</label>
<span class="settings-select">
<select id="chatSize" class="settings-control">
<option value="compact" data-i18n="OPTION_CHAT_SIZE_COMPACT">Compact</option>
<option value="standard" data-i18n="OPTION_CHAT_SIZE_STANDARD">Standard</option>
<option value="large" data-i18n="OPTION_CHAT_SIZE_LARGE">Large</option>
<option value="custom" data-i18n="OPTION_CHAT_SIZE_CUSTOM">Last custom size</option>
</select>
</span>
</div>
<div class="settings-row" data-chat-setting>
<label for="chatStartMode" title="Choose whether chat starts as a floating bubble or an open overlay." data-i18n="LABEL_CHAT_START_MODE" data-i18n-title="LABEL_CHAT_START_MODE_TOOLTIP">Start as</label>
<span class="settings-select">
<select id="chatStartMode" class="settings-control">
<option value="bubble" data-i18n="OPTION_CHAT_START_BUBBLE">Floating bubble</option>
<option value="open" data-i18n="OPTION_CHAT_START_OPEN">Open overlay</option>
</select>
</span>
</div>
<div class="settings-row" data-chat-setting>
<label for="chatReactionDisplay" title="Choose whether quick reactions stay in chat or also fall over the video." data-i18n="LABEL_CHAT_REACTION_DISPLAY" data-i18n-title="LABEL_CHAT_REACTION_DISPLAY_TOOLTIP">Quick reactions</label>
<span class="settings-select">
<select id="chatReactionDisplay" class="settings-control">
<option value="chat" data-i18n="OPTION_CHAT_REACTIONS_CHAT">Chat only</option>
<option value="video" data-i18n="OPTION_CHAT_REACTIONS_VIDEO">Chat and video</option>
</select>
</span>
</div>
</div>
</details>
<details class="form-group" name="settings-accordion">
<summary title="Customize playback, notifications, and audio preferences." data-i18n="LABEL_SETTINGS_GROUP_SYNC" data-i18n-title="LABEL_SETTINGS_GROUP_SYNC_TOOLTIP">Playback &amp; Sync</summary>
<div class="details-content">
@@ -1640,7 +1706,7 @@
</label>
</div>
<div class="settings-row">
<label for="browserNotifications" 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 for="browserNotifications" title="Shows system notifications when someone joins, leaves, plays, or pauses." data-i18n="LABEL_EVENT_NOTIFICATIONS" data-i18n-title="LABEL_EVENT_NOTIFICATIONS_TOOLTIP">Playback &amp; Room Notifications</label>
<label class="toggle-switch">
<input type="checkbox" id="browserNotifications">
<span class="slider"></span>
@@ -1682,7 +1748,7 @@
<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;">
No tab selected or video detected.
<span data-i18n="VIDEO_DEBUG_EMPTY">No tab selected or video detected.</span>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 8px;">
+154 -79
View File
@@ -3,8 +3,17 @@ import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js';
import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js';
import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js';
import { normalizeRoomId } from './chat-session.js';
import './shared/invite-links.js';
import { normalizeTabId, requestOriginPermission } from './host-access.js';
let pendingInviteRoomId = '';
let pendingInviteChatKey = '';
let pendingRoomCreation = false;
function normalizeChatKey(value) {
return typeof value === 'string' && /^[A-Za-z0-9_-]{21}[AQgw]$/.test(value) ? value : '';
}
const elements = {
tabs: document.querySelectorAll('.tabs .tab-btn'),
@@ -59,6 +68,12 @@ const elements = {
playBtn: document.getElementById('playBtn'),
pauseBtn: document.getElementById('pauseBtn'),
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
chatEnabled: document.getElementById('chatEnabled'),
chatNotifications: document.getElementById('chatNotifications'),
chatPosition: document.getElementById('chatPosition'),
chatSize: document.getElementById('chatSize'),
chatStartMode: document.getElementById('chatStartMode'),
chatReactionDisplay: document.getElementById('chatReactionDisplay'),
sendTabTitle: document.getElementById('sendTabTitle'),
mediaTitlePrivacyMode: document.getElementById('mediaTitlePrivacyMode'),
episodeLobbyCard: document.getElementById('episodeLobbyCard'),
@@ -328,7 +343,7 @@ function setRoomRefreshCooldown() {
async function init() {
// Local-only by design — settings and room credentials never come from
// storage.sync (only onboardingComplete + dismissedHints live there).
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
let activeLang = localData.locale;
if (!activeLang) {
@@ -353,12 +368,19 @@ async function init() {
}
elements.serverUrl.value = localData.serverUrl || '';
elements.roomId.value = localData.roomId || '';
elements.roomId.value = normalizeRoomId(localData.roomId);
elements.password.value = localData.password || '';
elements.username.value = username;
syncDevToolsVisibility();
if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false;
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = localData.autoSyncNextEpisode !== false;
if (elements.chatEnabled) elements.chatEnabled.checked = localData.chatEnabled === true;
if (elements.chatNotifications) elements.chatNotifications.checked = localData.chatNotifications !== false;
if (elements.chatPosition) elements.chatPosition.value = ['left', 'detached'].includes(localData.chatPosition) ? localData.chatPosition : 'right';
if (elements.chatSize) elements.chatSize.value = ['compact', 'large', 'custom'].includes(localData.chatSize) ? localData.chatSize : 'standard';
if (elements.chatStartMode) elements.chatStartMode.value = localData.chatStartMode === 'open' ? 'open' : 'bubble';
if (elements.chatReactionDisplay) elements.chatReactionDisplay.value = localData.chatReactionDisplay === 'video' ? 'video' : 'chat';
syncChatSettingsState();
const legacyTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.titlePrivacyMode) ? localData.titlePrivacyMode : TITLE_PRIVACY_MODES.FULL;
const mediaTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.mediaTitlePrivacyMode) ? localData.mediaTitlePrivacyMode : legacyTitlePrivacyMode;
if (elements.sendTabTitle) elements.sendTabTitle.checked = normalizeSendTabTitle(localData.sendTabTitle, legacyTitlePrivacyMode);
@@ -384,7 +406,7 @@ async function init() {
}
toggleUIState(!!localData.roomId);
updateUI(localData.roomId, localData.password, localData.useCustomServer, localData.serverUrl);
updateUI(localData.roomId, localData.password, localData.useCustomServer, localData.serverUrl, localData.chatKey);
refreshLogs();
refreshHistory();
@@ -656,15 +678,20 @@ if (elements.hostControlToggle) {
});
}
function updateUI(roomId, password, useCustomServer = false, serverUrl = '') {
function updateUI(roomId, password, useCustomServer = false, serverUrl = '', chatKey = '') {
const inRoom = !!roomId;
toggleUIState(inRoom);
if (inRoom) {
let invite = `${OFFICIAL_LANDING_PAGE_URL}/join.html#join:${roomId}:${password}`;
if (useCustomServer) {
const encodedUrl = encodeURIComponent(serverUrl || '');
invite += `:1:${encodedUrl}`;
}
const validChatKey = normalizeChatKey(chatKey);
let invite = validChatKey
? `${OFFICIAL_LANDING_PAGE_URL}/join.html${globalThis.KoalaSyncInviteLinks.buildJ2Hash({
roomId,
password,
chatKey: validChatKey,
serverUrl: useCustomServer ? serverUrl : ''
})}`
: `${OFFICIAL_LANDING_PAGE_URL}/join.html#join:${roomId}:${password}`;
if (!validChatKey && useCustomServer) invite += `:1:${encodeURIComponent(serverUrl || '')}`;
elements.inviteLink.value = invite;
if (window.justCreatedRoom) {
@@ -1393,41 +1420,21 @@ function updateRoomList(rooms) {
function checkInviteLink() {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const tab = tabs[0];
if (tab && tab.url && tab.url.startsWith(OFFICIAL_LANDING_PAGE_URL + '/') && tab.url.includes('#join:')) {
if (tab && tab.url && tab.url.startsWith(OFFICIAL_LANDING_PAGE_URL + '/')) {
try {
const rawHash = tab.url.split('#join:')[1];
if (!rawHash) return;
const parts = rawHash.split(':');
if (parts.length >= 2) {
const roomId = parts.shift();
let useCustomServer = false;
let serverUrl = '';
const invite = globalThis.KoalaSyncInviteLinks.parseInviteHash(new URL(tab.url).hash);
if (!invite) return;
elements.roomId.value = normalizeRoomId(invite.roomId);
elements.password.value = invite.password;
pendingInviteRoomId = elements.roomId.value;
pendingInviteChatKey = normalizeChatKey(invite.chatKey);
const last = parts[parts.length - 1];
const secondToLast = parts[parts.length - 2];
const decodedLast = decodeURIComponent(last || '');
const isCustom = secondToLast === '1' && (decodedLast.startsWith('ws://') || decodedLast.startsWith('wss://'));
const isOfficial = secondToLast === '0' && last === '';
elements.serverUrl.value = invite.serverUrl;
setServerMode(invite.useCustomServer);
chrome.storage.local.set({ serverUrl: invite.serverUrl, useCustomServer: invite.useCustomServer });
if (parts.length >= 3 && (isCustom || isOfficial)) {
serverUrl = decodeURIComponent(parts.pop());
useCustomServer = parts.pop() === '1';
}
const password = parts.join(':');
elements.roomId.value = roomId;
elements.password.value = password;
if (serverUrl || useCustomServer) {
elements.serverUrl.value = serverUrl;
setServerMode(useCustomServer);
chrome.storage.local.set({ serverUrl, useCustomServer });
}
elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)';
setTimeout(() => elements.joinBtn.style.boxShadow = '', 2000);
}
elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)';
setTimeout(() => elements.joinBtn.style.boxShadow = '', 2000);
} catch (_e) {
// Malformed invite link, ignore
}
@@ -1462,6 +1469,51 @@ elements.autoSyncNextEpisode.addEventListener('change', () => {
chrome.storage.local.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
});
function syncChatSettingsState() {
const enabled = elements.chatEnabled?.checked === true;
for (const control of [elements.chatNotifications, elements.chatPosition, elements.chatSize, elements.chatStartMode, elements.chatReactionDisplay]) {
if (control) control.disabled = !enabled;
}
document.querySelectorAll('[data-chat-setting]').forEach(row => {
row.classList.toggle('is-disabled', !enabled);
row.setAttribute('aria-disabled', String(!enabled));
});
}
if (elements.chatEnabled) {
elements.chatEnabled.addEventListener('change', () => {
syncChatSettingsState();
chrome.storage.local.set({ chatEnabled: elements.chatEnabled.checked });
});
}
if (elements.chatNotifications) {
elements.chatNotifications.addEventListener('change', () => {
chrome.storage.local.set({ chatNotifications: elements.chatNotifications.checked });
});
}
if (elements.chatPosition) {
elements.chatPosition.addEventListener('change', () => {
const chatPosition = ['left', 'detached'].includes(elements.chatPosition.value) ? elements.chatPosition.value : 'right';
chrome.storage.local.set({ chatPosition });
});
}
if (elements.chatSize) {
elements.chatSize.addEventListener('change', () => {
const chatSize = ['compact', 'large', 'custom'].includes(elements.chatSize.value) ? elements.chatSize.value : 'standard';
chrome.storage.local.set({ chatSize });
});
}
if (elements.chatStartMode) {
elements.chatStartMode.addEventListener('change', () => {
chrome.storage.local.set({ chatStartMode: elements.chatStartMode.value === 'open' ? 'open' : 'bubble' });
});
}
if (elements.chatReactionDisplay) {
elements.chatReactionDisplay.addEventListener('change', () => {
chrome.storage.local.set({ chatReactionDisplay: elements.chatReactionDisplay.value === 'video' ? 'video' : 'chat' });
});
}
if (elements.sendTabTitle) {
elements.sendTabTitle.addEventListener('change', () => {
chrome.storage.local.set({ sendTabTitle: elements.sendTabTitle.checked }, () => {
@@ -1572,8 +1624,8 @@ if (elements.langSelector) {
lastKnownPeers = res.peers || [];
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
const data = await chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
const data = await chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl']);
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey);
if (res.pendingTargetTabId) {
showSiteAccessNotice(
@@ -1588,8 +1640,8 @@ if (elements.langSelector) {
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
} else {
applyConnectionStatus('disconnected');
const data = await chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
const data = await chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl']);
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey);
await populateTabs();
}
});
@@ -1717,7 +1769,12 @@ function showError(msg) {
// --- Action Handlers ---
elements.roomId.addEventListener('input', () => {
elements.roomId.value = elements.roomId.value.replace(/[^a-zA-Z0-9\-]/g, '');
elements.roomId.value = normalizeRoomId(elements.roomId.value);
pendingRoomCreation = false;
if (pendingInviteRoomId && elements.roomId.value !== pendingInviteRoomId) {
pendingInviteRoomId = '';
pendingInviteChatKey = '';
}
});
elements.joinBtn.addEventListener('click', async () => {
@@ -1728,8 +1785,8 @@ elements.joinBtn.addEventListener('click', async () => {
isProcessingConnection = false;
return;
}
const roomIdInput = elements.roomId.value.trim();
const isCreating = !roomIdInput;
const roomIdInput = normalizeRoomId(elements.roomId.value);
const isCreating = pendingRoomCreation || !roomIdInput;
elements.joinBtn.disabled = true;
elements.joinBtn.textContent = isCreating ? getMessage('BTN_STATE_CREATING') : getMessage('BTN_STATE_JOINING');
@@ -1789,17 +1846,34 @@ elements.joinBtn.addEventListener('click', async () => {
self.crypto.getRandomValues(array);
password = Array.from(array, byte => chars[byte % chars.length]).join('');
elements.password.value = password;
window.justCreatedRoom = true;
}
let chatKey = roomId === pendingInviteRoomId ? pendingInviteChatKey : '';
if (isCreating) {
const created = await new Promise(resolve => chrome.runtime.sendMessage({ type: 'CREATE_CHAT_KEY' }, resolve));
chatKey = normalizeChatKey(created?.chatKey);
if (!chatKey) {
pendingRoomCreation = false;
elements.joinBtn.disabled = false;
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
isProcessingConnection = false;
showError(getMessage('CHAT_SEND_FAILED'));
return;
}
}
if (isCreating) window.justCreatedRoom = true;
pendingInviteRoomId = '';
pendingInviteChatKey = '';
pendingRoomCreation = false;
await chrome.storage.local.set({ serverUrl, roomId, password, useCustomServer: useCustom });
await chrome.storage.local.set({ serverUrl, roomId, password, chatKey, useCustomServer: useCustom });
elements.roomId.value = roomId;
// Tell background to connect
chrome.runtime.sendMessage({ type: 'CONNECT' });
// UI Feedback: Immediately switch state for better responsiveness
updateUI(roomId, password, useCustom, serverUrl);
updateUI(roomId, password, useCustom, serverUrl, chatKey);
});
elements.leaveBtn.addEventListener('click', async () => {
@@ -1807,7 +1881,7 @@ elements.leaveBtn.addEventListener('click', async () => {
isProcessingConnection = true;
clearConnectionErrorTimer();
chrome.runtime.sendMessage({ type: 'LEAVE_ROOM' });
await chrome.storage.local.set({ roomId: '', password: '' });
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' });
elements.roomId.value = '';
elements.password.value = '';
lastKnownPeers = [];
@@ -1833,6 +1907,7 @@ function handleCreateRoom() {
const password = secureGenerateId();
elements.roomId.value = roomId;
elements.password.value = password;
pendingRoomCreation = true;
window.justCreatedRoom = true;
// Auto-connect
@@ -2327,7 +2402,7 @@ chrome.runtime.onMessage.addListener((msg) => {
applyConnectionStatus(msg.status);
if (res && res.reconnectAttempts !== undefined) {
if (elements.connText) {
elements.connText.textContent = `Reconnecting... (${res.reconnectAttempts})`;
elements.connText.textContent = `${getMessage('STATUS_RECONNECTING')} (${res.reconnectAttempts})`;
}
}
});
@@ -2355,15 +2430,15 @@ chrome.runtime.onMessage.addListener((msg) => {
if (msg.success) {
// Final confirmation of join from background
chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl'], (data) => {
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl'], (data) => {
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey);
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
if (syncTabBtn) syncTabBtn.click();
showSelectVideoHint();
});
} else {
// Join failed: reset UI state
chrome.storage.local.set({ roomId: '', password: '' });
chrome.storage.local.set({ roomId: '', password: '', chatKey: '' });
elements.roomId.value = '';
elements.password.value = '';
updateUI(null, null);
@@ -2649,35 +2724,35 @@ function refreshDebugInfo() {
if (!state.found) {
// No video found — show diagnostic info
addSection('Video Detection');
addSection(getMessage('DEBUG_SECTION_VIDEO_DETECTION'));
const notFound = document.createElement('div');
notFound.style.cssText = 'color: var(--status-error-text); font-weight: 700; margin-bottom: 8px;';
notFound.textContent = 'NO VIDEO ELEMENT FOUND';
notFound.textContent = getMessage('DEBUG_VIDEO_NOT_FOUND');
elements.videoDebug.appendChild(notFound);
addField('Platform', state.platform || '?', 'var(--accent)');
addField('Page Title', state.pageTitle || '?');
addField('URL', state.url || '?');
addField('Video Tags', String(state.videoCount || 0));
addField('Shadow DOM', state.inShadowDom ? 'YES (checked)' : 'NO');
addField('Shadow DOM', state.inShadowDom ? getMessage('DEBUG_YES_CHECKED') : getMessage('DEBUG_NO'));
if (state.metadata) {
addSection('Media Session API');
addField('Title', state.metadata.title || 'n/a');
addField('Artist', state.metadata.artist || 'n/a');
addField('Album', state.metadata.album || 'n/a');
addSection(getMessage('DEBUG_SECTION_MEDIA_SESSION'));
addField('Title', state.metadata.title || getMessage('DEBUG_NA'));
addField('Artist', state.metadata.artist || getMessage('DEBUG_NA'));
addField('Album', state.metadata.album || getMessage('DEBUG_NA'));
}
const hint = document.createElement('div');
hint.style.cssText = 'margin-top: 12px; padding: 8px; background: color-mix(in oklch, var(--warning), transparent 88%); border-left: 3px solid var(--warning); border-radius: 4px; font-size: 10px; color: var(--text);';
hint.textContent = 'Is a video currently playing? The extension only detects <video> elements. Ensure media is actively loaded on the page.';
hint.textContent = getMessage('DEBUG_VIDEO_HINT');
elements.videoDebug.appendChild(hint);
return;
}
// Video found — full debug
addSection('Playback');
addField('State', state.paused ? 'PAUSED' : 'PLAYING', state.paused ? 'var(--text-muted)' : 'var(--status-success-text)');
addSection(getMessage('DEBUG_SECTION_PLAYBACK'));
addField('State', state.paused ? getMessage('DEBUG_STATE_PAUSED') : getMessage('DEBUG_STATE_PLAYING'), state.paused ? 'var(--text-muted)' : 'var(--status-success-text)');
addField('Time', `${state.currentTime.toFixed(2)}s / ${(state.duration || 0).toFixed(2)}s`);
addField('ReadyState', `${state.readyState} (${state.readyStateLabel || '?'})`,
state.readyState >= 3 ? 'var(--status-success-text)' : 'var(--status-warning-text)');
@@ -2696,7 +2771,7 @@ function refreshDebugInfo() {
const buttons = Array.isArray(quirk.seekButtons) ? quirk.seekButtons.slice(0, 4).join(' | ') : '';
if (buttons) addField(`${label} Buttons`, buttons);
}
addSection('Properties');
addSection(getMessage('DEBUG_SECTION_PROPERTIES'));
addField('Seeking', String(state.seeking));
addField('Ended', String(state.ended));
addField('Loop', String(state.loop));
@@ -2704,45 +2779,45 @@ function refreshDebugInfo() {
addField('Volume', String(state.volume));
addField('Speed', `${state.playbackRate}x`);
addSection('Dimensions');
addSection(getMessage('DEBUG_SECTION_DIMENSIONS'));
const dimsOk = state.videoWidth > 0 && state.videoHeight > 0;
addField('Resolution', `${state.videoWidth}x${state.videoHeight}`, dimsOk ? 'var(--status-success-text)' : 'var(--status-error-text)');
if (!dimsOk) {
const dimHint = document.createElement('div');
dimHint.style.cssText = 'color: var(--status-warning-text); font-size: 10px; margin: 2px 0 6px 12px;';
dimHint.textContent = '0x0 = video element hidden or not yet loaded';
dimHint.textContent = getMessage('DEBUG_DIMENSIONS_HINT');
elements.videoDebug.appendChild(dimHint);
}
if (state.error) {
addSection('Media Error');
addSection(getMessage('DEBUG_SECTION_MEDIA_ERROR'));
addField('Code', String(state.error.code), 'var(--status-error-text)');
addField('Message', state.error.message || '?', 'var(--status-error-text)');
}
addSection('Detection');
addSection(getMessage('DEBUG_SECTION_DETECTION'));
addField('Platform', state.platform || '?', 'var(--accent)');
addField('Video Count', String(state.videoCount || 0));
addField('Shadow DOM', state.inShadowDom ? 'YES' : 'NO');
addField('Shadow DOM', state.inShadowDom ? getMessage('DEBUG_YES') : getMessage('DEBUG_NO'));
addSection('Identification');
addSection(getMessage('DEBUG_SECTION_IDENTIFICATION'));
addField('URL', state.url);
addField('ID', state.id);
addField('CLASS', state.className);
addSection('Media Source');
addSection(getMessage('DEBUG_SECTION_MEDIA_SOURCE'));
addField('CurrentSrc', state.currentSrc);
addField('Src', state.src);
if (state.metadata) {
addSection('Media Session API');
addField('Title', state.metadata.title || 'n/a');
addField('Artist', state.metadata.artist || 'n/a');
addField('Album', state.metadata.album || 'n/a');
addSection(getMessage('DEBUG_SECTION_MEDIA_SESSION'));
addField('Title', state.metadata.title || getMessage('DEBUG_NA'));
addField('Artist', state.metadata.artist || getMessage('DEBUG_NA'));
addField('Album', state.metadata.album || getMessage('DEBUG_NA'));
}
if (state.dataAttributes && Object.keys(state.dataAttributes).length > 0) {
addSection('Data Attributes');
addSection(getMessage('DEBUG_SECTION_DATA_ATTRIBUTES'));
for (const [key, val] of Object.entries(state.dataAttributes)) {
addField(key.replace('data-', '').toUpperCase(), val);
}
+70
View File
@@ -0,0 +1,70 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'utf8');
const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8');
describe('target tab lifecycle', () => {
it('injects playback and chat scripts only into the explicitly selected tab', () => {
expect(backgroundSource).not.toContain('chrome.tabs.onActivated');
expect(backgroundSource).not.toContain('chrome.tabs.query({})');
expect(backgroundSource).toContain("target: { tabId }");
expect(backgroundSource).toContain("files: ['chat-format.js', 'chat-overlay.js', 'content.js']");
expect(backgroundSource).toContain("chrome.tabs.query({ url: 'https://sync.koalastuff.net/*' })");
const activationStart = backgroundSource.indexOf('async function activateTargetTab');
const activationEnd = backgroundSource.indexOf('async function reactivateCurrentTarget', activationStart);
const activationSource = backgroundSource.slice(activationStart, activationEnd);
expect(activationSource.indexOf('await deactivateTargetTab(previousTabId)'))
.toBeLessThan(activationSource.indexOf('await injectContentScript(selectedTabId'));
expect(activationSource).toContain('previousTabId !== selectedTabId');
expect(contentSource).toContain('if (window.koalaSyncInjected && chrome.runtime.id)');
expect(overlaySource).toContain('if (window.koalaSyncChatOverlay?.refresh)');
});
it('fully deactivates old and superseded target injections', () => {
expect(backgroundSource).toContain("chrome.tabs.sendMessage(normalizedTabId, { type: 'TARGET_DEACTIVATE' })");
expect(backgroundSource.match(/await deactivateTargetTab\(selectedTabId\)/g)?.length).toBeGreaterThanOrEqual(4);
expect(contentSource).toContain("if (message.type === 'TARGET_DEACTIVATE')");
expect(overlaySource).toContain("message?.type === 'TARGET_DEACTIVATE'");
});
it('tears down every persistent content-script resource', () => {
expect(contentSource).toContain('function destroyContentScript()');
expect(contentSource).toContain('observer.disconnect()');
expect(contentSource).toContain('keepAlivePort.disconnect()');
expect(contentSource).toContain('for (const video of attachedVideos)');
expect(contentSource).toContain("document.removeEventListener('visibilitychange', handleVisibilityChange)");
expect(contentSource).toContain("window.removeEventListener('pagehide', handlePageHide)");
expect(contentSource).toContain("window.removeEventListener('pageshow', handlePageShow)");
expect(contentSource).toContain('chrome.storage.onChanged.removeListener(handleStorageChanged)');
expect(contentSource).toContain('chrome.runtime.onMessage.removeListener(handleRuntimeMessage)');
expect(contentSource).toContain('window.koalaSyncInjected = false');
});
it('stops the MAIN-world seek bridge when its target is deactivated', () => {
expect(backgroundSource).toContain('const timelineInterval = setInterval');
expect(backgroundSource).toContain('clearInterval(timelineInterval)');
expect(backgroundSource).toContain("window.removeEventListener('message', handleBridgeMessage)");
expect(contentSource).toContain("kind: 'destroy'");
});
it('self-cleans instead of throwing when an extension reload invalidates the context', () => {
expect(contentSource).toContain('if (!chrome.runtime?.id)');
expect(contentSource).toContain('destroyContentScript()');
expect(contentSource).toContain('return chrome.runtime.sendMessage(message, callback) || Promise.resolve(undefined)');
expect(contentSource).toContain('function handleVisibilityChange()');
});
it('suspends background work for bfcache and restores it without duplicate injection', () => {
expect(contentSource).toContain('pageSuspended = true');
expect(contentSource).toContain('if (destroyed || pageSuspended) return');
expect(contentSource).toContain('if (!destroyed && !pageSuspended) scheduleLifecycleTimeout(connectKeepAlivePort, 1000)');
expect(contentSource).toMatch(/function handlePageShow\(event\)[\s\S]*if \(!event\.persisted\) return;[\s\S]*pageSuspended = false/);
expect(contentSource).toMatch(/function handlePageShow\(event\)[\s\S]*observer\.observe\(document\.documentElement[\s\S]*setupListeners\(\)[\s\S]*connectKeepAlivePort\(\)/);
});
});
+783 -1254
View File
File diff suppressed because it is too large Load Diff
+17 -9
View File
@@ -1,9 +1,12 @@
{
"name": "koalasync",
"version": "2.6.3",
"version": "3.0.1",
"description": "KoalaSync Build Scripts",
"private": true,
"type": "module",
"engines": {
"node": ">=20.9.0"
},
"scripts": {
"build:extension": "node scripts/build-extension.cjs",
"indexnow": "node website/submit-indexnow.cjs",
@@ -15,17 +18,22 @@
"verify": "node scripts/verify-release.mjs"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@vitest/coverage-v8": "^4.1.9",
"@playwright/test": "^1.62.0",
"@vitest/coverage-v8": "^4.1.10",
"addons-linter": "^10.9.0",
"archiver": "^7.0.1",
"archiver": "^8.0.0",
"esbuild": "^0.28.1",
"eslint": "^10.4.0",
"eslint": "^10.8.0",
"eslint-plugin-no-unsanitized": "^4.1.5",
"fs-extra": "^11.2.0",
"fs-extra": "^11.4.0",
"htmlnano": "^3.4.0",
"sharp": "^0.34.5",
"svgo": "^4.0.1",
"vitest": "^4.1.9"
"sharp": "^0.35.3",
"svgo": "^4.0.2",
"vitest": "^4.1.10"
},
"overrides": {
"brace-expansion": "5.0.8",
"fast-uri": "3.1.4",
"postcss": "8.5.18"
}
}
+65 -13
View File
@@ -1,11 +1,63 @@
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');
const localesDir = path.join(extDir, '_locales');
const requiredAppName = 'Watch Party - KoalaSync';
const maxAppNameLength = 75;
const maxAppDescLength = 132;
function countUnicodeCharacters(value) {
return Array.from(value).length;
}
function validateExtensionMetadata() {
const localeDirectories = fs.readdirSync(localesDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
for (const locale of localeDirectories) {
const messagesPath = path.join(localesDir, locale, 'messages.json');
let messages;
try {
messages = JSON.parse(fs.readFileSync(messagesPath, 'utf8'));
} catch (error) {
throw new Error(`CRITICAL: Invalid locale JSON at _locales/${locale}/messages.json: ${error.message}`);
}
if (!messages || typeof messages !== 'object' || Array.isArray(messages)) {
throw new Error(`CRITICAL: _locales/${locale}/messages.json must contain a JSON object`);
}
for (const key of ['appName', 'appDesc']) {
if (!messages[key] || typeof messages[key].message !== 'string' || messages[key].message.trim().length === 0) {
throw new Error(`CRITICAL: _locales/${locale}/messages.json is missing a valid ${key}.message`);
}
}
const appNameLength = countUnicodeCharacters(messages.appName.message);
const appDescLength = countUnicodeCharacters(messages.appDesc.message);
if (appNameLength > maxAppNameLength) {
throw new Error(`CRITICAL: _locales/${locale}/messages.json appName.message is ${appNameLength} characters; maximum is ${maxAppNameLength}`);
}
if (messages.appName.message !== requiredAppName) {
throw new Error(`CRITICAL: _locales/${locale}/messages.json appName.message must exactly equal "${requiredAppName}"`);
}
if (appDescLength > maxAppDescLength) {
throw new Error(`CRITICAL: _locales/${locale}/messages.json appDesc.message is ${appDescLength} characters; maximum is ${maxAppDescLength}`);
}
}
console.log(`✓ Validated localized extension metadata for ${localeDirectories.length} locales`);
}
validateExtensionMetadata();
// Ensure dist directory exists
if (fs.existsSync(distDir)) {
@@ -22,7 +74,7 @@ if (!fs.existsSync(extSharedDir)) {
fs.mkdirSync(extSharedDir, { recursive: true });
}
const sharedFiles = ['constants.js', 'blacklist.js', 'names.js', 'README.md'];
const sharedFiles = ['constants.js', 'blacklist.js', 'names.js', 'invite-links.js', 'README.md'];
for (const file of sharedFiles) {
const src = path.join(masterSharedDir, file);
const dest = path.join(extSharedDir, file);
@@ -31,7 +83,7 @@ for (const file of sharedFiles) {
}
fs.copyFileSync(src, dest);
}
console.log('✓ constants.js, blacklist.js, names.js, and README.md synced to extension/shared/');
console.log('✓ shared runtime files synced to extension/shared/');
// Read the base manifest
const baseManifest = JSON.parse(fs.readFileSync(baseManifestPath, 'utf8'));
@@ -43,7 +95,7 @@ function replaceRequiredBlock(content, pattern, replacement, description) {
return content.replace(pattern, replacement);
}
// Helper to copy files, ignoring manifest.json and manifest.base.json
// Helper to copy runtime files, ignoring source manifests and test-only modules
// Also injects shared constants into content.js
function copyExtensionFiles(targetDir, browserName) {
fs.mkdirSync(targetDir, { recursive: true });
@@ -68,7 +120,7 @@ function copyExtensionFiles(targetDir, browserName) {
const items = fs.readdirSync(extDir);
for (const item of items) {
if (item === 'manifest.json' || item === 'manifest.base.json') continue;
if (item === 'manifest.json' || item === 'manifest.base.json' || /\.test\.[cm]?js$/u.test(item)) continue;
const srcPath = path.join(extDir, item);
const destPath = path.join(targetDir, item);
@@ -149,18 +201,18 @@ function copyExtensionFiles(targetDir, browserName) {
}
// Helper to zip a directory
function zipDirectory(sourceDir, outPath) {
async function zipDirectory(sourceDir, outPath) {
const { ZipArchive } = await import('archiver');
return new Promise((resolve, reject) => {
const archive = archiver('zip', { zlib: { level: 9 } });
const archive = new ZipArchive({ zlib: { level: 9 } });
const stream = fs.createWriteStream(outPath);
archive
.directory(sourceDir, false)
.on('error', err => reject(err))
.pipe(stream);
archive.on('error', reject);
stream.on('close', () => resolve());
archive.finalize();
stream.on('error', reject);
archive.pipe(stream);
archive.directory(sourceDir, false);
void archive.finalize().catch(reject);
});
}
+69
View File
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const [popupHtml, popupJs, overlayJs, backgroundJs] = await Promise.all([
readFile(path.join(repoRoot, 'extension', 'popup.html'), 'utf8'),
readFile(path.join(repoRoot, 'extension', 'popup.js'), 'utf8'),
readFile(path.join(repoRoot, 'extension', 'chat-overlay.js'), 'utf8'),
readFile(path.join(repoRoot, 'extension', 'background.js'), 'utf8')
]);
function detailsSection(marker) {
const markerIndex = popupHtml.indexOf(marker);
assert.notEqual(markerIndex, -1, `missing settings marker: ${marker}`);
const start = popupHtml.lastIndexOf('<details', markerIndex);
const end = popupHtml.indexOf('</details>', markerIndex);
assert.ok(start >= 0 && end > markerIndex, `invalid details section: ${marker}`);
return popupHtml.slice(start, end + '</details>'.length);
}
const chatSection = detailsSection('data-i18n="CHAT_TITLE"');
const syncSection = detailsSection('data-i18n="LABEL_SETTINGS_GROUP_SYNC"');
for (const id of ['chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay']) {
assert.match(chatSection, new RegExp(`id="${id}"`), `${id} belongs in the dedicated chat settings section`);
}
assert.doesNotMatch(syncSection, /id="chatEnabled"/, 'chat enablement must not remain under Playback & Sync');
for (const value of ['right', 'left', 'detached']) {
assert.match(chatSection, new RegExp(`<option value="${value}"`), `chat position supports ${value}`);
}
for (const value of ['compact', 'standard', 'large', 'custom']) {
assert.match(chatSection, new RegExp(`<option value="${value}"`), `chat size supports ${value}`);
}
for (const value of ['bubble', 'open']) {
assert.match(chatSection, new RegExp(`<option value="${value}"`), `chat start mode supports ${value}`);
}
for (const value of ['chat', 'video']) {
assert.match(chatSection, new RegExp(`<option value="${value}"`), `chat reaction display supports ${value}`);
}
for (const key of ['chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay']) {
assert.match(popupJs, new RegExp(`chrome\\.storage\\.local\\.set\\(\\{ ${key}`), `popup persists ${key}`);
assert.ok(backgroundJs.includes(`'${key}'`), `legacy sync purge includes ${key}`);
}
for (const key of ['chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay']) {
assert.ok(overlayJs.includes(`'${key}'`), `overlay reads ${key}`);
}
assert.match(backgroundJs, /chatEnabled:\s*data\.chatEnabled === true/, 'background reads chatEnabled with an off-by-default contract');
assert.match(backgroundJs, /chatNotifications:\s*data\.chatNotifications !== false/, 'chat message notifications default on');
assert.match(popupJs, /elements\.chatNotifications\.checked = localData\.chatNotifications !== false/, 'popup shows chat notifications on by default');
assert.match(popupJs, /elements\.browserNotifications\.checked = localData\.browserNotifications === true/, 'activity notifications remain off by default');
assert.match(overlayJs, /context\?\.enabled/, 'overlay consumes chat enablement from the validated background context');
assert.match(overlayJs, /SIZE_PRESETS[\s\S]*compact[\s\S]*standard[\s\S]*large/, 'overlay defines all size presets');
assert.match(
overlayJs,
/} else {\s*layout\.width = Number\(layout\.customWidth\) \|\| DEFAULT_WIDTH;\s*layout\.height = Number\(layout\.customHeight\) \|\| DEFAULT_HEIGHT;/,
'custom size restores its dedicated dimensions'
);
assert.match(overlayJs, /layout\.customWidth = rect\.width[\s\S]*layout\.customHeight = rect\.height/, 'detached resize updates the custom-size snapshot');
assert.match(overlayJs, /changes\.chatPosition[\s\S]*setMode/, 'position changes apply live');
assert.match(overlayJs, /changes\.chatSize[\s\S]*setSize/, 'size changes apply live');
assert.match(overlayJs, /changes\.chatStartMode[\s\S]*setOpened/, 'startup-mode changes apply live');
assert.match(overlayJs, /changes\.chatReactionDisplay[\s\S]*chatReactionDisplay/, 'reaction display changes apply live');
console.log('chat settings tests passed');
+69 -1
View File
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { Buffer } from 'node:buffer';
import http from 'node:http';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
@@ -22,7 +23,10 @@ async function c() {
function s(ws, evt, d={}) { ws.send(`42${JSON.stringify([evt,d])}`); }
function a(ws) { if (ws._m.length) { const r=ws._m.shift(); return r.startsWith('42') ? JSON.parse(r.substring(2)) : r; } return new Promise((resolve, reject) => { const t=setTimeout(()=>reject(Error('timeout')),3e3); const h=(d)=>{clearTimeout(t);ws.removeListener('message',h);const r=d.toString();resolve(r.startsWith('42')?JSON.parse(r.substring(2)):r);};ws.on('message',h);}); }
async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-st<ms) { for(let i=0;i<ws._m.length;i++){const r=ws._m[i];ws._m.splice(i,1);if(r.startsWith('42')){try{const[e]=JSON.parse(r.substring(2));if(e===evt)return e}catch{/* skip */}}} await new Promise(r=>setTimeout(r,50));} throw Error(`wait:${evt}`); }
async function j(ws, rid, pid, pw=null) { s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0'}); assert.equal((await a(ws))[0],'room_data'); }
async function j(ws, rid, pid, pw=null, clientCapabilities=undefined) {
s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0',clientCapabilities});
assert.equal((await a(ws))[0],'room_data');
}
function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; }
// Test suite opens >10 connections/min — clear the IP connection counter so the
// connection rate limiter doesn't mask test failures (test-only, never at runtime).
@@ -70,6 +74,70 @@ try {
assert.equal(capEv, 'room_data');
assert.ok(Array.isArray(capData.capabilities) && capData.capabilities.includes('host-control'),
'ROOM_DATA advertises the host-control capability');
assert.ok(capData.capabilities.includes('chat'), 'ROOM_DATA advertises the chat capability');
assert.ok(capData.capabilities.includes('chat-v1'), 'ROOM_DATA advertises the versioned chat capability');
assert.equal(capData.chatHistory, undefined, 'ROOM_DATA never contains chat history');
close();
resetConnectionRate();
// --- Encrypted chat is a live-only canonical relay ---
const chatRoom = 'chat-'+Date.now();
const chat1 = await c(), chat2 = await c();
await j(chat1, chatRoom, 'alice', null, ['chat-v1']);
await j(chat2, chatRoom, 'bob', null, ['chat-v1']);
chat1._m.length = chat2._m.length = 0;
const ciphertext = Buffer.alloc(64, 7).toString('base64url');
s(chat1, 'chat_message', {
ciphertext,
id: 'spoofed-id', senderId: 'mallory', timestamp: 1, text: 'plaintext'
});
const [chat1Event, chat1Data] = await a(chat1);
const [chat2Event, chat2Data] = await a(chat2);
assert.equal(chat1Event, 'chat_message', 'sender receives canonical chat envelope');
assert.equal(chat2Event, 'chat_message', 'peer receives canonical chat envelope');
assert.deepEqual(chat2Data, chat1Data, 'all peers receive the same canonical envelope');
assert.equal(chat1Data.senderId, 'alice', 'relay stamps senderId');
assert.equal(chat1Data.ciphertext, ciphertext, 'relay preserves ciphertext');
assert.equal(chat1Data.text, undefined, 'relay drops plaintext fields');
assert.notEqual(chat1Data.id, 'spoofed-id', 'relay replaces client IDs');
assert.ok(Number.isFinite(chat1Data.timestamp) && chat1Data.timestamp > 1, 'relay stamps timestamp');
const late = await c();
s(late, 'join_room', { roomId: chatRoom, peerId: 'late', protocolVersion: '1.0.0' });
const [lateEvent, lateRoomData] = await a(late);
assert.equal(lateEvent, 'room_data');
assert.equal(lateRoomData.chatHistory, undefined, 'late joiner gets no chat backlog');
assert.equal(late._m.some(raw => raw.includes('chat_message')), false, 'late joiner receives no old message');
close();
resetConnectionRate();
// --- Mixed rollout: old non-chat clients never receive unknown chat events ---
const mixedChatRoom = 'chat-mixed-'+Date.now();
const newChat = await c(), oldNoChat = await c();
await j(newChat, mixedChatRoom, 'new-chat', null, ['chat-v1', 'chat-v1', 'future-chat', 42]);
await j(oldNoChat, mixedChatRoom, 'old-no-chat', null, ['chat-v2']);
newChat._m.length = oldNoChat._m.length = 0;
s(newChat, 'chat_message', { ciphertext });
await w(newChat, 'chat_message');
let oldReceivedChat = false;
try { await w(oldNoChat, 'chat_message', 500); oldReceivedChat = true; } catch { /* expected */ }
assert.equal(oldReceivedChat, false, 'old client receives no unknown chat event');
// The same old client remains fully usable for the pre-chat protocol.
newChat._m.length = oldNoChat._m.length = 0;
s(oldNoChat, 'play', { currentTime: 12 });
await w(newChat, 'play');
s(newChat, 'pause', { currentTime: 13 });
await w(oldNoChat, 'pause');
// First chat beta compatibility: sending a valid chat frame dynamically
// proves receive support even when that beta omitted clientCapabilities.
const firstBeta = await c();
await j(firstBeta, mixedChatRoom, 'first-beta');
firstBeta._m.length = newChat._m.length = 0;
s(firstBeta, 'chat_message', { ciphertext });
await w(firstBeta, 'chat_message');
await w(newChat, 'chat_message');
close();
resetConnectionRate();
+33
View File
@@ -22,6 +22,39 @@ const localeFiles = fs.readdirSync(localesDir)
let hasError = false;
const encryptedChatComparisonKeys = [
'ALT_TELEPARTY_INTRO',
'ALT_TELEPARTY_WHEN_TELE_BODY',
'ALT_TELEPARTY_MAIN_DIFF_BODY',
'ALT_TELEPARTY_LIMITS_BODY',
'ALT_TELEPARTY_SUMMARY_BODY',
'ALT_SCREEN_LIMITS_BODY',
'ALT_W2G_MAIN_DIFF_BODY',
'ALT_W2G_LIMITS_BODY',
'ALT_W2G_SUMMARY_BODY',
'ALT_SCENER_INTRO',
'ALT_SCENER_MAIN_DIFF_BODY',
'ALT_SCENER_LIMITS_BODY',
'ALT_KOSMI_LIMITS_BODY',
'ALT_TWOSEVEN_LIMITS_BODY'
];
const staleChatClaims = /does not currently have a built-in chat|has no built-in chat|no built-in chat box overlay|no built-in chat or webcam|no built-in chat or voice/i;
for (const key of encryptedChatComparisonKeys) {
const value = enDict[key];
if (typeof value !== 'string' || !value.includes('end-to-end encrypted')) {
hasError = true;
console.error(`❌ English comparison key ${key} must describe KoalaSync's end-to-end encrypted chat.`);
}
if (staleChatClaims.test(value)) {
hasError = true;
console.error(`❌ English comparison key ${key} still claims that KoalaSync has no built-in text chat.`);
}
}
if (!enDict.ALT_TWOSEVEN_LIMITS_BODY.includes('floating and dockable')) {
hasError = true;
console.error('❌ TwoSeven comparison must describe KoalaSync text chat as floating and dockable.');
}
console.log(`Auditing website i18n locales using ${enKeys.length} baseline keys from en.json...\n`);
for (const file of localeFiles) {
+90 -6
View File
@@ -12,11 +12,17 @@ const demoCss = fs.readFileSync(path.join(repoRoot, 'website', 'styles', 'demo.c
const landingPrimaryCss = fs.readFileSync(path.join(repoRoot, 'website', 'styles', 'landing-primary.css'), 'utf8');
const legalCss = fs.readFileSync(path.join(repoRoot, 'website', 'styles', 'legal.css'), 'utf8');
const joinPage = fs.readFileSync(path.join(repoRoot, 'website', 'join.html'), 'utf8');
const helpPage = fs.readFileSync(path.join(repoRoot, 'website', 'help.html'), 'utf8');
const siteAccessHelpPage = fs.readFileSync(path.join(repoRoot, 'website', 'site-access-help.html'), 'utf8');
const websiteBuild = fs.readFileSync(path.join(repoRoot, 'website', 'build.cjs'), 'utf8');
const imprintPage = fs.readFileSync(path.join(repoRoot, 'website', 'imprint.html'), 'utf8');
const germanImprintPage = fs.readFileSync(path.join(repoRoot, 'website', 'impressum-de.html'), 'utf8');
const privacyPage = fs.readFileSync(path.join(repoRoot, 'website', 'privacy.html'), 'utf8');
const germanPrivacyPage = fs.readFileSync(path.join(repoRoot, 'website', 'datenschutz-de.html'), 'utf8');
const alternativesIndex = fs.readFileSync(path.join(repoRoot, 'website', 'alternatives', 'index.html'), 'utf8');
const alternativesCss = fs.readFileSync(path.join(repoRoot, 'website', 'styles', 'alternatives.css'), 'utf8');
const llmsText = fs.readFileSync(path.join(repoRoot, 'website', 'llms.txt'), 'utf8');
const websiteVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, 'website', 'version.json'), 'utf8')).version;
const mockupStart = template.indexOf('<div class="extension-mockup">');
const mockupEnd = template.indexOf('<div class="demo-invite-fly"', mockupStart);
@@ -25,6 +31,32 @@ if (mockupStart === -1 || mockupEnd === -1) {
}
const mockup = template.slice(mockupStart, mockupEnd);
const privacyChatContracts = [
[privacyPage, 'English', [
'end-to-end encrypted in the browser extension before transmission',
'256-bit AES-GCM key',
'the relay does not receive the chat secret',
'does not create a server-side chat history',
'maximum of 200 visible message or activity entries',
"browser's notification API"
]],
[germanPrivacyPage, 'German', [
'vor der Übertragung Ende-zu-Ende verschlüsselt',
'256-Bit-Schlüssel für AES-GCM',
'der Relay das Chat-Secret nicht erhält',
'keine serverseitige Chat-Historie',
'höchstens 200 Nachrichten- oder Aktivitätseinträge',
'Benachrichtigungs-API des Browsers'
]]
];
for (const [page, language, statements] of privacyChatContracts) {
for (const statement of statements) {
if (!page.includes(statement)) {
throw new Error(`${language} privacy policy is missing encrypted-chat disclosure: ${statement}`);
}
}
}
const themeSensitiveControls = [
['video selector', /<select[^>]+id="demo-video-select"[^>]+>/],
['sync target', /<div[^>]+class="mock-input"[^>]+title="Choose sync target"[^>]*>/],
@@ -63,6 +95,60 @@ for (const section of requiredLlmsSections) {
if (/\bWebRTC\b|\bport 54000\b|peer-to-peer by design/i.test(llmsText)) {
throw new Error('llms.txt must describe the current WebSocket relay architecture without obsolete WebRTC or port claims');
}
if (!llmsText.includes('- built-in voice, video or webcam chat') || llmsText.includes('- built-in text, voice or webcam chat')) {
throw new Error('llms.txt must distinguish built-in encrypted text chat from unsupported voice, video or webcam chat');
}
if (!llmsText.includes(`Current website release: ${websiteVersion}`)) {
throw new Error(`llms.txt release must match website/version.json (${websiteVersion})`);
}
const requiredSupportSocialMetadata = [
'<link rel="canonical" href="https://sync.koalastuff.net/site-access-help">',
'name="twitter:card" content="summary_large_image"',
'name="twitter:title"',
'name="twitter:description"',
'name="twitter:image"',
'"datePublished"',
'"dateModified"',
'"mainEntityOfPage"'
];
for (const metadata of requiredSupportSocialMetadata) {
if (!siteAccessHelpPage.includes(metadata)) {
throw new Error(`site-access-help.html is missing SEO metadata: ${metadata}`);
}
}
const requiredHelpMetadata = [
'<link rel="canonical" href="https://sync.koalastuff.net/help">',
'type="text/markdown" href="https://sync.koalastuff.net/llms.txt"',
'name="twitter:card" content="summary_large_image"',
'property="og:image:alt"',
'name="twitter:image:alt"',
'"@type": ["CollectionPage", "FAQPage"]',
'"@type": "BreadcrumbList"',
'"datePublished"',
'"dateModified"'
];
for (const metadata of requiredHelpMetadata) {
if (!helpPage.includes(metadata)) {
throw new Error(`help.html is missing SEO/AEO metadata: ${metadata}`);
}
}
if (!llmsText.includes('[Help Center](https://sync.koalastuff.net/help)')
|| !llmsText.includes('[Website access guide](https://sync.koalastuff.net/site-access-help)')) {
throw new Error('llms.txt must expose the Help Center and website-access guide');
}
if (!websiteBuild.includes("['log', '-1', '--format=%cs', '--', ...sourceFiles]")
|| !websiteBuild.includes("lastmod(['website/help.html', 'website/styles/support.css', 'website/app.js'])")
|| !websiteBuild.includes("lastmod(['website/site-access-help.html', 'website/app.js'])")
|| !websiteBuild.includes("['rev-parse', '--is-shallow-repository']")
|| !websiteBuild.includes("['diff', '--name-only', '--']")
|| !websiteBuild.includes("['diff', '--cached', '--name-only', '--']")
|| !websiteBuild.includes("['ls-files', '--others', '--exclude-standard', '--']")
|| !websiteBuild.includes('sourceFiles.some(file => dirtySourceFiles.has(file))')) {
throw new Error('Sitemap lastmod values must come from the mapped source files in Git');
}
if (websiteBuild.includes('<changefreq>') || websiteBuild.includes('<priority>')) {
throw new Error('Sitemap must not emit ignored changefreq or priority hints');
}
const documentedRelayUrls = [...llmsText.matchAll(/`(wss:\/\/[^`\s]+)`/g)].map(([, value]) => new URL(value));
const hasCanonicalPublicRelay = documentedRelayUrls.some((url) => (
url.protocol === 'wss:' &&
@@ -97,7 +183,6 @@ for (const value of requiredSelfHostingValues) {
if ((selfHostingExamples.match(/localhost:3000/g) || []).length !== 2 || selfHostingExamples.includes('KoalaSync:3000')) {
throw new Error('Both Caddy examples must match the compose loopback port binding');
}
const landingStylesheet = landingStylesheets[0][0];
if (!/href="\{\{ASSET_PATH\}\}landing\.min\.css"/.test(landingStylesheet)) {
throw new Error('Landing stylesheet must use the landing.min.css build placeholder');
@@ -151,11 +236,9 @@ if (!/\.legal-inline-link\s*\{[^}]*text-decoration:\s*underline/s.test(legalCss)
throw new Error('Legal prose links must use a non-color underline cue');
}
for (const [name, page] of [['imprint.html', imprintPage], ['impressum-de.html', germanImprintPage]]) {
if ((page.match(/class="legal-inline-link"/g) || []).length !== 3) {
throw new Error(`${name} must mark all three prose links as legal-inline-link`);
}
if ((page.match(/class="legal-inline-label"/g) || []).length !== 2 || /opacity:\s*0\.6/.test(page)) {
throw new Error(`${name} contact labels must use full-opacity theme text`);
if (!/rel="canonical" href="https:\/\/koalastuff\.net\/legal"/.test(page)
|| !/href="https:\/\/koalastuff\.net\/legal"/.test(page)) {
throw new Error(`${name} must point to the central KoalaStuff legal notice`);
}
}
@@ -176,6 +259,7 @@ if (!/animation:\s*none\s*!important/.test(reducedMotionRule)) {
console.log('Extension mockup theme-sensitive text uses theme-aware colors');
console.log('Landing CSS is render-blocking, single-request, and cascade-stable');
console.log('Landing head discovers a complete and architecture-accurate llms.txt');
console.log('Support-page social metadata and Git-derived sitemap dates remain truthful');
console.log('Website self-hosting examples include production secrets and consistent networking');
console.log('Foreground film birds use complete, always-on wing and glide animations');
console.log('All Getting Started mockups define explicit light-theme surfaces');
+1
View File
@@ -20,6 +20,7 @@ const checks = [
['content video finder', 'node', ['scripts/test-content-video-finder.cjs']],
['audio settings', 'node', ['scripts/test-audio-settings.mjs']],
['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']],
['chat settings', 'node', ['scripts/test-chat-settings.mjs']],
['host access recovery', 'node', ['scripts/test-host-access.mjs']],
['server syntax index', 'node', ['-c', 'server/index.js']],
['server syntax ops', 'node', ['-c', 'server/ops.js']],
+6 -5
View File
@@ -1,15 +1,16 @@
FROM node:20-alpine
FROM node:24-alpine
WORKDIR /app
# Copy shared protocol first
COPY shared/ ./shared/
COPY --chown=node:node shared/ ./shared/
# Copy server
COPY server/package*.json ./server/
RUN cd server && npm install --production
COPY server/ ./server/
COPY --chown=node:node server/package*.json ./server/
RUN cd server && npm ci --omit=dev
COPY --chown=node:node server/ ./server/
WORKDIR /app/server
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
+24
View File
@@ -0,0 +1,24 @@
import { randomUUID } from 'node:crypto';
export const CHAT_CIPHERTEXT_MAX_BYTES = 2028;
export const CHAT_CIPHERTEXT_MIN_BYTES = 29;
export function normalizeChatCiphertext(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) return null;
const bytes = Buffer.from(value, 'base64url');
if (bytes.length < CHAT_CIPHERTEXT_MIN_BYTES || bytes.length > CHAT_CIPHERTEXT_MAX_BYTES) return null;
if (bytes.toString('base64url') !== value) return null;
return value;
}
export function createChatEnvelope(data, senderId, now = Date.now, createId = randomUUID) {
if (!data || typeof data !== 'object' || typeof senderId !== 'string' || !senderId) return null;
const ciphertext = normalizeChatCiphertext(data.ciphertext);
if (!ciphertext) return null;
return {
id: createId(),
senderId,
timestamp: now(),
ciphertext
};
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { Buffer } from 'node:buffer';
import {
CHAT_CIPHERTEXT_MAX_BYTES,
createChatEnvelope,
normalizeChatCiphertext
} from './chat.js';
const encoded = (length) => Buffer.alloc(length, 7).toString('base64url');
describe('encrypted chat relay envelopes', () => {
it('accepts canonical base64url within the encrypted message bounds', () => {
expect(normalizeChatCiphertext(encoded(29))).toBe(encoded(29));
expect(normalizeChatCiphertext(encoded(CHAT_CIPHERTEXT_MAX_BYTES))).toBe(encoded(CHAT_CIPHERTEXT_MAX_BYTES));
});
it('rejects malformed, padded, too-small, and oversized payloads', () => {
expect(normalizeChatCiphertext('<script>')).toBeNull();
expect(normalizeChatCiphertext(`${encoded(29)}=`)).toBeNull();
expect(normalizeChatCiphertext(encoded(28))).toBeNull();
expect(normalizeChatCiphertext(encoded(CHAT_CIPHERTEXT_MAX_BYTES + 1))).toBeNull();
expect(normalizeChatCiphertext('A')).toBeNull();
});
it('stamps authoritative metadata and keeps only ciphertext from the sender', () => {
const ciphertext = encoded(64);
const envelope = createChatEnvelope({
ciphertext,
id: 'spoofed',
senderId: 'mallory',
timestamp: 1,
text: 'plaintext must not survive'
}, 'alice', () => 1234, () => 'server-id');
expect(envelope).toEqual({
id: 'server-id',
senderId: 'alice',
timestamp: 1234,
ciphertext
});
expect(JSON.stringify(envelope)).not.toContain('plaintext');
});
});
+63 -1
View File
@@ -5,6 +5,7 @@ import { Server } from 'socket.io';
import crypto from 'crypto';
import dotenv from 'dotenv';
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js';
import { createChatEnvelope } from './chat.js';
import {
buildHealthPayload,
checkCooldown,
@@ -19,6 +20,7 @@ import {
connectionCounts,
failedAuthAttempts,
eventCounts,
chatMessageCounts,
healthCounts,
adminMetricsAuthCounts,
roomListCooldowns,
@@ -28,6 +30,7 @@ import {
recordAuthFailure,
checkConnectionRate,
checkEventRate,
checkChatMessageRate,
checkHealthRate,
checkAdminMetricsAuthRate,
checkLeaveRoomRate,
@@ -171,7 +174,26 @@ const HOST_ONLY_GATED_EVENTS = new Set([
// Features this relay supports, advertised to clients in ROOM_DATA so they can
// enable matching UI/behavior only when the server actually backs it. Append a
// flag here when a new server-gated feature ships (e.g. co-host promotion).
const SERVER_CAPABILITIES = [CAPABILITIES.HOST_CONTROL, CAPABILITIES.CO_HOST];
const SERVER_CAPABILITIES = [
CAPABILITIES.HOST_CONTROL,
CAPABILITIES.CO_HOST,
CAPABILITIES.CHAT,
CAPABILITIES.CHAT_V1
];
function normalizeClientCapabilities(value) {
if (!Array.isArray(value)) return [];
return [...new Set(value.slice(0, 16)
.filter(capability => typeof capability === 'string')
.map(capability => capability.substring(0, 32))
.filter(capability => capability === CAPABILITIES.CHAT_V1)
)];
}
function clientSupportsChat(socket) {
return Array.isArray(socket?.data?.clientCapabilities) &&
socket.data.clientCapabilities.includes(CAPABILITIES.CHAT_V1);
}
// M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly
// toggling host from thrashing every guest's UI (locked/unlocked/locked...) and
@@ -353,6 +375,7 @@ io.on('connection', (socket) => {
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;
const clientCapabilities = normalizeClientCapabilities(payload.clientCapabilities);
if (!roomId || !peerId) return; // Guard: empty or invalid after sanitization
@@ -367,6 +390,7 @@ io.on('connection', (socket) => {
// Cleanup old room if re-joining
const oldMapping = socketToRoom.get(socket.id);
if (oldMapping && oldMapping.roomId === roomId && oldMapping.peerId === peerId) {
socket.data.clientCapabilities = clientCapabilities;
return; // Already in this room with same peerId, ignore to prevent spam
}
if (oldMapping && oldMapping.roomId !== roomId) {
@@ -490,6 +514,7 @@ io.on('connection', (socket) => {
}
socket.join(roomId);
socket.data.clientCapabilities = clientCapabilities;
room.peers.add(socket.id);
room.peerIds.set(socket.id, peerId);
room.peerData.set(socket.id, {
@@ -844,8 +869,45 @@ io.on('connection', (socket) => {
}
});
socket.on(EVENTS.CHAT_MESSAGE, (data) => {
try {
if (!checkEventRate(socket.id) || !checkChatMessageRate(socket.id)) {
log('SECURITY', `Encrypted chat rate limit exceeded for socket: ${socket.id}`);
socket.disconnect(true);
return;
}
const mapping = socketToRoom.get(socket.id);
if (!mapping) return;
const room = rooms.get(mapping.roomId);
if (!room) return;
const envelope = createChatEnvelope(data, mapping.peerId);
if (!envelope) return;
// Transitional compatibility: the first chat beta did not announce
// clientCapabilities. Successfully sending a canonical chat frame is
// sufficient proof that this socket understands the v1 receive event.
if (!clientSupportsChat(socket)) {
socket.data.clientCapabilities = [
...(socket.data.clientCapabilities || []),
CAPABILITIES.CHAT_V1
];
}
room.lastActivity = Date.now();
for (const socketId of room.peers) {
const recipient = io.sockets.sockets.get(socketId);
if (clientSupportsChat(recipient)) {
recipient.emit(EVENTS.CHAT_MESSAGE, envelope);
}
}
} catch (err) {
log('ERROR', `CHAT_MESSAGE handler error: ${err.message}`);
}
});
socket.on('disconnect', () => {
eventCounts.delete(socket.id);
chatMessageCounts.delete(socket.id);
roomListCooldowns.delete(socket.id);
leaveRoomCounts.delete(socket.id);
const mapping = socketToRoom.get(socket.id);
+90 -51
View File
@@ -30,12 +30,12 @@
}
},
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.19.0"
"undici-types": "~8.3.0"
}
},
"node_modules/@types/ws": {
@@ -70,20 +70,20 @@
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
@@ -93,6 +93,19 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -347,9 +360,9 @@
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -526,9 +539,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -558,9 +571,9 @@
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -604,12 +617,16 @@
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/merge-descriptors": {
@@ -739,12 +756,13 @@
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
@@ -754,12 +772,16 @@
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/raw-body": {
@@ -851,14 +873,14 @@
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
@@ -951,9 +973,9 @@
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
@@ -1025,23 +1047,40 @@
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"license": "MIT"
},
"node_modules/unpipe": {
@@ -1069,9 +1108,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
+21
View File
@@ -12,6 +12,8 @@ export const CONNECTION_RATE_LIMIT = 10; // max new connections per IP
export const CONNECTION_RATE_WINDOW_MS = 60000; // 1 minute
export const EVENT_RATE_LIMIT = 50; // max relayed events per socket per window
export const EVENT_RATE_WINDOW_MS = 10000; // 10 seconds
export const CHAT_MESSAGE_RATE_LIMIT = 10; // max encrypted chat messages per socket per window
export const CHAT_MESSAGE_RATE_WINDOW_MS = 10000; // 10 seconds
export const HEALTH_RATE_WINDOW_MS = 60000; // 1 minute
export const ADMIN_METRICS_AUTH_WINDOW_MS = 60000; // 1 minute
export const LEAVE_ROOM_RATE_LIMIT = 10; // max LEAVE_ROOM events per socket per window
@@ -20,6 +22,7 @@ export const LEAVE_ROOM_RATE_WINDOW_MS = 60000; // 1 minute
export const connectionCounts = new Map(); // ip -> { count, resetTime }
export const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
export const eventCounts = new Map(); // socketId -> { count, resetTime }
export const chatMessageCounts = new Map(); // socketId -> { count, resetTime }
export const healthCounts = new Map(); // ip -> { count, resetTime }
export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
export const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
@@ -28,6 +31,7 @@ export const leaveRoomCounts = new Map(); // socketId -> { count, resetTime }
export const rateLimitDenied = {
connections: 0,
events: 0,
chatMessages: 0,
health: 0,
adminMetricsAuth: 0,
roomList: 0,
@@ -104,6 +108,17 @@ export function checkEventRate(socketId) {
return false;
}
export function checkChatMessageRate(socketId) {
const now = Date.now();
const entry = chatMessageCounts.get(socketId) || { count: 0, resetTime: now + CHAT_MESSAGE_RATE_WINDOW_MS };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + CHAT_MESSAGE_RATE_WINDOW_MS; }
entry.count++;
chatMessageCounts.set(socketId, entry);
if (entry.count <= CHAT_MESSAGE_RATE_LIMIT) return true;
rateLimitDenied.chatMessages++;
return false;
}
export function checkHealthRate(ip) {
const now = Date.now();
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + HEALTH_RATE_WINDOW_MS };
@@ -163,6 +178,11 @@ export function startRateLimitCleanup(io) {
eventCounts.delete(socketId);
}
}
for (const [socketId, entry] of chatMessageCounts.entries()) {
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
chatMessageCounts.delete(socketId);
}
}
for (const [socketId, entry] of leaveRoomCounts.entries()) {
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
leaveRoomCounts.delete(socketId);
@@ -189,6 +209,7 @@ export function clearRateLimitMaps() {
connectionCounts.clear();
failedAuthAttempts.clear();
eventCounts.clear();
chatMessageCounts.clear();
healthCounts.clear();
adminMetricsAuthCounts.clear();
+30
View File
@@ -1,6 +1,10 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
checkLeaveRoomRate,
checkChatMessageRate,
CHAT_MESSAGE_RATE_LIMIT,
CHAT_MESSAGE_RATE_WINDOW_MS,
chatMessageCounts,
LEAVE_ROOM_RATE_LIMIT,
LEAVE_ROOM_RATE_WINDOW_MS,
rateLimitDenied,
@@ -99,6 +103,32 @@ describe('LEAVE_ROOM Rate Limiter', () => {
});
});
describe('CHAT_MESSAGE Rate Limiter', () => {
const socketId = 'chat-socket';
beforeEach(() => {
clearRateLimitMaps();
rateLimitDenied.chatMessages = 0;
});
afterEach(() => clearRateLimitMaps());
it('allows ten messages per ten-second window and blocks the next', () => {
for (let i = 0; i < CHAT_MESSAGE_RATE_LIMIT; i++) {
expect(checkChatMessageRate(socketId)).toBe(true);
}
expect(checkChatMessageRate(socketId)).toBe(false);
expect(rateLimitDenied.chatMessages).toBe(1);
});
it('resets after the window expires', () => {
for (let i = 0; i <= CHAT_MESSAGE_RATE_LIMIT; i++) checkChatMessageRate(socketId);
const entry = chatMessageCounts.get(socketId);
entry.resetTime = Date.now() - CHAT_MESSAGE_RATE_WINDOW_MS - 1;
expect(checkChatMessageRate(socketId)).toBe(true);
});
});
describe('Rate Limit Constants', () => {
it('should have correct rate limit values', () => {
expect(LEAVE_ROOM_RATE_LIMIT).toBe(10);
+7 -2
View File
@@ -7,7 +7,7 @@
*/
export const PROTOCOL_VERSION = "1.0.0";
export const APP_VERSION = "2.6.3";
export const APP_VERSION = "3.0.1";
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
@@ -57,6 +57,9 @@ export const EVENTS = {
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
// Ephemeral end-to-end encrypted chat
CHAT_MESSAGE: "chat_message", // Ciphertext relay; no server history
// Ping / Latency
PING: "ping", // { t: timestamp, target?: peerId } — empty target = server echo
PONG: "pong" // server responds with same { t } for client RTT calculation
@@ -77,7 +80,9 @@ export const CONTROL_MODES = {
// simply ignore the field. Add a flag here as each server-gated feature lands.
export const CAPABILITIES = {
HOST_CONTROL: 'host-control',
CO_HOST: 'co-host' // owner promotes guests to additional controllers
CO_HOST: 'co-host', // owner promotes guests to additional controllers
CHAT: 'chat', // legacy server capability used by the first chat beta
CHAT_V1: 'chat-v1' // versioned client/server chat wire contract
};
export const HEARTBEAT_INTERVAL = 15000; // 15s
+77
View File
@@ -0,0 +1,77 @@
(function exposeInviteLinks(root) {
const J2_PREFIX = '#j2:';
const LEGACY_PREFIX = '#join:';
function parseJ2Hash(hash) {
if (typeof hash !== 'string' || !hash.startsWith(J2_PREFIX)) return null;
const params = new URLSearchParams(hash.slice(J2_PREFIX.length));
const roomId = params.get('r') || '';
const password = params.get('p') || '';
const chatKey = params.get('k') || '';
const serverUrl = params.get('u') || '';
if (!roomId || !password || !chatKey) return null;
return {
format: 'j2',
roomId,
password,
chatKey,
useCustomServer: serverUrl.length > 0,
serverUrl
};
}
function parseLegacyHash(hash) {
if (typeof hash !== 'string' || !hash.startsWith(LEGACY_PREFIX)) return null;
const parts = hash.slice(LEGACY_PREFIX.length).split(':');
const roomId = parts.shift() || '';
let useCustomServer = false;
let serverUrl = '';
if (parts.length >= 3) {
const flag = parts.at(-2);
const rawUrl = parts.at(-1) || '';
let decodedUrl = '';
try {
decodedUrl = decodeURIComponent(rawUrl);
} catch (_) {
return null;
}
const custom = flag === '1' && /^(?:ws|wss):\/\//.test(decodedUrl);
const official = flag === '0' && rawUrl === '';
if (custom || official) {
parts.splice(-2, 2);
useCustomServer = custom;
serverUrl = custom ? decodedUrl : '';
}
}
const password = parts.join(':');
if (!roomId || !password) return null;
return {
format: 'legacy',
roomId,
password,
chatKey: '',
useCustomServer,
serverUrl
};
}
function parseInviteHash(hash) {
return parseJ2Hash(hash) || parseLegacyHash(hash);
}
function buildJ2Hash({ roomId, password, chatKey, serverUrl = '' }) {
if (!roomId || !password || !chatKey) throw new TypeError('roomId, password, and chatKey are required');
const params = new URLSearchParams({ r: roomId, p: password, k: chatKey });
if (serverUrl) params.set('u', serverUrl);
return `${J2_PREFIX}${params.toString()}`;
}
root.KoalaSyncInviteLinks = Object.freeze({
J2_PREFIX,
LEGACY_PREFIX,
parseInviteHash,
buildJ2Hash
});
})(globalThis);
+58
View File
@@ -0,0 +1,58 @@
import { beforeAll, describe, expect, it } from 'vitest';
beforeAll(async () => {
await import('./invite-links.js');
});
describe('invite links', () => {
it('round-trips official and custom j2 links', () => {
const api = globalThis.KoalaSyncInviteLinks;
const official = api.buildJ2Hash({
roomId: 'SAPPHIRE-DUCK-49',
password: '0X:UK3C',
chatKey: 'R5Ti1nxp0crfAFHf3gVncw'
});
expect(api.parseInviteHash(official)).toEqual({
format: 'j2',
roomId: 'SAPPHIRE-DUCK-49',
password: '0X:UK3C',
chatKey: 'R5Ti1nxp0crfAFHf3gVncw',
useCustomServer: false,
serverUrl: ''
});
const custom = api.buildJ2Hash({
roomId: 'SILENT-EAGLE-90',
password: '30PXPD',
chatKey: 'R5Ti1nxp0crfAFHf3gVncw',
serverUrl: 'wss://sync.example.test/socket?region=eu'
});
expect(api.parseInviteHash(custom)).toMatchObject({
useCustomServer: true,
serverUrl: 'wss://sync.example.test/socket?region=eu'
});
});
it('keeps legacy links compatible, including colons in passwords', () => {
const parse = globalThis.KoalaSyncInviteLinks.parseInviteHash;
expect(parse('#join:ROOM-1:pass:with:colons')).toMatchObject({
format: 'legacy',
password: 'pass:with:colons',
chatKey: '',
useCustomServer: false
});
expect(parse('#join:ROOM-1:pass:with:colons:1:wss%3A%2F%2Frelay.example')).toMatchObject({
password: 'pass:with:colons',
useCustomServer: true,
serverUrl: 'wss://relay.example'
});
});
it('rejects incomplete or malformed invite hashes', () => {
const parse = globalThis.KoalaSyncInviteLinks.parseInviteHash;
expect(parse('#j2:r=ROOM&p=PASS')).toBeNull();
expect(parse('#join:ROOM')).toBeNull();
expect(parse('#join:ROOM:PASS:1:%E0%A4%A')).toBeNull();
expect(parse('#other:r=ROOM&p=PASS&k=KEY')).toBeNull();
});
});
+3 -1
View File
@@ -8,7 +8,9 @@ export default defineConfig({
'server/**/*.test.js',
'server/**/*.test.mjs',
'shared/**/*.test.js',
'shared/**/*.test.mjs'
'shared/**/*.test.mjs',
'extension/**/*.test.js',
'extension/**/*.test.mjs'
],
coverage: {
provider: 'v8',
+1 -1
View File
@@ -135,7 +135,7 @@
<div class="container">
<p>&copy; 2026 KoalaSync. Open source under the MIT License.</p>
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
<a href="/imprint" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
<a href="https://koalastuff.net/legal" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
<a href="/privacy" style="color: var(--text-muted); text-decoration: none;">Privacy Policy</a>
</div>
</div>
+3 -3
View File
@@ -183,7 +183,7 @@
<div class="nav-links">
<a href="{{ASSET_PATH}}" style="display: inline-flex; align-items: center; gap: 6px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
<span>Home</span>
<span>{{NAV_HOME}}</span>
</a>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 6px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" 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>
@@ -192,7 +192,7 @@
</div>
<div class="nav-right">
<div class="lang-select-container">
<select class="lang-dropdown" aria-label="Select Language">
<select class="lang-dropdown" aria-label="{{ARIA_SELECT_LANGUAGE}}">
<option value="en">🇬🇧 English</option>
<option value="de">🇩🇪 Deutsch</option>
<option value="fr">🇫🇷 Français</option>
@@ -211,7 +211,7 @@
</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<button class="hamburger" aria-label="Menu" aria-expanded="false"></button>
<button class="hamburger" aria-label="{{ARIA_MENU}}" aria-expanded="false"></button>
</div>
</div>
</nav>
+3 -3
View File
@@ -189,7 +189,7 @@
<div class="nav-links">
<a href="{{ASSET_PATH}}" style="display: inline-flex; align-items: center; gap: 6px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
<span>Home</span>
<span>{{NAV_HOME}}</span>
</a>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 6px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" 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>
@@ -198,7 +198,7 @@
</div>
<div class="nav-right">
<div class="lang-select-container">
<select class="lang-dropdown" aria-label="Select Language">
<select class="lang-dropdown" aria-label="{{ARIA_SELECT_LANGUAGE}}">
<option value="en">🇬🇧 English</option>
<option value="de">🇩🇪 Deutsch</option>
<option value="fr">🇫🇷 Français</option>
@@ -217,7 +217,7 @@
</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<button class="hamburger" aria-label="Menu" aria-expanded="false"></button>
<button class="hamburger" aria-label="{{ARIA_MENU}}" aria-expanded="false"></button>
</div>
</div>
</nav>
+3 -3
View File
@@ -189,7 +189,7 @@
<div class="nav-links">
<a href="{{ASSET_PATH}}" style="display: inline-flex; align-items: center; gap: 6px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
<span>Home</span>
<span>{{NAV_HOME}}</span>
</a>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 6px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" 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>
@@ -198,7 +198,7 @@
</div>
<div class="nav-right">
<div class="lang-select-container">
<select class="lang-dropdown" aria-label="Select Language">
<select class="lang-dropdown" aria-label="{{ARIA_SELECT_LANGUAGE}}">
<option value="en">🇬🇧 English</option>
<option value="de">🇩🇪 Deutsch</option>
<option value="fr">🇫🇷 Français</option>
@@ -217,7 +217,7 @@
</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<button class="hamburger" aria-label="Menu" aria-expanded="false"></button>
<button class="hamburger" aria-label="{{ARIA_MENU}}" aria-expanded="false"></button>
</div>
</div>
</nav>
+3 -3
View File
@@ -189,7 +189,7 @@
<div class="nav-links">
<a href="{{ASSET_PATH}}" style="display: inline-flex; align-items: center; gap: 6px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
<span>Home</span>
<span>{{NAV_HOME}}</span>
</a>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 6px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" 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>
@@ -198,7 +198,7 @@
</div>
<div class="nav-right">
<div class="lang-select-container">
<select class="lang-dropdown" aria-label="Select Language">
<select class="lang-dropdown" aria-label="{{ARIA_SELECT_LANGUAGE}}">
<option value="en">🇬🇧 English</option>
<option value="de">🇩🇪 Deutsch</option>
<option value="fr">🇫🇷 Français</option>
@@ -217,7 +217,7 @@
</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<button class="hamburger" aria-label="Menu" aria-expanded="false"></button>
<button class="hamburger" aria-label="{{ARIA_MENU}}" aria-expanded="false"></button>
</div>
</div>
</nav>
+3 -3
View File
@@ -189,7 +189,7 @@
<div class="nav-links">
<a href="{{ASSET_PATH}}" style="display: inline-flex; align-items: center; gap: 6px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
<span>Home</span>
<span>{{NAV_HOME}}</span>
</a>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 6px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" 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>
@@ -198,7 +198,7 @@
</div>
<div class="nav-right">
<div class="lang-select-container">
<select class="lang-dropdown" aria-label="Select Language">
<select class="lang-dropdown" aria-label="{{ARIA_SELECT_LANGUAGE}}">
<option value="en">🇬🇧 English</option>
<option value="de">🇩🇪 Deutsch</option>
<option value="fr">🇫🇷 Français</option>
@@ -217,7 +217,7 @@
</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<button class="hamburger" aria-label="Menu" aria-expanded="false"></button>
<button class="hamburger" aria-label="{{ARIA_MENU}}" aria-expanded="false"></button>
</div>
</div>
</nav>
+3 -3
View File
@@ -189,7 +189,7 @@
<div class="nav-links">
<a href="{{ASSET_PATH}}" style="display: inline-flex; align-items: center; gap: 6px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
<span>Home</span>
<span>{{NAV_HOME}}</span>
</a>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 6px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" 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>
@@ -198,7 +198,7 @@
</div>
<div class="nav-right">
<div class="lang-select-container">
<select class="lang-dropdown" aria-label="Select Language">
<select class="lang-dropdown" aria-label="{{ARIA_SELECT_LANGUAGE}}">
<option value="en">🇬🇧 English</option>
<option value="de">🇩🇪 Deutsch</option>
<option value="fr">🇫🇷 Français</option>
@@ -217,7 +217,7 @@
</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<button class="hamburger" aria-label="Menu" aria-expanded="false"></button>
<button class="hamburger" aria-label="{{ARIA_MENU}}" aria-expanded="false"></button>
</div>
</div>
</nav>
+89 -41
View File
@@ -235,20 +235,55 @@ document.addEventListener('DOMContentLoaded', () => {
// Auto-update URL hash as user scrolls through sections
// (preserves position across language switches)
if ('IntersectionObserver' in window) {
const sectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
if (entry.target.id === 'top') {
history.replaceState(null, null, window.location.pathname + window.location.search);
} else {
history.replaceState(null, null, '#' + entry.target.id);
}
if (forestGreet) forestGreet();
}
const trackedSections = [...document.querySelectorAll('section[id], header[id]')];
if (trackedSections.length > 0) {
let hashUpdateFrame = null;
let hashNavigationTimer = null;
let hashNavigationInProgress = false;
const updateSectionHash = () => {
hashUpdateFrame = null;
const readingLine = 112;
const activeSection = trackedSections.reduce((closest, section) => {
const distance = Math.abs(section.getBoundingClientRect().top - readingLine);
const closestDistance = Math.abs(closest.getBoundingClientRect().top - readingLine);
return distance < closestDistance ? section : closest;
});
}, { threshold: 0.3 });
document.querySelectorAll('section[id], header[id]').forEach(el => sectionObserver.observe(el));
const nextHash = activeSection.id === 'top' ? '' : '#' + activeSection.id;
if (window.location.hash === nextHash) return;
if (nextHash === '') {
history.replaceState(null, null, window.location.pathname + window.location.search);
} else {
history.replaceState(null, null, nextHash);
}
if (forestGreet) forestGreet();
};
const scheduleSectionHashUpdate = () => {
if (hashUpdateFrame !== null) return;
hashUpdateFrame = requestAnimationFrame(updateSectionHash);
};
const finishHashNavigation = () => {
hashNavigationTimer = null;
hashNavigationInProgress = false;
scheduleSectionHashUpdate();
};
const beginHashNavigation = () => {
hashNavigationInProgress = true;
if (hashNavigationTimer !== null) clearTimeout(hashNavigationTimer);
hashNavigationTimer = setTimeout(finishHashNavigation, 250);
};
const handleSectionScroll = () => {
if (!hashNavigationInProgress) {
scheduleSectionHashUpdate();
return;
}
if (hashNavigationTimer !== null) clearTimeout(hashNavigationTimer);
hashNavigationTimer = setTimeout(finishHashNavigation, 180);
};
window.addEventListener('hashchange', beginHashNavigation);
window.addEventListener('scroll', handleSectionScroll, { passive: true });
if (window.location.hash) beginHashNavigation();
else scheduleSectionHashUpdate();
}
// Navbar scroll effect (class-based so it follows the active theme)
@@ -457,15 +492,11 @@ document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
const isInstalled = document.documentElement.dataset.koalasyncInstalled === 'true';
if (window.location.hash.startsWith('#join:')) {
const parts = window.location.hash.split(':');
if (parts.length >= 3) {
const roomId = parts[1];
const password = parts[2];
const serverFlag = parts[3] || '0';
const serverUrl = parts[4] ? decodeURIComponent(parts[4]) : '';
const invite = globalThis.KoalaSyncInviteLinks?.parseInviteHash(window.location.hash);
if (invite) {
const { roomId, password, chatKey, useCustomServer, serverUrl } = invite;
if (isJoinPage) {
if (isJoinPage) {
const displayRoom = document.getElementById('display-room-id');
const actions = document.getElementById('join-actions');
if (displayRoom) displayRoom.textContent = roomId;
@@ -527,14 +558,15 @@ document.addEventListener('DOMContentLoaded', () => {
detail: {
roomId,
password,
useCustomServer: serverFlag === '1',
serverUrl: serverUrl
chatKey,
useCustomServer,
serverUrl
}
}));
}, 500);
}
}
} else {
} else {
// Fallback banner for index.html
if (!document.getElementById('koala-banner')) {
const banner = document.createElement('div');
@@ -564,22 +596,22 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
// Global listener for Join Button
document.addEventListener('click', (e) => {
if (e.target && e.target.id === 'webJoinBtn') {
e.target.textContent = 'JOINING...';
e.target.disabled = true;
window.dispatchEvent(new CustomEvent('KOALASYNC_JOIN_REQUEST', {
detail: {
roomId,
password,
useCustomServer: serverFlag === '1',
serverUrl: serverUrl
}
}));
}
});
}
// Global listener for Join Button
document.addEventListener('click', (e) => {
if (e.target && e.target.id === 'webJoinBtn') {
e.target.textContent = 'JOINING...';
e.target.disabled = true;
window.dispatchEvent(new CustomEvent('KOALASYNC_JOIN_REQUEST', {
detail: {
roomId,
password,
chatKey,
useCustomServer,
serverUrl
}
}));
}
});
}
}, 600); // 600ms delay to ensure bridge.js has set the dataset
};
@@ -1561,6 +1593,8 @@ document.addEventListener('DOMContentLoaded', () => {
tempSpan.style.fontFamily = style.fontFamily;
tempSpan.style.fontSize = style.fontSize;
tempSpan.style.fontWeight = style.fontWeight;
tempSpan.style.fontStyle = style.fontStyle;
tempSpan.style.letterSpacing = style.letterSpacing;
tempSpan.style.visibility = 'hidden';
tempSpan.style.position = 'absolute';
tempSpan.style.whiteSpace = 'nowrap';
@@ -1570,7 +1604,18 @@ document.addEventListener('DOMContentLoaded', () => {
tempSpan.textContent = activeOption.textContent;
document.body.appendChild(tempSpan);
const textWidth = tempSpan.getBoundingClientRect().width;
select.style.width = (textWidth + 18) + 'px';
const borderBoxChrome = style.boxSizing === 'border-box'
? [
style.paddingLeft,
style.paddingRight,
style.borderLeftWidth,
style.borderRightWidth
].reduce((total, value) => total + (Number.parseFloat(value) || 0), 0)
: 0;
// Leave a few extra pixels for platform-specific native select
// text rendering. The right padding already reserves the
// visible chevron area.
select.style.width = `${Math.ceil(textWidth + borderBoxChrome + 4)}px`;
document.body.removeChild(tempSpan);
}
});
@@ -1715,6 +1760,9 @@ document.addEventListener('DOMContentLoaded', () => {
updateDynamicVersion();
localizeHomeLinks();
initLanguageSelectorValue();
if (document.fonts?.ready) {
document.fonts.ready.then(adjustDropdownWidth);
}
// FAQ Accordion Transition (Web Animations API for smooth vertical spring height collapse/expand)
try {
document.querySelectorAll('.faq-item').forEach(details => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+116 -42
View File
@@ -5,6 +5,7 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { execFileSync } = require('child_process');
const sharp = require('sharp');
const esbuild = require('esbuild');
const htmlnano = require('htmlnano');
@@ -108,12 +109,22 @@ function stageFlagFontSubset(websiteDir, wwwDir) {
function copyDirSync(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
if (entry.name === '.DS_Store') continue;
const s = path.join(src, entry.name);
const d = path.join(dest, entry.name);
entry.isDirectory() ? copyDirSync(s, d) : fs.copyFileSync(s, d);
}
}
function removeBuildMetadata(dir) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.name === '.DS_Store') fs.rmSync(entryPath, { force: true });
else if (entry.isDirectory()) removeBuildMetadata(entryPath);
}
}
function sha8(buf) { return crypto.createHash('sha256').update(buf).digest('hex').slice(0, 8); }
function sha384(buf) { return 'sha384-' + crypto.createHash('sha384').update(buf).digest('base64'); }
@@ -163,6 +174,7 @@ async function compile() {
const websiteDir = __dirname;
const wwwDir = path.join(websiteDir, 'www');
fs.mkdirSync(wwwDir, { recursive: true });
removeBuildMetadata(wwwDir);
// ── 0. Auto-generate website logo sizes and sync favicons ──
console.log('Generating responsive website logos...');
@@ -285,6 +297,12 @@ async function compile() {
const appName = `app.${appHash}.min.js`;
const appSRI = sha384(appMin);
const inviteLinksRaw = fs.readFileSync(path.join(websiteDir, '..', 'shared', 'invite-links.js'), 'utf8');
const inviteLinksMin = await minifyJS(inviteLinksRaw);
const inviteLinksHash = sha8(inviteLinksMin);
const inviteLinksName = `invite-links.${inviteLinksHash}.min.js`;
const inviteLinksSRI = sha384(inviteLinksMin);
const langRaw = fs.readFileSync(path.join(websiteDir, 'lang-init.js'), 'utf8');
const langMin = await minifyJS(langRaw);
const langHash = sha8(langMin);
@@ -299,6 +317,7 @@ async function compile() {
console.log(` Landing ${bundle.key}: ${bundle.name} (${(bundle.min.length/1024).toFixed(1)} KB)`);
}
console.log(` App: ${appName} (${(appMin.length/1024).toFixed(1)} KB, -${appPct}%)`);
console.log(` Invite links: ${inviteLinksName} (${(inviteLinksMin.length/1024).toFixed(1)} KB)`);
console.log(` Lang: ${langName} (${(langMin.length/1024).toFixed(1)} KB, -${langPct}%)`);
// ── 2. Clean stale minified output ──
@@ -315,6 +334,7 @@ async function compile() {
fs.writeFileSync(path.join(wwwDir, bundle.name), bundle.min);
}
fs.writeFileSync(path.join(wwwDir, appName), appMin);
fs.writeFileSync(path.join(wwwDir, inviteLinksName), inviteLinksMin);
fs.writeFileSync(path.join(wwwDir, langName), langMin);
// ── 3. Compile HTML templates ──
@@ -515,6 +535,7 @@ async function compile() {
{ src: 'join.html', dest: 'join.html' },
{ src: 'imprint.html', dest: 'imprint.html' },
{ src: 'privacy.html', dest: 'privacy.html' },
{ src: 'help.html', dest: 'help.html' },
{ src: 'site-access-help.html', dest: 'site-access-help.html' },
{ src: 'impressum-de.html', dest: 'de/impressum.html' },
{ src: 'datenschutz-de.html', dest: 'de/datenschutz.html' },
@@ -541,7 +562,7 @@ async function compile() {
}
// ── 5.5 Generate dynamic sitemap ──
generateSitemap(wwwDir);
generateSitemap(websiteDir, wwwDir);
// Auto-copy Google verification files and IndexNow/txt key files from website source to www root
const websiteFiles = fs.readdirSync(websiteDir);
@@ -654,6 +675,9 @@ async function compile() {
html = html.replace(/(<script\b[^>]*?\bsrc=")((?:\.\.\/)*\/?)app\.min\.js"/g, (m, before, prefix) => {
return `${before}${prefix}${appName}" integrity="${appSRI}" crossorigin="anonymous"`;
});
html = html.replace(/(<script\b[^>]*?\bsrc=")((?:\.\.\/)*\/?)invite-links\.min\.js"/g, (m, before, prefix) => {
return `${before}${prefix}${inviteLinksName}" integrity="${inviteLinksSRI}" crossorigin="anonymous"`;
});
html = html.replace(/(<script\b[^>]*?\bsrc=")((?:\.\.\/)*\/?)lang-init\.min\.js"/g, (m, before, prefix) => {
return `${before}${prefix}${langName}" integrity="${langSRI}" crossorigin="anonymous"`;
});
@@ -686,7 +710,7 @@ async function compile() {
console.log('KoalaSync build finished successfully! Output: website/www/');
}
function generateSitemap(wwwDir) {
function generateSitemap(websiteDir, wwwDir) {
const languages = [
{ code: 'en', prefix: '', hreflang: 'en' },
{ code: 'de', prefix: 'de/', hreflang: 'de' },
@@ -705,54 +729,102 @@ function generateSitemap(wwwDir) {
{ code: 'pt', prefix: 'pt/', hreflang: 'pt' }
];
const today = new Date().toISOString().split('T')[0];
const repoRoot = path.resolve(websiteDir, '..');
const lastModifiedCache = new Map();
const dirtySourceFiles = new Set();
let gitHistoryAvailable = false;
let usedDirtySourceDate = false;
try {
const isInsideWorkTree = execFileSync(
'git', ['rev-parse', '--is-inside-work-tree'],
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
).trim() === 'true';
const isShallow = execFileSync(
'git', ['rev-parse', '--is-shallow-repository'],
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
).trim() === 'true';
gitHistoryAvailable = isInsideWorkTree && !isShallow;
if (gitHistoryAvailable) {
const dirtyCommands = [
['diff', '--name-only', '--'],
['diff', '--cached', '--name-only', '--'],
['ls-files', '--others', '--exclude-standard', '--']
];
for (const args of dirtyCommands) {
const output = execFileSync(
'git', args,
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
);
for (const file of output.split(/\r?\n/).filter(Boolean)) {
dirtySourceFiles.add(file.replaceAll('\\', '/'));
}
}
}
} catch (_) {
gitHistoryAvailable = false;
}
function getLastModified(sourceFiles) {
const cacheKey = sourceFiles.slice().sort().join('\0');
if (lastModifiedCache.has(cacheKey)) return lastModifiedCache.get(cacheKey);
if (!gitHistoryAvailable) {
lastModifiedCache.set(cacheKey, null);
return null;
}
let date = null;
try {
if (sourceFiles.some(file => dirtySourceFiles.has(file))) {
usedDirtySourceDate = true;
date = new Date().toISOString().slice(0, 10);
} else {
const output = execFileSync(
'git',
['log', '-1', '--format=%cs', '--', ...sourceFiles],
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
).trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(output)) date = output;
}
} catch (_) {
// Source archives and minimal deployment environments may not contain
// Git metadata. Omitting lastmod is more accurate than inventing a date.
}
lastModifiedCache.set(cacheKey, date);
return date;
}
function lastmod(sourceFiles, indent = ' ') {
const date = getLastModified(sourceFiles);
return date ? `\n${indent}<lastmod>${date}</lastmod>` : '';
}
let xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">`;
// Static legal pages
// Static project-specific policy and help pages
xml += `
<url>
<loc>https://sync.koalastuff.net/imprint</loc>
<lastmod>${today}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
<loc>https://sync.koalastuff.net/privacy</loc>${lastmod(['website/privacy.html'])}
</url>
<url>
<loc>https://sync.koalastuff.net/privacy</loc>
<lastmod>${today}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
<loc>https://sync.koalastuff.net/help</loc>${lastmod(['website/help.html', 'website/styles/support.css', 'website/app.js'])}
</url>
<url>
<loc>https://sync.koalastuff.net/site-access-help.html</loc>
<lastmod>${today}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
<loc>https://sync.koalastuff.net/site-access-help</loc>${lastmod(['website/site-access-help.html', 'website/app.js'])}
</url>
<url>
<loc>https://sync.koalastuff.net/de/impressum</loc>
<lastmod>${today}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://sync.koalastuff.net/de/datenschutz</loc>
<lastmod>${today}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
<loc>https://sync.koalastuff.net/de/datenschutz</loc>${lastmod(['website/datenschutz-de.html'])}
</url>`;
function addPage(relativePath, changefreq, priority) {
function addPage(relativePath, templateSource) {
for (const lang of languages) {
const loc = `https://sync.koalastuff.net/${lang.prefix}${relativePath}`;
const sourceFiles = [templateSource, `website/locales/${lang.code}.json`];
xml += `
<url>
<loc>${loc}</loc>
<lastmod>${today}</lastmod>
<changefreq>${changefreq}</changefreq>
<priority>${priority}</priority>`;
<loc>${loc}</loc>${lastmod(sourceFiles)}`;
for (const alt of languages) {
const altHref = `https://sync.koalastuff.net/${alt.prefix}${relativePath}`;
xml += `
@@ -765,24 +837,26 @@ function generateSitemap(wwwDir) {
}
}
addPage('', 'weekly', '1.0');
addPage('alternatives', 'weekly', '0.7');
addPage('', 'website/template.html');
addPage('alternatives', 'website/alternatives/index.html');
const subpages = [
'alternatives/teleparty',
'alternatives/screen-sharing',
'alternatives/watch2gether',
'alternatives/scener',
'alternatives/kosmi',
'alternatives/twoseven'
['alternatives/teleparty', 'website/alternatives/teleparty.html'],
['alternatives/screen-sharing', 'website/alternatives/screen-sharing.html'],
['alternatives/watch2gether', 'website/alternatives/watch2gether.html'],
['alternatives/scener', 'website/alternatives/scener.html'],
['alternatives/kosmi', 'website/alternatives/kosmi.html'],
['alternatives/twoseven', 'website/alternatives/twoseven.html']
];
for (const sub of subpages) {
addPage(sub, 'weekly', '0.7');
for (const [sub, templateSource] of subpages) {
addPage(sub, templateSource);
}
xml += `\n</urlset>\n`;
fs.writeFileSync(path.join(wwwDir, 'sitemap.xml'), xml.trim() + '\n', 'utf8');
console.log(' ✓ Dynamically generated sitemap.xml with current date');
const datedEntries = (xml.match(/<lastmod>/g) || []).length;
const dateSource = usedDirtySourceDate ? 'Git/working-tree-derived' : 'Git-derived';
console.log(` ✓ Dynamically generated sitemap.xml with ${datedEntries} ${dateSource} modification dates`);
}
compile().catch(err => { console.error('Build failed:', err); process.exit(1); });
+23 -8
View File
@@ -11,7 +11,7 @@
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/privacy">
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/datenschutz">
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/privacy">
<meta name="description" content="Datenschutzerklärung von KoalaSync - Wir nehmen Datenschutz ernst. Erfahre mehr über unseren Ansatz ohne Tracking, ohne Logs und ohne Cookies.">
<meta name="description" content="Datenschutzerklärung von KoalaSync. Erfahre, wie Live-Raumdaten, verschlüsselter Chat, Betriebslogs und Website-Access-Logs verarbeitet werden.">
<meta name="robots" content="index, follow">
<script type="application/ld+json">
@@ -31,7 +31,7 @@
"@type": "WebPage",
"name": "Datenschutzerklärung",
"url": "https://sync.koalastuff.net/de/datenschutz",
"description": "Datenschutzerklärung von KoalaSync - Wir nehmen Datenschutz ernst. Erfahre mehr über unseren Ansatz ohne Tracking, ohne Logs und ohne Cookies.",
"description": "Datenschutzerklärung von KoalaSync. Erfahre, wie Live-Raumdaten, verschlüsselter Chat, Betriebslogs und Website-Access-Logs verarbeitet werden.",
"inLanguage": "de",
"isPartOf": {
"@type": "WebSite",
@@ -202,11 +202,20 @@
<section>
<h2>4. Offizieller Relay-Server & Raumdaten</h2>
<p>
Der offizielle KoalaSync-Relay-Server führt keine persistenten Logs. Er speichert keine Raumhistorie, keinen Sync-Verlauf, keinen Wiedergabeverlauf, keine Raumnachrichten, keine Playback-Events, keine Tab-Titel und keine Medientitel auf Festplatte.
Der offizielle KoalaSync-Relay-Server erstellt keine Raum-, Chat-, Synchronisations- oder Wiedergabehistorie. Betriebslogs für Sicherheit und Fehleranalyse können IP-Adressen, Socket- oder Peer-IDs, Protokollversionen und gekürzte Room-IDs enthalten. Ihre Aufbewahrungsdauer hängt von der Server-Infrastruktur und deren Logging-Konfiguration ab.
</p>
<p style="margin-top: 0.5rem;">
Zur Bereitstellung der Raumfunktion verarbeitet der Relay Raum- und Synchronisationsdaten temporär im Arbeitsspeicher, solange ein Raum aktiv ist. Dazu können Room-ID, Peer-ID, Anzeigename, Wiedergabestatus, Sync-Events und abhängig von den Titel-Privatsphäre-Einstellungen Tab-/Medientitel gehören. Diese Daten werden nur an Teilnehmer desselben Raums weitergeleitet und aus dem Arbeitsspeicher entfernt, wenn der Raum abläuft oder geschlossen wird.
</p>
<p style="margin-top: 0.5rem;">
Der Inhalt von Raum-Chatnachrichten wird bereits in der Browser-Erweiterung vor der Übertragung Ende-zu-Ende verschlüsselt. KoalaSync leitet lokal aus der Room-ID und einem zufällig erzeugten Chat-Secret einen 256-Bit-Schlüssel für AES-GCM ab. Das Chat-Secret wird über den Einladungslink geteilt und von der Erweiterung lokal gespeichert; es ist nicht Bestandteil der an den Relay gesendeten Chat-Nutzdaten.
</p>
<p style="margin-top: 0.5rem;">
Der Relay erhält ausschließlich den verschlüsselten Nachrichteninhalt sowie die für die Live-Zustellung erforderlichen technischen Metadaten, beispielsweise Absender-ID, Nachrichten-ID und Zeitstempel. Da der Relay das Chat-Secret nicht erhält, kann er den Nachrichtentext nicht entschlüsseln oder lesen. Er leitet den verschlüsselten Inhalt an die aktuell verbundenen Teilnehmer desselben Raums weiter und erstellt keine serverseitige Chat-Historie, schreibt keine Nachrichten auf Festplatte und bewahrt sie nach der Live-Weiterleitung nicht auf.
</p>
<p style="margin-top: 0.5rem;">
Wer einen gültigen Einladungslink einschließlich Chat-Secret erhält, kann Nachrichten dieses Raums möglicherweise entschlüsseln. Einladungslinks sollten daher ausschließlich mit den vorgesehenen Teilnehmern geteilt werden.
</p>
<p style="margin-top: 0.5rem;">
Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO, berechtigtes Interesse an der Bereitstellung der gewünschten Synchronisationsfunktion, Sicherheit und Missbrauchsprävention.
</p>
@@ -221,7 +230,13 @@
Zur Synchronisierung der Wiedergabe zwischen Teilnehmern desselben Raums erfasst die KoalaSync Browser-Erweiterung temporär Daten des aktuell aktiven Video-Tabs (z. B. Tab-Titel, Medien-Metadaten wie den Videotitel sowie den Wiedergabestatus). Tab- oder Medientitel können je nach Inhalt potenziell personenbezogen sein. Sie werden nur für die Synchronisationsfunktion verarbeitet, können über die Privatsphäre-Einstellungen der Erweiterung eingeschränkt oder deaktiviert werden, werden nur an Teilnehmer desselben Raums weitergeleitet und vom Relay nicht protokolliert oder dauerhaft gespeichert.
</p>
<p style="margin-top: 0.5rem;">
Die Erweiterung speichert nur für den Betrieb erforderliche Einstellungen lokal im Browser, z. B. Anzeigename, Server-URL, aktuelle Raumdaten, Sprache, Titel-Privatsphäre-Einstellungen, Benachrichtigungseinstellungen und Audio-Processing-Einstellungen. KoalaSync speichert keinen allgemeinen Browserverlauf und keinen persistenten Wiedergabeverlauf.
Die Erweiterung speichert nur für den Betrieb erforderliche Einstellungen lokal im Browser, z. B. Anzeigename, Server-URL, aktuelle Raumdaten einschließlich Chat-Secret, Sprache, Titel-Privatsphäre-Einstellungen, Benachrichtigungseinstellungen und Audio-Processing-Einstellungen. KoalaSync speichert keinen allgemeinen Browserverlauf und keinen persistenten Wiedergabeverlauf.
</p>
<p style="margin-top: 0.5rem;">
Entschlüsselte Chatnachrichten werden ausschließlich im Arbeitsspeicher des Overlays im aktuellen Video-Tab gehalten; sichtbar bleiben höchstens 200 Nachrichten- oder Aktivitätseinträge. Sie werden nicht in den Browser-Speicher geschrieben und verschwinden, wenn der Chat zurückgesetzt oder entfernt, die Seite neu geladen oder die Raumsitzung beendet wird. Im Chat angezeigte Wiedergabe- und Anwesenheitseinträge werden lokal aus bereits vorhandenen Raum-Events erzeugt und erstellen keine separate serverseitige Historie.
</p>
<p style="margin-top: 0.5rem;">
Wenn Chat-Benachrichtigungen aktiviert sind, werden der entschlüsselte Anzeigename und Nachrichtentext an die Benachrichtigungs-API des Browsers übergeben und können entsprechend den Einstellungen des Browsers oder Betriebssystems angezeigt oder dort vorgehalten werden. Chat-Benachrichtigungen können in den Einstellungen der Erweiterung deaktiviert werden.
</p>
<p style="margin-top: 0.5rem;">
Die Erweiterung verbindet sich nur mit dem Relay-Server, während Sie sich aktiv in einem Raum befinden. Es wird keine dauerhafte Hintergrundverbindung aufrechterhalten, sodass Ihre IP-Adresse dem Server nicht offengelegt wird, wenn Sie die Erweiterung nicht nutzen.
@@ -254,14 +269,14 @@
<section>
<h2>8. Empfänger & Dritte</h2>
<p>
Technische Hosting- und Verbindungsdaten können durch Hetzner als Hosting-Anbieter im Auftrag des Betreibers verarbeitet werden. Raum-Synchronisationsdaten werden nur an Teilnehmer desselben Raums weitergeleitet. KoalaSync verwendet keine Analytics-Anbieter, Werbenetzwerke oder Tracking-Dienste.
Technische Hosting- und Verbindungsdaten können durch Hetzner als Hosting-Anbieter im Auftrag des Betreibers verarbeitet werden. Raum-Synchronisationsdaten werden nur an Teilnehmer desselben Raums weitergeleitet. Verschlüsselte Raum-Chat-Nutzdaten und deren Zustellungsmetadaten werden ebenfalls nur innerhalb des Raums weitergeleitet; lesbarer Nachrichteninhalt steht nur Teilnehmern zur Verfügung, die das Chat-Secret besitzen. KoalaSync verwendet keine Analytics-Anbieter, Werbenetzwerke oder Tracking-Dienste.
</p>
</section>
<section>
<h2>9. Erforderliche & optionale Daten</h2>
<p>
Die Bereitstellung technischer Verbindungsdaten ist erforderlich, um die Website aufzurufen und den Relay zu nutzen. Raumdaten und Sync-Events sind erforderlich, um die Raumsynchronisation zu verwenden. Das Teilen von Tab-/Medientitel ist optional und kann über die Privatsphäre-Einstellungen der Erweiterung eingeschränkt werden.
Die Bereitstellung technischer Verbindungsdaten ist erforderlich, um die Website aufzurufen und den Relay zu nutzen. Raumdaten und Sync-Events sind erforderlich, um die Raumsynchronisation zu verwenden. Der Text-Chat ist optional; bei seiner Nutzung sind verschlüsselte Nachrichten-Nutzdaten und Zustellungsmetadaten für die Live-Zustellung erforderlich. Das Teilen von Tab-/Medientiteln und Browser-Benachrichtigungen sind optional und können über die Privatsphäre-Einstellungen der Erweiterung gesteuert werden.
</p>
</section>
@@ -295,9 +310,9 @@
<footer>
<div class="container">
<p>&copy; 2026 KoalaSync. Open Source unter der MIT-Lizenz.</p>
<p style="font-size: 0.8rem; margin-top: 0.5rem;">Keine Relay-Logs. Kein Tracking. Website-Access-Logs werden nach 7 Tagen gelöscht.</p>
<p style="font-size: 0.8rem; margin-top: 0.5rem;">Keine Chat- oder Wiedergabehistorie. Kein Tracking. Website-Access-Logs werden nach 7 Tagen gelöscht.</p>
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
<a href="impressum" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
<a href="https://koalastuff.net/legal" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
<a href="datenschutz" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
<a href="alternatives/teleparty">Vergleiche & Anleitungen</a>
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
+457
View File
@@ -0,0 +1,457 @@
<!DOCTYPE html>
<html lang="en" class="lang-en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KoalaSync Help | Setup, Chat, Privacy & Troubleshooting</title>
<meta name="description" content="Get KoalaSync working, fix invite and chat problems, understand supported devices and data handling, and verify official open-source releases.">
<meta name="robots" content="index, follow">
<link rel="preload" href="style.min.css" as="style">
<link rel="stylesheet" href="style.min.css">
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32x32.png">
<link rel="canonical" href="https://sync.koalastuff.net/help">
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/help">
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/help">
<link rel="alternate" type="text/markdown" href="https://sync.koalastuff.net/llms.txt" title="KoalaSync information for AI assistants">
<meta property="og:type" content="website">
<meta property="og:url" content="https://sync.koalastuff.net/help">
<meta property="og:title" content="KoalaSync Help">
<meta property="og:description" content="Setup help, invite troubleshooting, encrypted chat, supported devices, privacy, open-source releases, and reproducible bug reports.">
<meta property="og:image" content="https://sync.koalastuff.net/assets/og-image.png">
<meta property="og:image:width" content="1280">
<meta property="og:image:height" content="640">
<meta property="og:image:type" content="image/png">
<meta property="og:image:alt" content="KoalaSync browser extension">
<meta property="og:site_name" content="KoalaSync">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="KoalaSync Help">
<meta name="twitter:description" content="Setup help, invite troubleshooting, encrypted chat, supported devices, privacy, open-source releases, and reproducible bug reports.">
<meta name="twitter:image" content="https://sync.koalastuff.net/assets/og-image.png">
<meta name="twitter:image:alt" content="KoalaSync browser extension">
<meta name="theme-color" content="#10190e">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://sync.koalastuff.net/#website",
"name": "KoalaSync",
"url": "https://sync.koalastuff.net/"
},
{
"@type": "Organization",
"@id": "https://sync.koalastuff.net/#organization",
"name": "KoalaSync",
"url": "https://sync.koalastuff.net/",
"logo": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
"sameAs": ["https://github.com/Shik3i/KoalaSync"]
},
{
"@type": ["CollectionPage", "FAQPage"],
"@id": "https://sync.koalastuff.net/help#webpage",
"name": "KoalaSync Help",
"headline": "KoalaSync Help: Setup, Chat, Privacy and Troubleshooting",
"description": "Help for joining rooms, granting browser access, fixing encrypted room chat and playback synchronization, understanding data handling, and verifying official releases.",
"url": "https://sync.koalastuff.net/help",
"datePublished": "2026-07-28",
"dateModified": "2026-07-28",
"inLanguage": "en",
"isPartOf": {"@id": "https://sync.koalastuff.net/#website"},
"publisher": {"@id": "https://sync.koalastuff.net/#organization"},
"mainEntity": [
{
"@type": "Question",
"name": "Why does the browser show such a broad permission warning for KoalaSync?",
"acceptedAnswer": {
"@type": "Answer",
"text": "KoalaSync works with video players across many websites. It uses access on the video tab you actively select to find and control the video element, synchronize playback, and display optional room chat. Open tab titles populate the tab selector; KoalaSync does not create a general browsing or watch history."
}
},
{
"@type": "Question",
"name": "Why does a KoalaSync invite link not join the room?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Open the complete invite link in the same desktop browser profile where KoalaSync is installed. If the invite page was already open when the extension was installed or enabled, reload it. Confirm that the browser allows KoalaSync to run on sync.koalastuff.net, then ask the sender for a newly copied invite if the link is incomplete."
}
},
{
"@type": "Question",
"name": "Why does a video tab not appear in KoalaSync?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Reload the video page, reopen KoalaSync, and disable Hide Clutter Tabs in Settings. If the tab then appears, report the domain because it was filtered by mistake. Otherwise, verify that KoalaSync has website access."
}
},
{
"@type": "Question",
"name": "Why does KoalaSync find no video on a listed tab?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Select the tab containing the visible player and start playback once. Check Video Debug Info in the Status tab, reload after permission changes, and temporarily disable extensions that modify the player. Some protected or non-HTML5 players may need site-specific support."
}
},
{
"@type": "Question",
"name": "Why does KoalaSync room chat not work for everyone?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Room chat requires KoalaSync 3.0.0 or newer. The room creator receives a chat encryption secret when creating a new room, and every friend must join through that room's current invite link to receive the same secret. Manual room entry and old invite links can still join playback synchronization, but do not provide chat access."
}
},
{
"@type": "Question",
"name": "Why are connected KoalaSync participants not synchronized?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Confirm that everyone uses the same KoalaSync version and selected the correct local video tab. Reload after website-access changes, test play, pause, and seeking separately, and collect a debug report from each affected participant."
}
},
{
"@type": "Question",
"name": "Does KoalaSync stream the video to other participants?",
"acceptedAnswer": {
"@type": "Answer",
"text": "No. KoalaSync does not stream, upload, proxy, share, or bypass access to video content. Everyone watches locally in their own browser and needs their own access to the website or streaming service. KoalaSync only synchronizes playback actions such as play, pause, and seeking."
}
},
{
"@type": "Question",
"name": "Which devices and browsers support KoalaSync?",
"acceptedAnswer": {
"@type": "Answer",
"text": "KoalaSync is a desktop browser extension for Chrome, Firefox, Edge, and compatible Chromium-based browsers on Windows, macOS, Linux, and ChromeOS. It does not currently provide a mobile app or Safari extension. Website compatibility also depends on the site's video player."
}
},
{
"@type": "Question",
"name": "What data does KoalaSync send from the browser?",
"acceptedAnswer": {
"@type": "Answer",
"text": "While you are in a room, KoalaSync sends the room, peer, and playback data needed for live synchronization. Display names and, depending on privacy settings, tab or media titles may be shared with room participants. Chat text is encrypted in the extension before transmission. KoalaSync does not create a persistent chat, browsing, synchronization, or watch history."
}
},
{
"@type": "Question",
"name": "How can official KoalaSync releases be verified?",
"acceptedAnswer": {
"@type": "Answer",
"text": "The extension, relay server, build scripts, release workflow, and Dockerfile are public under the MIT License. Extension ZIPs on GitHub Releases and Docker images on GitHub Container Registry receive signed provenance attestations that bind their exact digest to this repository, a release tag and commit, and the public GitHub Actions workflow."
}
}
]
},
{
"@type": "BreadcrumbList",
"@id": "https://sync.koalastuff.net/help#breadcrumb",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "KoalaSync",
"item": "https://sync.koalastuff.net/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Help",
"item": "https://sync.koalastuff.net/help"
}
]
}
]
}
</script>
<script src="lang-init.min.js"></script>
</head>
<body class="support-page">
<div class="scroll-progress-bar"></div>
<a class="skip-link" href="#main-content">Skip to help topics</a>
<nav>
<div class="container nav-content">
<a class="logo-area" href="./" aria-label="KoalaSync home">
<img class="nav-logo-img" src="assets/NewLogoIcon_128.webp" srcset="assets/NewLogoIcon_64.webp 64w, assets/NewLogoIcon_128.webp 128w, assets/NewLogoIcon.webp 256w" sizes="64px" alt="KoalaSync logo" width="64" height="64">
<span>KoalaSync</span>
</a>
<div class="nav-links" id="primary-nav" role="navigation" aria-label="Primary">
<a href="./">Home</a>
<a href="privacy">Privacy</a>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener">GitHub</a>
</div>
<div class="nav-right">
<button class="theme-toggle" id="theme-toggle" type="button" aria-label="Toggle light or dark theme" title="Toggle light or dark theme">
<svg class="theme-icon-sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>
<svg class="theme-icon-moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18" aria-hidden="true"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>
</button>
<button class="hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="primary-nav">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
</div>
</div>
</nav>
<main id="main-content" class="support-content">
<header class="support-hero">
<span class="support-status">Help center</span>
<h1>KoalaSync Help & Troubleshooting</h1>
<p class="support-lead">Start with the problem or question you have. These guides cover invites, browser access, video detection, encrypted chat, playback synchronization, supported devices, privacy, and release verification.</p>
</header>
<section class="support-topic-index" aria-labelledby="start-title">
<h2 id="start-title" class="support-section-title">Choose a help topic</h2>
<div class="support-topic-grid">
<article class="support-card support-topic-group">
<header class="support-topic-header">
<h3>Get connected</h3>
<p>Join a room and give KoalaSync the access it needs.</p>
</header>
<div class="support-topic-links" role="navigation" aria-label="Get connected help">
<a href="#invite-problems">The invite opens, but nothing happens</a>
<a href="site-access-help">KoalaSync cannot access the video site</a>
<a href="#missing-tab">The video tab does not appear</a>
</div>
</article>
<article class="support-card support-topic-group">
<header class="support-topic-header">
<h3>Playback & chat</h3>
<p>Fix the selected player, room chat, or synchronization.</p>
</header>
<div class="support-topic-links" role="navigation" aria-label="Playback and chat help">
<a href="#video-detection">The tab appears, but KoalaSync finds no usable video</a>
<a href="#room-chat">Chat does not work, or one person cannot see it</a>
<a href="#sync-problems">Everyone is connected, but playback does not sync</a>
</div>
</article>
<article class="support-card support-topic-group">
<header class="support-topic-header">
<h3>Using KoalaSync</h3>
<p>Understand what the extension does and supports.</p>
</header>
<div class="support-topic-links" role="navigation" aria-label="Using KoalaSync help">
<a href="#video-access">Does KoalaSync send the video to my friends?</a>
<a href="#supported-devices">Which devices and browsers are supported?</a>
</div>
</article>
<article class="support-card support-topic-group">
<header class="support-topic-header">
<h3>Privacy & trust</h3>
<p>See what is shared and how official builds are verified.</p>
</header>
<div class="support-topic-links" role="navigation" aria-label="Privacy and trust help">
<a href="#permissions">Why does the browser show such a broad warning?</a>
<a href="#data-handling">What data leaves my browser?</a>
<a href="#trust">Why should I trust KoalaSync?</a>
</div>
</article>
</div>
</section>
<section id="invite-problems" class="support-card support-anchor-section" aria-labelledby="invite-problems-title">
<span class="support-eyebrow">Invite links</span>
<h2 id="invite-problems-title">The invite opens, but nothing happens</h2>
<ol class="support-steps">
<li>Open the invite in the same desktop browser profile where KoalaSync is installed. Extensions installed in another browser or profile cannot receive the join request.</li>
<li>If you installed, enabled, or updated KoalaSync after opening the invite, reload the invite page once so the extension can connect to it.</li>
<li>Confirm that KoalaSync is allowed to run on <code>sync.koalastuff.net</code>. If the browser blocks access, follow the <a class="support-link" href="site-access-help">site-access guide</a>, then reload the invite.</li>
<li>Ask the sender to open KoalaSync and copy the invite link again. Use the complete link without removing anything after <code>#</code>; that part contains the room credentials and current chat secret.</li>
<li>Wait for the join page to report success. Joining the room does not select a video automatically, so open KoalaSync afterward and choose your local video tab under <strong>Sync</strong>.</li>
</ol>
<div class="support-callout" role="note">
<span class="support-callout-icon" aria-hidden="true">i</span>
<div>
<strong>The extension must be detected on the invite page.</strong>
<p>If the page still offers an install button even though KoalaSync is installed, the usual causes are a different browser profile, blocked website access, or a page that was opened before the extension became available.</p>
</div>
</div>
</section>
<section id="missing-tab" class="support-card support-anchor-section" aria-labelledby="missing-tab-title">
<span class="support-eyebrow">Tab list</span>
<h2 id="missing-tab-title">The video tab does not appear</h2>
<ol class="support-steps">
<li>Keep the video page open and reload it once.</li>
<li>Close and reopen the KoalaSync popup, then check <strong>Select Video</strong> again.</li>
<li>Open KoalaSync <strong>Settings</strong> and turn off <strong>Hide Clutter Tabs</strong>.</li>
<li>Return to the tab selector. If the tab now appears, the site was filtered as clutter by mistake.</li>
<li>If the tab is still missing, verify website access using the <a class="support-link" href="site-access-help">site-access guide</a>.</li>
</ol>
<div class="support-callout" role="note">
<span class="support-callout-icon" aria-hidden="true">i</span>
<div>
<strong>Tell us which domain was classified incorrectly.</strong>
<p>For a false clutter match, report the domain, whether the tab appears with the filter disabled, and whether the page contains a visible HTML5 video. A domain such as <code>video.example.com</code> is usually enough; do not publish a private URL containing account or session details.</p>
</div>
</div>
</section>
<section id="video-detection" class="support-card support-anchor-section" aria-labelledby="video-detection-title">
<span class="support-eyebrow">Video detection</span>
<h2 id="video-detection-title">The tab appears, but KoalaSync finds no usable video</h2>
<ul>
<li>Select the exact tab containing the visible player, not a login, search, or media-library tab.</li>
<li>Start the video once. Some websites create their video element only after playback begins.</li>
<li>Open the KoalaSync <strong>Status</strong> tab and inspect <strong>Video Debug Info</strong>.</li>
<li>If the report says <strong>NO VIDEO ELEMENT</strong>, note the video count and whether Shadow DOM was detected.</li>
<li>Temporarily disable other extensions that modify the player, then reload and test again.</li>
<li>Protected, heavily customized, or non-HTML5 players may require site-specific support.</li>
</ul>
</section>
<section id="room-chat" class="support-card support-anchor-section" aria-labelledby="room-chat-title">
<span class="support-eyebrow">Room chat</span>
<h2 id="room-chat-title">Chat does not work, or one person cannot see it</h2>
<p>Room chat was introduced in KoalaSync 3.0.0. Every participant who wants to send or read chat messages needs version 3.0.0 or newer. Older versions can still join a room and synchronize playback, but they do not support room chat.</p>
<ol class="support-steps">
<li>Update KoalaSync in every browser. Open the extension and check the version in the top-right corner. Each participant needs version <strong>3.0.0 or newer</strong>.</li>
<li>After updating, the room creator should leave the old room and click <strong>Create New Room</strong>. This creates a new chat encryption secret and includes it in the new invite link.</li>
<li>Send that newly copied invite link to your friends. Each friend must leave the old room and join through the link so their extension receives the same chat encryption secret.</li>
<li>Do not join by typing only the room ID and password, and do not reuse an old invite link beginning with <code>#join:</code>. Both can connect playback synchronization, but neither contains the chat secret.</li>
<li>Each participant must select their local video tab, then open <strong>Settings → Room Chat</strong> and turn on <strong>Enable Room Chat</strong>. The chat appears on the selected video page, not inside the extension popup.</li>
</ol>
<div class="support-callout" role="note">
<span class="support-callout-icon" aria-hidden="true">i</span>
<div>
<strong>Chat is live only.</strong>
<p>There is no server-side message history. Someone who joins later sees only messages sent after they joined. Keep the invite link private: it contains the chat secret used to decrypt messages in that room.</p>
</div>
</div>
</section>
<section id="sync-problems" class="support-card support-anchor-section" aria-labelledby="sync-problems-title">
<span class="support-eyebrow">Synchronization</span>
<h2 id="sync-problems-title">Everyone is connected, but playback does not sync</h2>
<ol class="support-steps">
<li>Update KoalaSync in every browser and confirm everyone uses the same extension version.</li>
<li>Make sure each participant selected the correct local video tab.</li>
<li>Reload the video page after changing website access, then select the tab again.</li>
<li>Test play, pause, and seeking separately and note which action fails.</li>
<li>Before anyone leaves the room, collect a debug report from every affected participant.</li>
</ol>
</section>
<section id="video-access" class="support-card support-anchor-section" aria-labelledby="video-access-title">
<span class="support-eyebrow">Video access</span>
<h2 id="video-access-title">Does KoalaSync send the video to my friends?</h2>
<p>No. KoalaSync does not stream, upload, proxy, download, or redistribute video content. It only sends the live room and playback events needed to keep actions such as play, pause, and seeking synchronized.</p>
<ul>
<li>Everyone watches the video locally in their own browser on the original website.</li>
<li>Each participant needs their own valid access to the website or streaming service.</li>
<li>KoalaSync does not bypass subscriptions, logins, DRM, regional restrictions, or platform rules.</li>
<li>Subtitles, audio tracks, fullscreen, and video quality remain local choices when the video player supports them.</li>
</ul>
</section>
<section id="supported-devices" class="support-card support-anchor-section" aria-labelledby="supported-devices-title">
<span class="support-eyebrow">Compatibility</span>
<h2 id="supported-devices-title">Which devices and browsers are supported?</h2>
<p>KoalaSync is currently a desktop browser extension. It supports Google Chrome, Mozilla Firefox, Microsoft Edge, Opera, Brave, Vivaldi, and other compatible Chromium-based desktop browsers on Windows, macOS, Linux, and ChromeOS.</p>
<ul>
<li>There is currently no KoalaSync mobile app for phones or tablets.</li>
<li>There is currently no Safari extension.</li>
<li>Website compatibility depends on the browser and the site's video player. Websites can change their players, DRM behavior, or browser restrictions at any time.</li>
<li>Protected, customized, or non-HTML5 players may need site-specific support even in a supported browser.</li>
</ul>
</section>
<section id="permissions" class="support-card support-anchor-section" aria-labelledby="permissions-title">
<span class="support-eyebrow">Privacy & permissions</span>
<h2 id="permissions-title">Why does the browser show such a broad warning?</h2>
<p>Browsers may say that KoalaSync can “read and change all your data on all websites.” The wording is broad because KoalaSync is designed to work with video players across many different websites instead of a short fixed list.</p>
<p>KoalaSync injects its synchronization script into the video tab you actively select. On that tab it needs to find and control the video element, synchronize play, pause, and seeking, and display the optional room chat overlay. Open tab titles are read to populate the tab selector. KoalaSync does not create a general browsing or watch history.</p>
<p><a class="support-link" href="privacy#extension-permissions">Read the complete permission and data-handling explanation</a></p>
</section>
<section id="data-handling" class="support-card support-anchor-section" aria-labelledby="data-handling-title">
<span class="support-eyebrow">Privacy</span>
<h2 id="data-handling-title">What data leaves my browser?</h2>
<p>KoalaSync only connects to a relay while you are actively in a room. During that session, the relay temporarily processes the data required to connect participants and synchronize playback.</p>
<ul>
<li><strong>Room and presence:</strong> room ID, peer ID, display name, connection state, and the information needed to join the room.</li>
<li><strong>Playback synchronization:</strong> actions and state such as play, pause, seeking, and the current playback position.</li>
<li><strong>Titles:</strong> tab or media titles may be shared with room participants depending on your title privacy settings. They can be limited or disabled.</li>
<li><strong>Room chat:</strong> message text is encrypted in the extension before transmission. The relay receives ciphertext and delivery metadata, but not the chat secret or readable message text.</li>
<li><strong>Stored locally:</strong> preferences, current room credentials, and the chat secret are stored in the browser. The chat secret is not sent to the relay. Decrypted chat entries remain only in the current page overlay and are not written to browser storage.</li>
<li><strong>Operational logs:</strong> the relay may log technical connection and security information such as IP addresses, protocol versions, socket or peer identifiers, and shortened room identifiers. Retention depends on the server infrastructure and configuration.</li>
</ul>
<p>KoalaSync does not create a persistent browsing, room, synchronization, chat, or watch history. Browser or operating-system notifications may retain notification text according to their own settings.</p>
<p><a class="support-link" href="privacy">Read the complete privacy policy</a></p>
</section>
<section id="trust" class="support-card support-anchor-section" aria-labelledby="trust-title">
<span class="support-eyebrow">Trust & verification</span>
<h2 id="trust-title">Why should I trust KoalaSync?</h2>
<p>Trust should not depend on promises alone. The KoalaSync browser extension, relay server, build scripts, release workflow, and Dockerfile used for official GitHub releases are public under the MIT License.</p>
<p>Official extension ZIPs on GitHub Releases and Docker images on GitHub Container Registry are built by the public GitHub Actions release workflow. Signed SLSA provenance attestations connect each artifact's exact digest to this repository, its release tag and commit, and the workflow that built it.</p>
<p>You can install KoalaSync from an official browser store, download the Chrome or Firefox ZIP from GitHub Releases, build the extension yourself, or run your own relay server. Manual ZIP installation uses your browser's developer or manual extension installation process.</p>
<div class="support-actions support-actions-trust">
<a class="btn btn-secondary" href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener">View source code</a>
<a class="btn btn-secondary" href="https://github.com/Shik3i/KoalaSync/releases/latest" target="_blank" rel="noopener">Download releases</a>
<a class="btn btn-secondary" href="https://github.com/Shik3i/KoalaSync/blob/main/.github/workflows/release.yml" target="_blank" rel="noopener">View release workflow</a>
<a class="btn btn-secondary" href="./#self-hosting">Self-host the relay</a>
</div>
<details class="support-details">
<summary>Advanced: verify signed build provenance</summary>
<p>Install the GitHub CLI, download the release ZIP, and run:</p>
<pre><code>gh attestation verify koalasync-chrome.zip \
--repo Shik3i/KoalaSync</code></pre>
<p>To verify the current Docker image:</p>
<pre><code>gh attestation verify oci://ghcr.io/shik3i/koalasync:latest \
--repo Shik3i/KoalaSync</code></pre>
</details>
<div class="support-callout support-callout-warning" role="note">
<span class="support-callout-icon" aria-hidden="true">!</span>
<div>
<strong>What an attestation proves, and what it does not.</strong>
<p>An attestation proves the origin and build workflow of the GitHub release ZIP or GHCR image with that exact digest. It does not guarantee that software has no bugs or vulnerabilities. It also does not by itself prove that a package downloaded from a browser store is byte-for-byte identical to a GitHub release ZIP.</p>
</div>
</div>
</section>
<section id="report-issue" class="support-card support-anchor-section" aria-labelledby="report-issue-title">
<span class="support-eyebrow">Still not working?</span>
<h2 id="report-issue-title">Create a useful GitHub issue</h2>
<ol class="support-steps">
<li>Open KoalaSync, switch to <strong>Status</strong>, and click <strong>Copy Logs</strong> while the problem is still visible.</li>
<li>Open the KoalaSync GitHub bug-report form and sign in to GitHub if requested.</li>
<li>Describe the exact steps that reproduce the problem, what you expected, and what happened instead.</li>
<li>Add your browser, operating system, KoalaSync version, and the affected website or domain.</li>
<li>Paste the debug report, add a screenshot if useful, and review everything before publishing.</li>
</ol>
<div class="support-callout support-callout-warning" role="note">
<span class="support-callout-icon" aria-hidden="true">!</span>
<div>
<strong>GitHub issues are public.</strong>
<p>The copied report can include page titles and URLs, peer and room identifiers, participant names, and a custom server URL. Remove anything private. Never publish an invite link, room password, chat secret, account token, or a URL containing private session parameters.</p>
</div>
</div>
<div class="support-actions">
<a class="btn btn-primary" href="https://github.com/Shik3i/KoalaSync/issues/new?template=bug_report.md" target="_blank" rel="noopener">Create a bug report</a>
<a class="btn btn-secondary" href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" rel="noopener">Search existing issues</a>
</div>
</section>
<div class="support-actions">
<a class="btn btn-secondary" href="./">Back to KoalaSync</a>
<a class="btn btn-secondary" href="privacy">Privacy policy</a>
</div>
</main>
<footer>
<div class="container">
<p class="footer-ram">No chat or watch history. No tracking. Website access logs are deleted after 7 days.</p>
<p class="footer-copyright">&copy; 2026 KoalaSync. Open source under the MIT License.</p>
<div class="footer-links">
<a href="./">Home</a>
<a href="help">Help</a>
<a href="privacy">Privacy</a>
<a href="https://koalastuff.net/legal">Legal notice</a>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener">GitHub</a>
</div>
</div>
</footer>
<script src="app.min.js"></script>
</body>
</html>
+15 -244
View File
@@ -1,250 +1,21 @@
<!DOCTYPE html>
<html lang="de" class="lang-de">
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Impressum | KoalaSync</title>
<link rel="preload" href="../style.min.css" as="style">
<link rel="stylesheet" href="../style.min.css">
<link rel="icon" type="image/webp" href="../assets/NewLogoIcon_64.webp">
<link rel="canonical" href="https://sync.koalastuff.net/de/impressum">
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/imprint">
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/impressum">
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/imprint">
<meta name="description" content="Impressum (Legal Notice) von KoalaSync - Anbieterkennzeichnung und Kontaktinformationen für die Open-Source Video-Synchronisations-Erweiterung.">
<meta name="robots" content="index, follow">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Startseite", "item": "https://sync.koalastuff.net/de/" },
{ "@type": "ListItem", "position": 2, "name": "Impressum", "item": "https://sync.koalastuff.net/de/impressum" }
]
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebPage",
"name": "Impressum",
"url": "https://sync.koalastuff.net/de/impressum",
"description": "Impressum (Legal Notice) von KoalaSync - Anbieterkennzeichnung und Kontaktinformationen für die Open-Source Video-Synchronisations-Erweiterung.",
"inLanguage": "de",
"isPartOf": {
"@type": "WebSite",
"name": "KoalaSync",
"url": "https://sync.koalastuff.net/de/"
}
}
</script>
<!-- Mobile Browser Theme Styling -->
<meta name="theme-color" content="#10190e">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<script src="../lang-init.min.js"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Impressum | KoalaSync</title>
<meta name="description" content="Das KoalaSync-Impressum ist zum zentralen KoalaStuff-Impressum umgezogen.">
<meta name="robots" content="noindex, follow">
<link rel="canonical" href="https://koalastuff.net/legal">
<link rel="stylesheet" href="../style.min.css">
</head>
<body>
<div class="bg-nature" aria-hidden="true">
<div class="bg-dusk-tint"></div>
<div class="bg-depth-day">
<div class="bg-godrays">
<span class="godray godray-1"></span>
<span class="godray godray-2"></span>
<span class="godray godray-3"></span>
</div>
<div class="bg-canopy"></div>
<span class="canopy-arc canopy-arc-1"></span>
<span class="canopy-arc canopy-arc-2"></span>
<span class="canopy-arc canopy-arc-3"></span>
<span class="canopy-arc canopy-arc-4"></span>
</div>
<div id="bamboo-far">
<span class="bamboo-stalk far-1"></span>
<span class="bamboo-stalk far-2"></span>
<span class="bamboo-stalk far-3"></span>
<span class="bamboo-stalk far-4"></span>
<span class="bamboo-stalk far-5"></span>
</div>
<div id="bamboo-near">
<div class="bamboo-stalk near-1">
<i class="bamboo-node node-1"></i>
<i class="bamboo-node node-2"></i>
<i class="bamboo-node node-3"></i>
<i class="bamboo-leaf leaf-1"></i>
<i class="bamboo-leaf leaf-2"></i>
</div>
<div class="bamboo-stalk near-2">
<i class="bamboo-node node-1"></i>
<i class="bamboo-node node-2"></i>
<i class="bamboo-node node-3"></i>
<i class="bamboo-leaf leaf-1"></i>
</div>
<div class="bamboo-stalk near-3">
<i class="bamboo-node node-1"></i>
<i class="bamboo-node node-2"></i>
<i class="bamboo-node node-3"></i>
<i class="bamboo-leaf leaf-1"></i>
</div>
</div>
<span class="fall-leaf fall-leaf-1"><i></i></span>
<span class="fall-leaf fall-leaf-2"><i></i></span>
<span class="fall-leaf fall-leaf-3"><i></i></span>
<span class="fall-leaf fall-leaf-4"><i></i></span>
<span class="fall-leaf fall-leaf-5"><i></i></span>
<span class="fall-leaf fall-leaf-6"><i></i></span>
<span class="fall-leaf fall-leaf-7"><i></i></span>
<span class="fall-leaf fall-leaf-8"><i></i></span>
<span class="bg-grass bg-grass-1"></span>
<span class="bg-grass bg-grass-2"></span>
<span class="bg-grass bg-grass-3"></span>
<span class="bg-grass bg-grass-4"></span>
<div class="bg-depth-dusk">
<div class="bg-mist bg-mist-1"></div>
<div class="bg-mist bg-mist-2"></div>
<div class="bg-horizon"></div>
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
</div>
<div class="bg-light-sweep"></div>
<div class="bg-grain"></div>
<main class="legal-content">
<div class="legal-card">
<h1>Impressum umgezogen</h1>
<p>Das KoalaSync-Impressum ist jetzt unter der zentralen KoalaStuff-Adresse verfügbar.</p>
<p><a class="legal-inline-link" href="https://koalastuff.net/legal">Zentrales Impressum öffnen</a></p>
</div>
<nav>
<div class="container nav-content">
<a href="./" class="logo-area" style="text-decoration: none;">
<img src="../assets/NewLogoIcon_128.webp" alt="KoalaSync Logo" width="40" height="40">
<span>KoalaSync</span>
</a>
<div class="nav-links">
<a href="./" style="display: inline-flex; align-items: center; gap: 6px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
<span>Startseite</span>
</a>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 6px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" 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>
GitHub
</a>
</div>
<div class="nav-right">
<div class="lang-select-container">
<select class="lang-dropdown" aria-label="Select Language">
<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>
<option value="it">🇮🇹 Italiano</option>
<option value="pl">🇵🇱 Polski</option>
<option value="tr">🇹🇷 Türkçe</option>
<option value="nl">🇳🇱 Nederlands</option>
<option value="ja">🇯🇵 日本語</option>
<option value="ko">🇰🇷 한국어</option>
<option value="pt">🇵🇹 Português (Portugal)</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<button class="hamburger" aria-label="Menu" aria-expanded="false"></button>
</div>
</div>
</nav>
<main class="legal-content">
<div class="legal-card" data-reveal style="padding: 2rem;">
<div style="display: flex; justify-content: center;">
<img src="../assets/KoalaImprintl-180.webp" srcset="../assets/KoalaImprintl-180.webp 180w, ../assets/KoalaImprintl-360.webp 360w" sizes="180px" alt="Niedlicher Koala, der das Impressum repräsentiert" class="legal-mascot" width="180" height="180">
</div>
<h1>Impressum / Anbieterinformationen</h1>
<p style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
Kontakt & Projektinformationen
</p>
<section>
<h2>Betreiber & Kontakt</h2>
<p>Timo Schmidt</p>
<p style="margin-top: 0.5rem;">
E-Mail: <span class="email-reveal" data-user="koalasync" data-domain="koalastuff.net" style="color: var(--accent); cursor: pointer; text-decoration: underline;">[E-Mail anzeigen]</span>
</p>
<p style="margin-top: 0.75rem;">
<span class="legal-inline-label">🔒 Private Nachricht:</span>
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" class="legal-inline-link">@koalastuff auf Mastodon</a>
</p>
<p style="margin-top: 0.5rem;">
<span class="legal-inline-label">🐛 Fehlerberichte und Sicherheitsmeldungen:</span>
<a href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" rel="noopener" class="legal-inline-link">GitHub Issues</a>
</p>
</section>
<section>
<h2>Projektinformationen</h2>
<p>
KoalaSync ist eine kostenlose und quelloffene Browser-Erweiterung. Der Quellcode ist auf GitHub unter der MIT-Lizenz verfügbar.
</p>
<p style="margin-top: 0.5rem;">
Informationen zum Datenschutz, einschließlich Hosting, Access Logs, Relay-Verhalten und Betroffenenrechten, finden Sie in der <a href="datenschutz" class="legal-inline-link">Datenschutzerklärung</a>.
</p>
</section>
<section>
<h2>Haftung für Inhalte</h2>
<p>
Die Inhalte dieser Website wurden mit Sorgfalt erstellt. Ich kann jedoch nicht gewährleisten, dass alle Informationen jederzeit vollständig, richtig oder aktuell sind.
</p>
<p style="margin-top: 0.5rem;">
Als Diensteanbieter bin ich nach den allgemeinen Gesetzen für eigene Inhalte auf diesen Seiten verantwortlich.
</p>
</section>
<section>
<h2>Haftung für Links</h2>
<p>
Diese Website enthält Links zu externen Websites Dritter. Auf deren Inhalte habe ich keinen Einfluss. Für die Inhalte verlinkter Seiten ist der jeweilige Anbieter oder Betreiber verantwortlich.
</p>
<p style="margin-top: 0.5rem;">
Wenn mir rechtswidrige Inhalte auf verlinkten Seiten bekannt werden, entferne ich die entsprechenden Links.
</p>
</section>
<section>
<h2>Lizenz & Urheberrecht</h2>
<p>
KoalaSync ist freie und quelloffene Software. Sofern nicht anders angegeben, steht der Quellcode von KoalaSync unter der MIT-Lizenz.
</p>
<p style="margin-top: 0.5rem;">
Die MIT-Lizenz erlaubt Nutzung, Vervielfältigung, Bearbeitung, Verbreitung, Unterlizenzierung und Verkauf der Software, sofern der Copyright-Hinweis und der Lizenztext entsprechend den Lizenzbedingungen erhalten bleiben.
</p>
<p style="margin-top: 0.5rem;">
Projektname, Logos, Grafiken, Website-Texte und andere Nicht-Code-Assets können separaten Rechten unterliegen, sofern sie nicht ausdrücklich von der Repository-Lizenz umfasst sind.
</p>
</section>
</div>
</main>
<footer>
<div class="container">
<p>&copy; 2026 KoalaSync. Open Source unter der MIT-Lizenz.</p>
<p style="font-size: 0.8rem; margin-top: 0.5rem;">Keine Relay-Logs. Kein Tracking. Website-Access-Logs werden nach 7 Tagen gelöscht.</p>
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
<a href="impressum" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
<a href="datenschutz" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
<a href="alternatives">Vergleiche & Anleitungen</a>
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="14" height="14" aria-hidden="true" style="display: block;"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
Mastodon
</a>
</div>
</div>
</footer>
<script src="../app.min.js"></script>
</main>
</body>
</html>
+8 -20
View File
@@ -1,26 +1,14 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, follow">
<title>Redirecting...</title>
<script>
(function() {
var savedLang = localStorage.getItem('koala_lang');
var browserLang = (navigator.language || navigator.userLanguage || '').toLowerCase();
var isDe = (savedLang === 'de') || (!savedLang && browserLang.indexOf('de') === 0);
var path = window.location.pathname;
var hasHtml = path.endsWith('.html');
if (isDe) {
window.location.replace(hasHtml ? 'de/impressum.html' : 'de/impressum');
} else {
window.location.replace(hasHtml ? 'imprint.html' : 'imprint');
}
})();
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="0; url=https://koalastuff.net/legal">
<meta name="robots" content="noindex, follow">
<link rel="canonical" href="https://koalastuff.net/legal">
<title>Legal Notice | KoalaSync</title>
</head>
<body>
<p>Redirecting / Weiterleitung...</p>
<p><a href="https://koalastuff.net/legal">Open the central KoalaStuff legal notice</a></p>
</body>
</html>
+15 -244
View File
@@ -1,250 +1,21 @@
<!DOCTYPE html>
<html lang="en" class="lang-en">
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Legal Notice | KoalaSync</title>
<link rel="preload" href="style.min.css" as="style">
<link rel="stylesheet" href="style.min.css">
<link rel="icon" type="image/webp" href="assets/NewLogoIcon_64.webp">
<link rel="canonical" href="https://sync.koalastuff.net/imprint">
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/imprint">
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/impressum">
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/imprint">
<meta name="description" content="Legal Notice (Impressum) of KoalaSync - Contact information and operator details for the open-source video synchronization extension.">
<meta name="robots" content="index, follow">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://sync.koalastuff.net/" },
{ "@type": "ListItem", "position": 2, "name": "Legal Notice", "item": "https://sync.koalastuff.net/imprint" }
]
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebPage",
"name": "Legal Notice",
"url": "https://sync.koalastuff.net/imprint",
"description": "Legal Notice (Impressum) of KoalaSync - Contact information and operator details for the open-source video synchronization extension.",
"inLanguage": "en",
"isPartOf": {
"@type": "WebSite",
"name": "KoalaSync",
"url": "https://sync.koalastuff.net/"
}
}
</script>
<!-- Mobile Browser Theme Styling -->
<meta name="theme-color" content="#10190e">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<script src="lang-init.min.js"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Legal Notice | KoalaSync</title>
<meta name="description" content="The KoalaSync legal notice has moved to the central KoalaStuff legal notice.">
<meta name="robots" content="noindex, follow">
<link rel="canonical" href="https://koalastuff.net/legal">
<link rel="stylesheet" href="style.min.css">
</head>
<body>
<div class="bg-nature" aria-hidden="true">
<div class="bg-dusk-tint"></div>
<div class="bg-depth-day">
<div class="bg-godrays">
<span class="godray godray-1"></span>
<span class="godray godray-2"></span>
<span class="godray godray-3"></span>
</div>
<div class="bg-canopy"></div>
<span class="canopy-arc canopy-arc-1"></span>
<span class="canopy-arc canopy-arc-2"></span>
<span class="canopy-arc canopy-arc-3"></span>
<span class="canopy-arc canopy-arc-4"></span>
</div>
<div id="bamboo-far">
<span class="bamboo-stalk far-1"></span>
<span class="bamboo-stalk far-2"></span>
<span class="bamboo-stalk far-3"></span>
<span class="bamboo-stalk far-4"></span>
<span class="bamboo-stalk far-5"></span>
</div>
<div id="bamboo-near">
<div class="bamboo-stalk near-1">
<i class="bamboo-node node-1"></i>
<i class="bamboo-node node-2"></i>
<i class="bamboo-node node-3"></i>
<i class="bamboo-leaf leaf-1"></i>
<i class="bamboo-leaf leaf-2"></i>
</div>
<div class="bamboo-stalk near-2">
<i class="bamboo-node node-1"></i>
<i class="bamboo-node node-2"></i>
<i class="bamboo-node node-3"></i>
<i class="bamboo-leaf leaf-1"></i>
</div>
<div class="bamboo-stalk near-3">
<i class="bamboo-node node-1"></i>
<i class="bamboo-node node-2"></i>
<i class="bamboo-node node-3"></i>
<i class="bamboo-leaf leaf-1"></i>
</div>
</div>
<span class="fall-leaf fall-leaf-1"><i></i></span>
<span class="fall-leaf fall-leaf-2"><i></i></span>
<span class="fall-leaf fall-leaf-3"><i></i></span>
<span class="fall-leaf fall-leaf-4"><i></i></span>
<span class="fall-leaf fall-leaf-5"><i></i></span>
<span class="fall-leaf fall-leaf-6"><i></i></span>
<span class="fall-leaf fall-leaf-7"><i></i></span>
<span class="fall-leaf fall-leaf-8"><i></i></span>
<span class="bg-grass bg-grass-1"></span>
<span class="bg-grass bg-grass-2"></span>
<span class="bg-grass bg-grass-3"></span>
<span class="bg-grass bg-grass-4"></span>
<div class="bg-depth-dusk">
<div class="bg-mist bg-mist-1"></div>
<div class="bg-mist bg-mist-2"></div>
<div class="bg-horizon"></div>
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
</div>
<div class="bg-light-sweep"></div>
<div class="bg-grain"></div>
<main class="legal-content">
<div class="legal-card">
<h1>Legal notice moved</h1>
<p>The KoalaSync legal notice is now available at the central KoalaStuff address.</p>
<p><a class="legal-inline-link" href="https://koalastuff.net/legal">Open the central legal notice</a></p>
</div>
<nav>
<div class="container nav-content">
<a href="./" class="logo-area" style="text-decoration: none;">
<img src="assets/NewLogoIcon_128.webp" alt="KoalaSync Logo" width="40" height="40">
<span>KoalaSync</span>
</a>
<div class="nav-links">
<a href="./" style="display: inline-flex; align-items: center; gap: 6px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
<span>Home</span>
</a>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 6px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" 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>
GitHub
</a>
</div>
<div class="nav-right">
<div class="lang-select-container">
<select class="lang-dropdown" aria-label="Select Language">
<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>
<option value="it">🇮🇹 Italiano</option>
<option value="pl">🇵🇱 Polski</option>
<option value="tr">🇹🇷 Türkçe</option>
<option value="nl">🇳🇱 Nederlands</option>
<option value="ja">🇯🇵 日本語</option>
<option value="ko">🇰🇷 한국어</option>
<option value="pt">🇵🇹 Português (Portugal)</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<button class="hamburger" aria-label="Menu" aria-expanded="false"></button>
</div>
</div>
</nav>
<main class="legal-content">
<div class="legal-card" data-reveal style="padding: 2rem;">
<div style="display: flex; justify-content: center;">
<img src="assets/KoalaImprintl-180.webp" srcset="assets/KoalaImprintl-180.webp 180w, assets/KoalaImprintl-360.webp 360w" sizes="180px" alt="Cute Koala representing the legal notice page" class="legal-mascot" width="180" height="180">
</div>
<h1>Legal Notice</h1>
<p style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
Contact & Project Information
</p>
<section>
<h2>Operator & Contact</h2>
<p>Timo Schmidt</p>
<p style="margin-top: 0.5rem;">
E-Mail: <span class="email-reveal" data-user="koalasync" data-domain="koalastuff.net" style="color: var(--accent); cursor: pointer; text-decoration: underline;">[Show Email]</span>
</p>
<p style="margin-top: 0.75rem;">
<span class="legal-inline-label">🔒 Private message:</span>
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" class="legal-inline-link">@koalastuff on Mastodon</a>
</p>
<p style="margin-top: 0.5rem;">
<span class="legal-inline-label">🐛 Bug reports and security concerns:</span>
<a href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" rel="noopener" class="legal-inline-link">GitHub Issues</a>
</p>
</section>
<section>
<h2>Project Information</h2>
<p>
KoalaSync is a free and open-source browser extension. The source code is available on GitHub under the MIT License.
</p>
<p style="margin-top: 0.5rem;">
For privacy-related information, including hosting, access logs, relay behavior, and data subject rights, please see the <a href="privacy" class="legal-inline-link">Privacy Policy</a>.
</p>
</section>
<section>
<h2>Liability for Content</h2>
<p>
The contents of this website were created with care. However, I cannot guarantee that all information is always complete, accurate, or up to date.
</p>
<p style="margin-top: 0.5rem;">
As a service provider, I am responsible for my own content on these pages in accordance with the general laws.
</p>
</section>
<section>
<h2>Liability for Links</h2>
<p>
This website contains links to external third-party websites. I have no influence over the content of those websites. The respective provider or operator is responsible for the content of linked pages.
</p>
<p style="margin-top: 0.5rem;">
If I become aware of unlawful content on linked pages, I will remove the corresponding links.
</p>
</section>
<section>
<h2>License & Copyright</h2>
<p>
KoalaSync is free and open-source software. Unless otherwise stated, the KoalaSync source code is licensed under the MIT License.
</p>
<p style="margin-top: 0.5rem;">
The MIT License allows use, copying, modification, distribution, sublicensing, and sale of the software, provided that the copyright notice and license text are included where required by the license.
</p>
<p style="margin-top: 0.5rem;">
Project name, logos, graphics, website text, and other non-code assets may be subject to separate rights unless they are explicitly included under the repository license.
</p>
</section>
</div>
</main>
<footer>
<div class="container">
<p>&copy; 2026 KoalaSync. Open source under the MIT License.</p>
<p style="font-size: 0.8rem; margin-top: 0.5rem;">No relay logs. No tracking. Website access logs are deleted after 7 days.</p>
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
<a href="imprint" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
<a href="privacy" style="color: var(--text-muted); text-decoration: none;">Privacy Policy</a>
<a href="alternatives">Guides & comparisons</a>
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="14" height="14" aria-hidden="true" style="display: block;"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
Mastodon
</a>
</div>
</div>
</footer>
<script src="app.min.js"></script>
</main>
</body>
</html>
+4 -3
View File
@@ -183,11 +183,11 @@
<footer>
<div class="container">
<p>&copy; 2026 KoalaSync. <span lang="en">Open source under the MIT License.</span><span lang="de">Open Source unter der MIT-Lizenz.</span></p>
<p style="font-size: 0.8rem; margin-top: 0.5rem;"><span lang="en">No relay logs. No tracking. Website access logs are deleted after 7 days.</span><span lang="de">Keine Relay-Logs. Kein Tracking. Website-Access-Logs werden nach 7 Tagen gelöscht.</span></p>
<p style="font-size: 0.8rem; margin-top: 0.5rem;"><span lang="en">No chat or watch history. No tracking. Website access logs are deleted after 7 days.</span><span lang="de">Keine Chat- oder Wiedergabehistorie. Kein Tracking. Website-Access-Logs werden nach 7 Tagen gelöscht.</span></p>
<p class="footer-disclaimer" style="margin-top: 1.2rem;"><span lang="en">KoalaSync is not affiliated with, endorsed by, or associated with Netflix, Disney+, Amazon, YouTube, Twitch, or any other streaming platform. All trademarks are property of their respective owners.</span><span lang="de">KoalaSync ist nicht mit Netflix, Disney+, Amazon, YouTube, Twitch oder anderen Streaming-Plattformen verbunden, wird von diesen nicht unterstützt oder steht in keiner Beziehung zu diesen. Alle Markenrechte liegen bei ihren jeweiligen Inhabern.</span></p>
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
<a href="imprint" lang="en" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
<a href="de/impressum" lang="de" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
<a href="https://koalastuff.net/legal" lang="en" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
<a href="https://koalastuff.net/legal" lang="de" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
<a href="privacy" lang="en" style="color: var(--text-muted); text-decoration: none;">Privacy Policy</a>
<a href="de/datenschutz" lang="de" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
<a href="alternatives"><span lang="en">Guides & comparisons</span><span lang="de">Vergleiche & Anleitungen</span></a>
@@ -203,6 +203,7 @@
</div>
</footer>
<script src="invite-links.min.js"></script>
<script src="app.min.js"></script>
</body>
</html>
+12 -3
View File
@@ -19,7 +19,7 @@ KoalaSync is not a fit when users need:
- video or screen sharing to people who do not have access to the content
- a hosted video library, remote browser or cloud-streaming service
- built-in text, voice or webcam chat
- built-in voice, video or webcam chat
- a mobile app or Safari extension
- guaranteed compatibility with a player that does not expose an accessible HTML5 video element
@@ -76,9 +76,18 @@ Compatibility depends on each website's player implementation and can change whe
- [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/koalasync/): Firefox
- [GitHub Releases](https://github.com/Shik3i/KoalaSync/releases): Release notes and manual packages
## Help and troubleshooting
- [Help Center](https://sync.koalastuff.net/help): Browser permissions, missing tabs, video detection, synchronization checks and safe bug reports
- [Website access guide](https://sync.koalastuff.net/site-access-help): Restore access when Chrome, Edge, Brave, Opera or Vivaldi blocks KoalaSync on a video site
- If a video tab is missing, disable `Hide Clutter Tabs` temporarily. If the tab then appears, report the affected domain so the filter can be corrected.
- If a listed tab has no detected video, start playback once and inspect `Status` → `Video Debug Info`.
- GitHub issues are public. Remove private page URLs, titles, room or peer identifiers, participant names, custom server URLs, credentials and session parameters before publishing logs.
- [GitHub bug report](https://github.com/Shik3i/KoalaSync/issues/new?template=bug_report.md): Include reproducible steps, browser, operating system, KoalaSync version and sanitized debug output
## Technical information
- Current website release: 2.5.4
- Current website release: 3.0.1
- License: MIT
- Extension runtime: dependency-free browser extension code
- Relay: Node.js with Socket.IO-compatible WebSocket messaging
@@ -112,6 +121,6 @@ Compatibility depends on each website's player implementation and can change whe
- [Main website](https://sync.koalastuff.net/)
- [GitHub issues](https://github.com/Shik3i/KoalaSync/issues): Bugs, compatibility reports and feature requests
- [Mastodon](https://mastodon.social/@koalastuff): Private contact and project updates
- [Legal notice](https://sync.koalastuff.net/imprint)
- [Legal notice](https://koalastuff.net/legal)
KoalaSync is an independent project and is not affiliated with, endorsed by or associated with Netflix, Disney+, Amazon, YouTube, Twitch or any other streaming platform. All trademarks belong to their respective owners.
+52 -48
View File
@@ -14,11 +14,11 @@
"TWITTER_DESCRIPTION": "Starte eine Watch Party auf Netflix, Emby, Jellyfin, YouTube, Twitch und fast jedem HTML5-Video. Open Source, datenschutzfreundlich und ohne Konto.",
"NAV_FEATURES": "Funktionen",
"NAV_HOW_IT_WORKS": "So funktioniert's",
"SUPPORT_BANNER_TEXT": "KoalaSync kann nicht auf deine Videoseite zugreifen?",
"SUPPORT_BANNER_CTA": "Websitezugriff reparieren",
"SUPPORT_BANNER_TEXT": "Brauchst du Hilfe mit KoalaSync?",
"SUPPORT_BANNER_CTA": "Help Center öffnen",
"SUPPORT_BANNER_DISMISS": "Hinweis schließen",
"HERO_TITLE": "Mach jedes Video zur <span class='hero-highlight'>Watch Party</span>.",
"HERO_SUBTITLE": "Dein Kino-Abend auf Distanz, ohne Lags. Kein Account, kein Tracking, keine Werbung. Einfach Link teilen und gemeinsam Play drücken.",
"HERO_SUBTITLE": "Schaut Videos gemeinsam aus der Ferne. Kein Konto, kein Tracking, keine Werbung. Link teilen und zusammen starten.",
"HERO_MASCOT_ALT": "Ein niedlicher Koala steht da und schaut nach unten auf die Download-Buttons",
"DEMO_SYNC": "Synchron",
"DEMO_HINT": "Live-Demo: Drück Play in einem der Tabs, KoalaSync hält beide synchron.",
@@ -42,7 +42,7 @@
"USE_CASE_1_DESC": "Synchronisiere eure Filme in Echtzeit und quatscht nebenbei auf Discord, Zoom oder im Tool eurer Wahl. Es fühlt sich an, als säßet ihr im selben Raum.",
"USE_CASE_2_ALT": "Ein Koala-Professor hält einen Vortrag auf einem Bildschirm vor zwei remote zugeschalteten Schüler-Koalas",
"USE_CASE_2_TITLE": "Gemeinsam Lernen",
"USE_CASE_2_DESC": "Analysiert Online-Tutorials, Vorlesungen oder Streams gemeinsam mit Mitschülern oder Kollegen. Pausiert und besprecht komplizierte Schritte in perfektem Sync.",
"USE_CASE_2_DESC": "Analysiert Tutorials, Vorlesungen oder Schulungsvideos gemeinsam. Pausiert und besprecht komplexe Schritte in der Gruppe.",
"USE_CASE_3_ALT": "Ein niedliches Koala-Pärchen schaut remote zusammen; einer sitzt am PC und sie liegt mit dem Laptop im Bett, während Herzen zwischen ihnen fliegen",
"USE_CASE_3_TITLE": "Fernbeziehungen",
"USE_CASE_3_DESC": "Überbrückt die Distanz und genießt gemeinsame Date-Nächte. Erlebt jeden Plot-Twist, lacht über dieselben Witze und teilt emotionale Momente zur selben Millisekunde.",
@@ -115,8 +115,8 @@
"STEP_2_ILLUS_CREATE": "+ Neuer Raum",
"STEP_2_ILLUS_MANUAL": "Manuell verbinden / Erweitert",
"STEP_2_ILLUS_COPIED": "Einladungslink kopiert!",
"STEP_3_TITLE": "Synchron genießen",
"STEP_3_DESC": "Drücke auf Wiedergabe. Egal wer pausiert, spult oder startet: Alles läuft bei allen Beteiligten absolut zeitgleich und verzögerungsfrei ab.",
"STEP_3_TITLE": "Gemeinsam schauen",
"STEP_3_DESC": "Starte die Wiedergabe. Änderungen werden mit allen im Raum geteilt; Netzwerk und Videoplayer können eine kurze Verzögerung verursachen.",
"STEP_3_ILLUS_IN_SYNC": "SYNCHRON",
"SELF_TITLE": "Für Self-Hoster",
"SELF_SUBTITLE": "Du traust dem Server nicht? Behalte die volle Datenhoheit. Richte deinen eigenen privaten Relay-Server in wenigen Minuten ein.",
@@ -127,33 +127,33 @@
"BOTTOM_SUBTITLE": "KoalaSync ist zu 100 % Open Source, werbefrei und trackingsicher. Überprüfe unseren Code auf GitHub oder installiere direkt die Browser-Erweiterung.",
"BOTTOM_MASCOT_ALT": "Ein niedlicher Koala hält eine GitHub-Projektseite zusammen mit dem GitHub Maskottchen Octocat",
"FAQ_TITLE": "Häufig gestellte Fragen",
"FAQ_SUBTITLE": "Alles, was du über synchrones Videoschauen mit Freunden wissen musst.",
"FAQ_SUBTITLE": "Antworten zu unterstützten Websites, Synchronisierung, Datenschutz, Chat und Audioverarbeitung.",
"FAQ_Q1": "Kann ich Netflix, Emby oder Jellyfin synchron mit Freunden schauen?",
"FAQ_A1": "Ja! KoalaSync funktioniert mit Netflix, YouTube, Twitch und jeder Website mit einem HTML5-Videoplayer, einschließlich Emby, Jellyfin und eigenen Medienservern. Installiere die Erweiterung, erstelle einen Raum, teile den Einladungslink und alle schauen perfekt synchron.",
"FAQ_A1": "KoalaSync funktioniert mit vielen unterstützten Websites, die einen normalen Browser-Videoplayer verwenden, darunter Netflix, YouTube, Twitch, Emby, Jellyfin und selbst gehostete Medienserver. Die Kompatibilität hängt von Website, Browser und Player ab und kann sich durch Website-Updates ändern.",
"FAQ_Q_VIDEO_ACCESS": "Streamt KoalaSync das Video an meine Freunde?",
"FAQ_A_VIDEO_ACCESS": "Nein. KoalaSync streamt, hostet oder teilt keine Videoinhalte und umgeht keinen Zugang. Jede Person schaut das Video lokal im eigenen Browser und braucht selbst Zugriff auf die jeweilige Website oder den Streamingdienst, zum Beispiel ein eigenes Netflix-Konto. KoalaSync synchronisiert nur Aktionen wie Play, Pause und Spulen.",
"FAQ_Q2": "Ist KoalaSync eine kostenlose Alternative zu Teleparty?",
"FAQ_A2": "100 % kostenlos und Open Source unter der MIT-Lizenz. Keine Premium-Stufen, keine gesperrten Funktionen, keine Werbung. Anders als Teleparty läuft KoalaSyncs Relay-Server ausschließlich im RAM, ohne Datensammlung und ohne Benutzerkonten.",
"FAQ_A2": "Ja. KoalaSync ist unter der MIT-Lizenz kostenlos und Open Source ohne Premium-Stufe, gesperrte Funktionen, Werbung oder erforderliches Benutzerkonto.",
"FAQ_Q3": "Funktioniert KoalaSync mit Emby, Jellyfin und eigenen Medienservern?",
"FAQ_A3": "Ja. KoalaSync erkennt jedes HTML5-Videoelement und funktioniert perfekt mit Emby, Jellyfin, Plex und beliebigen eigenen Streaming-Setups. Du kannst den KoalaSync Relay-Server auch via Docker selbst hosten.",
"FAQ_A3": "Ja. KoalaSync unterstützt viele normale Browser-Videoplayer von Emby, Jellyfin, Plex und eigenen Streaming-Setups. Die Kompatibilität hängt vom jeweiligen Player ab. Den KoalaSync-Relay-Server kannst du außerdem mit Docker selbst hosten.",
"FAQ_Q4": "Welche Browser und Plattformen unterstützen KoalaSync?",
"FAQ_A4": "Google Chrome, Mozilla Firefox, Microsoft Edge, Opera, Brave und alle Chromium-basierten Browser. Direkt aus dem Chrome Web Store oder den Firefox Add-ons installieren, keine manuelle Konfiguration nötig.",
"FAQ_Q5": "Müssen alle meine Freunde die Erweiterung installieren?",
"FAQ_A5": "Ja, jeder Teilnehmer braucht KoalaSync, aber ohne Anmeldung oder Kontoerstellung. Einfach auf den Einladungslink klicken, die Erweiterung installieren (falls nicht vorhanden) und du bist automatisch im Raum.",
"FAQ_Q6": "Sind meine Daten sicher? Was, wenn ich dem offiziellen Server nicht traue?",
"FAQ_A6": "Absolut sicher. Der KoalaSync Relay-Server läuft ausschließlich im RAM, alle Sitzungsdaten werden sofort gelöscht, sobald du den Raum verlässt. Keine Datenbank, keine Protokolle, keine Analysen, keine Benutzerkonten. Wenn du volle Kontrolle möchtest: hoste deinen eigenen privaten Relay-Server via Docker in wenigen Minuten. Der gesamte Quellcode ist Open Source und auf GitHub einsehbar.",
"FAQ_A6": "Der offizielle KoalaSync-Relay speichert Raum-, Sync-, Wiedergabe- und Chatverläufe nicht in einer Datenbank. Aktive Raumdaten liegen nur vorübergehend im Arbeitsspeicher und werden gelöscht, wenn der Raum leer ist oder wegen Inaktivität abläuft. Für Sicherheit und Missbrauchsschutz können technische Verbindungsprotokolle entstehen; deren Aufbewahrung hängt von der Serverkonfiguration ab. Chatnachrichten sind Ende-zu-Ende-verschlüsselt und werden nicht als serverseitiger Verlauf gespeichert. Den Relay kannst du über Docker auch selbst hosten.",
"FAQ_Q7": "Kann ich leise Dialoge und laute Explosionen beim Videoschauen im Browser ausgleichen?",
"FAQ_A7": "Ja. KoalaSync 2.2.0 enthält einen eingebauten Audio Compressor, der den Unterschied zwischen lauten und leisen Passagen reduziert. Er läuft lokal über die Web Audio API, keine Audiodaten verlassen deinen Computer. Öffne das Popup, erstelle einen Raum, gehe zu Einstellungen → Audio Processing, schalte den Compressor ein und wähle ein Preset. Funktioniert auf Netflix, Prime Video, Disney+, YouTube und allen HTML5-Video-Seiten.",
"FAQ_A7": "Ja. KoalaSync enthält einen Audio-Kompressor, der den Unterschied zwischen leisen und lauten Passagen reduziert. Er läuft lokal über die Web Audio API; Audiodaten werden nicht an den Relay gesendet. Öffne das Popup, erstelle einen Raum oder tritt einem bei und aktiviere ihn unter Einstellungen → Audioverarbeitung.",
"HOWTO_TITLE": "So synchronisierst du Videos mit Freunden mit KoalaSync",
"HOWTO_DESC": "Schaue Videos perfekt synchron mit Freunden auf YouTube, Netflix, Twitch und mehr. Keine Anmeldung, kein Tracking.",
"HOWTO_DESC": "Schaut unterstützte Videos gemeinsam auf YouTube, Netflix, Twitch und weiteren Seiten. Ohne Anmeldung oder Tracking.",
"HOWTO_STEP_1_NAME": "Browser-Erweiterung installieren",
"HOWTO_STEP_1_TEXT": "Füge KoalaSync aus dem Chrome Web Store oder den Firefox Add-ons zu deinem Browser hinzu. Funktioniert auf jeder Website mit einem Videoelement.",
"HOWTO_STEP_1_TEXT": "Installiere KoalaSync aus dem Chrome Web Store oder über Firefox Add-ons. Öffne eine unterstützte Seite und wähle ein von der Erweiterung erkanntes Video.",
"HOWTO_STEP_2_NAME": "Raum erstellen",
"HOWTO_STEP_2_TEXT": "Öffne das Erweiterungs-Popup und klicke auf Neuen Raum erstellen. Der Einladungslink wird automatisch in deine Zwischenablage kopiert.",
"HOWTO_STEP_3_NAME": "Teilen und synchronisieren",
"HOWTO_STEP_3_TEXT": "Sende den Einladungslink an deine Freunde. Wähle einen Video-Tab aus und genieße synchronisierte Wiedergabe in Echtzeit.",
"FOOTER_MIT": "Open Source unter der MIT-Lizenz.",
"FOOTER_RAM": "Keine Relay-Logs. Kein Tracking. Website-Access-Logs werden nach 7 Tagen gelöscht.",
"FOOTER_RAM": "Keine Chat- oder Wiedergabehistorie. Kein Tracking. Website-Access-Logs werden nach 7 Tagen gelöscht.",
"FOOTER_LEGAL": "Impressum",
"FOOTER_PRIVACY": "Datenschutz",
"MOCK_01": "Aktiver Raum",
@@ -191,7 +191,7 @@
"FOOTER_LEGAL_LINK": "de/impressum",
"FOOTER_PRIVACY_LINK": "de/datenschutz",
"FOOTER_DISCLAIMER": "KoalaSync ist nicht mit Netflix, Disney+, Amazon, YouTube, Twitch oder anderen Streaming-Plattformen verbunden, wird von diesen nicht unterstützt oder steht in keiner Beziehung zu diesen. Alle Markenrechte liegen bei ihren jeweiligen Inhabern.",
"FOOTER_SUPPORT": "Support KoalaSync",
"FOOTER_SUPPORT": "KoalaSync unterstützen",
"ALT_INDEX_TITLE": "KoalaSync - Vergleiche & Anleitungen",
"ALT_INDEX_META_DESC": "Entdecke ehrliche Vergleiche und detaillierte Leitfäden zwischen KoalaSync und anderen Watch-Party-Erweiterungen.",
"ALT_INDEX_HEADING": "Vergleiche & Anleitungen",
@@ -201,17 +201,17 @@
"ALT_INDEX_FOOTER_LINK": "Vergleiche & Anleitungen",
"ALT_TELEPARTY_TITLE": "KoalaSync vs Teleparty: Welche Watch-Party-Lösung passt besser?",
"ALT_TELEPARTY_META_DESC": "Ein ehrlicher Vergleich von KoalaSync und Teleparty für Watch-Partys, eingebauten Chat, Mobile-Support, Browser-Video-Sync, selbst gehostete Medienserver und Open Source.",
"ALT_TELEPARTY_INTRO": "KoalaSync und Teleparty lösen beide dasselbe nervige Problem: Du willst mit anderen Leuten online etwas schauen, ohne ständig „3, 2, 1, Play“ sagen zu müssen. Beide synchronisieren Videowiedergabe, aber sie gehen ziemlich unterschiedlich an die Sache ran.<br><br>Teleparty ist sinnvoll, wenn du einen eingebauten Chat möchtest oder am Handy schauen willst. KoalaSync ist eher für Desktop-Browser-Watch-Partys gedacht, bei denen du sowieso schon mit Freunden in Discord, Teamspeak oder einem anderen Voice-Call bist und einfach nur die Wiedergabe synchron halten möchtest.<br><br>Der Hauptgrund, warum ich KoalaSync gebaut habe, war Flexibilität. Es soll mit fast jeder Website funktionieren, die einen normalen Browser-Videoplayer nutzt, nicht nur mit einer festen Liste an Plattformen. Dazu gehören auch selbst gehostete Setups wie Emby, Jellyfin und Plex, lokale oder private Domains und viele normale Videoseiten im Browser.",
"ALT_TELEPARTY_INTRO": "KoalaSync und Teleparty lösen beide dasselbe nervige Problem: Du willst mit anderen Leuten online etwas schauen, ohne ständig „3, 2, 1, Play“ sagen zu müssen. Beide bieten Text-Chat, gehen die Watch-Party aber unterschiedlich an.<br><br>Teleparty ist sinnvoll, wenn du ein stärker integriertes Watch-Party-Erlebnis bevorzugst oder am Handy schauen willst. KoalaSync ist für Desktop-Browser-Watch-Partys mit einer größeren Auswahl normaler Browser-Videoplayer gedacht. Der eingebaute Text-Chat ist Ende-zu-Ende-verschlüsselt und nur live verfügbar; Sprach- und Videoanrufe bleiben getrennt.<br><br>Der Hauptgrund, warum ich KoalaSync gebaut habe, war Flexibilität. Es soll mit fast jeder Website funktionieren, die einen normalen Browser-Videoplayer nutzt, nicht nur mit einer festen Liste an Plattformen. Dazu gehören auch selbst gehostete Setups wie Emby, Jellyfin und Plex, lokale oder private Domains und viele normale Videoseiten im Browser.",
"ALT_TELEPARTY_WHEN_TELE_TITLE": "Wann Teleparty trotzdem sinnvoller sein kann",
"ALT_TELEPARTY_WHEN_TELE_BODY": "Teleparty kann trotzdem die bessere Wahl sein, wenn du den Chat direkt im Watch-Party-Tool haben möchtest. KoalaSync hat aktuell keinen eingebauten Chat. Es geht eher davon aus, dass du sowieso schon woanders mit deinen Freunden redest, zum Beispiel in Discord, Teamspeak, WhatsApp, Signal oder einem normalen Anruf.<br><br>Mobile ist der zweite große Unterschied. Teleparty hat eine App, während KoalaSync aktuell nur auf Desktop-Browsern läuft. Wenn du Netflix oder andere Inhalte am Handy oder Tablet synchron schauen möchtest, ist KoalaSync im Moment nicht das richtige Tool dafür.<br><br>Wenn dir also vor allem eingebauter Chat und Mobile-Support wichtig sind, passt Teleparty wahrscheinlich besser zu deinem Setup.",
"ALT_TELEPARTY_WHEN_TELE_BODY": "Teleparty kann trotzdem die bessere Wahl sein, wenn du das stärker integrierte Watch-Party-Erlebnis bevorzugst oder Mobile-Support brauchst. Beide Tools bieten Text-Chat, daher ist der Chat allein nicht mehr der entscheidende Unterschied. Der KoalaSync-Chat ist Ende-zu-Ende-verschlüsselt und nur live verfügbar; Sprach- und Videoanrufe laufen weiterhin über eine separate App.<br><br>Mobile ist der zweite große Unterschied. Teleparty hat eine App, während KoalaSync aktuell nur auf Desktop-Browsern läuft. Wenn du Netflix oder andere Inhalte am Handy oder Tablet synchron schauen möchtest, ist KoalaSync im Moment nicht das richtige Tool dafür.<br><br>Wenn dir also vor allem Mobile-Support und eine stärker integrierte Plattform wichtig sind, passt Teleparty wahrscheinlich besser zu deinem Setup.",
"ALT_TELEPARTY_WHEN_KOALA_TITLE": "Wann KoalaSync besser passt",
"ALT_TELEPARTY_WHEN_KOALA_BODY": "KoalaSync passt besser, wenn du ein flexibles Sync-Tool für den Desktop-Browser suchst und nicht auf eine kurze Plattformliste beschränkt sein möchtest.<br><br>Es funktioniert mit fast jeder Website, die einen normalen Browser-Videoplayer nutzt. Das ist besonders nützlich für Emby, Jellyfin, Plex, private Medienserver, lokale Domains oder kleinere Videoseiten, die klassische Watch-Party-Tools meistens gar nicht beachten.<br><br>Außerdem ist KoalaSync kostenlos, Open Source, braucht keine Accounts und kann bei Bedarf selbst gehostet werden. Ich habe es gebaut, weil ich etwas Privacy-freundliches wollte, das mit meinem eigenen Emby-Setup funktioniert, nicht noch eine geschlossene Plattform nur für ein paar große Streaming-Seiten.",
"ALT_TELEPARTY_MAIN_DIFF_TITLE": "Der wichtigste Unterschied",
"ALT_TELEPARTY_MAIN_DIFF_BODY": "Teleparty ist eher ein Watch-Party-Produkt mit Chat und Mobile-Unterstützung. KoalaSync ist eher eine flexible Sync-Schicht für Browser-Video.<br><br>Das klingt erstmal nach einem kleinen Unterschied, ändert aber den Use Case ziemlich stark. Wenn du eine Watch-Party-App mit eingebautem Chat brauchst, ergibt Teleparty Sinn. Wenn du schon einen Voice-Call hast und nur möchtest, dass Play, Pause und Seek zwischen mehreren Browsern synchron bleiben, passt KoalaSync besser.",
"ALT_TELEPARTY_MAIN_DIFF_BODY": "Teleparty ist eher ein integriertes Watch-Party-Produkt mit Mobile-Unterstützung. KoalaSync ist eine flexible Sync-Schicht für Desktop-Browser mit Ende-zu-Ende-verschlüsseltem Live-Text-Chat.<br><br>Das klingt erstmal nach einem kleinen Unterschied, ändert aber den Use Case ziemlich stark. Wenn du eine mobile Watch-Party-App brauchst, ergibt Teleparty Sinn. Wenn du breite Browser-Player-Unterstützung einschließlich selbst gehosteter Medien möchtest, ohne dem Relay lesbare Chatnachrichten zu übermitteln, passt KoalaSync besser.",
"ALT_TELEPARTY_LIMITS_TITLE": "Ehrliche Grenzen",
"ALT_TELEPARTY_LIMITS_BODY": "KoalaSync ist nicht magisch und versucht auch nicht, seine Grenzen zu verstecken. Es funktioniert aktuell nur auf Desktop-Browsern. Es gibt keinen eingebauten Chat und keine Mobile-App.<br><br>Die Kompatibilität hängt außerdem vom Browser und vom jeweiligen Videoplayer ab. KoalaSync sollte mit fast jedem normalen Browser-Videoplayer funktionieren, aber Websites können ihre Player, DRM-Logik oder Browser-Einschränkungen jederzeit ändern.<br><br>KoalaSync ist nicht mit Netflix, Disney+, Prime Video, YouTube, Teleparty, Emby, Jellyfin, Plex oder anderen hier genannten Marken verbunden. Die Namen werden nur verwendet, um typische Anwendungsfälle verständlich zu beschreiben.",
"ALT_TELEPARTY_LIMITS_BODY": "KoalaSync ist nicht magisch und versucht auch nicht, seine Grenzen zu verstecken. Es funktioniert aktuell nur auf Desktop-Browsern. Es bietet Ende-zu-Ende-verschlüsselten Text-Chat, aber keinen Sprach-, Video- oder Webcam-Chat und keine Mobile-App.<br><br>Die Kompatibilität hängt außerdem vom Browser und vom jeweiligen Videoplayer ab. KoalaSync sollte mit fast jedem normalen Browser-Videoplayer funktionieren, aber Websites können ihre Player, DRM-Logik oder Browser-Einschränkungen jederzeit ändern.<br><br>KoalaSync ist nicht mit Netflix, Disney+, Prime Video, YouTube, Teleparty, Emby, Jellyfin, Plex oder anderen hier genannten Marken verbunden. Die Namen werden nur verwendet, um typische Anwendungsfälle verständlich zu beschreiben.",
"ALT_TELEPARTY_SUMMARY_TITLE": "Kurz gesagt",
"ALT_TELEPARTY_SUMMARY_BODY": "Wenn du eingebauten Chat oder Mobile-Support brauchst, passt Teleparty wahrscheinlich besser. Wenn du kostenlose, Open-Source-Desktop-Browser-Synchronisierung möchtest, die mit fast jedem normalen Videoplayer funktioniert, inklusive selbst gehosteter Medienserver wie Emby, Jellyfin und Plex, ist KoalaSync wahrscheinlich die passendere Wahl.",
"ALT_TELEPARTY_SUMMARY_BODY": "Beide Tools bieten Text-Chat. Wenn du Mobile-Support brauchst oder Telepartys stärker integriertes Watch-Party-Erlebnis bevorzugst, passt Teleparty wahrscheinlich besser. Wenn du kostenlose Open-Source-Synchronisierung für Desktop-Browser mit Ende-zu-Ende-verschlüsseltem Live-Chat möchtest, die mit fast jedem normalen Videoplayer funktioniert, einschließlich selbst gehosteter Medienserver wie Emby, Jellyfin und Plex, ist KoalaSync wahrscheinlich die passendere Wahl.",
"ALT_SCREEN_TITLE": "KoalaSync vs Bildschirmübertragung: Wann ist Video-Sync besser?",
"ALT_SCREEN_META_DESC": "Ein ehrlicher Vergleich von KoalaSync und Bildschirmübertragung für Filmabende, Wiedergabekontrolle, Videoqualität, Privatsphäre, Untertitel und gemeinsames Schauen im Browser.",
"ALT_SCREEN_INTRO": "Bildschirmübertragung funktioniert, und manchmal reicht das auch völlig. Eine Person teilt den Bildschirm, alle anderen schauen zu, fertig.<br><br>Bei längeren Filmabenden oder Serien kann das aber schnell nervig werden. Die Qualität hängt vom Upload, der CPU, dem Browser und der Komprimierung durch Discord, Teamspeak, Zoom oder andere Call-Apps ab. Außerdem kontrolliert nur eine Person wirklich die Wiedergabe.",
@@ -222,61 +222,61 @@
"ALT_SCREEN_WHY_CLEANER_TITLE": "Warum KoalaSync sauberer sein kann",
"ALT_SCREEN_WHY_CLEANER_BODY": "KoalaSync überträgt nicht deinen Bildschirm. Jeder schaut das Video lokal im eigenen Browser, und KoalaSync hält nur Aktionen wie Play, Pause und Spulen zwischen allen Teilnehmern synchron.<br><br>Dadurch kann jeder lokal Vollbild nutzen, eigene Untertitel oder Audio-Einstellungen verwenden, sofern der Player das unterstützt, und der Voice-Call bleibt separat in Discord, Teamspeak oder einer anderen App.<br><br>Für Filmabende fühlt sich das oft deutlich sauberer an, als wenn eine Person den ganzen Bildschirm streamt. Besonders praktisch ist das bei browserbasierten Setups wie Emby, Jellyfin, Plex, lokalen Dateien im Browser, privaten Domains oder anderen Websites mit normalen Browser-Videoplayern.",
"ALT_SCREEN_LIMITS_TITLE": "Ehrliche Grenzen",
"ALT_SCREEN_LIMITS_BODY": "KoalaSync ersetzt keinen Voice-Call. Wenn ihr beim Schauen reden wollt, nutzt ihr weiterhin Discord, Teamspeak, Signal, WhatsApp oder was auch immer ihr normalerweise verwendet.<br><br>Außerdem braucht jede Person selbst Zugriff auf das Video oder die jeweilige Seite. KoalaSync umgeht keine Abos, Logins, DRM, regionale Einschränkungen oder Plattformregeln.<br><br>KoalaSync ist aktuell Desktop-Browser-only, hat keinen eingebauten Chat oder Voice-Chat und die Funktion hängt immer vom jeweiligen Browser und Videoplayer ab.",
"ALT_SCREEN_LIMITS_BODY": "KoalaSync bietet Ende-zu-Ende-verschlüsselten Text-Chat, ersetzt aber keinen Sprach- oder Videoanruf. Wenn ihr beim Schauen reden wollt, nutzt ihr weiterhin Discord, Teamspeak, Signal, WhatsApp oder was auch immer ihr normalerweise verwendet.<br><br>Außerdem braucht jede Person selbst Zugriff auf das Video oder die jeweilige Seite. KoalaSync umgeht keine Abos, Logins, DRM, regionale Einschränkungen oder Plattformregeln.<br><br>KoalaSync ist aktuell Desktop-Browser-only, hat keinen eingebauten Sprach-, Video- oder Webcam-Chat und die Funktion hängt immer vom jeweiligen Browser und Videoplayer ab.",
"ALT_SCREEN_SUMMARY_TITLE": "Kurz gesagt",
"ALT_SCREEN_SUMMARY_BODY": "Bildschirmübertragung ist die einfachste Lösung, wenn es schnell gehen soll und Qualität, Privatsphäre oder individuelle Kontrolle nicht so wichtig sind. KoalaSync passt besser, wenn jeder lokal im eigenen Browser schauen soll und sich der Filmabend mehr wie wirklich gemeinsames Schauen anfühlen soll.",
"ALT_W2G_TITLE": "KoalaSync vs Watch2Gether: Welche Watch-Party-Lösung passt besser?",
"ALT_W2G_META_DESC": "Ein ehrlicher Vergleich von KoalaSync und Watch2Gether für Watch-Partys. Erfahre, wann KoalaSyncs Flexibilität unschlagbar ist und warum Watch2Gether für YouTube die Nase vorn hat.",
"ALT_W2G_META_DESC": "Ein praktischer Vergleich von KoalaSync und Watch2Gether zu gemeinsamen Räumen, Playlists, Browser-Player-Synchronisierung und verschlüsseltem Text-Chat.",
"ALT_W2G_INTRO": "KoalaSync und Watch2Gether lösen beide dasselbe Problem: Du willst mit Freunden online Videos schauen, ohne ständig manuell abgleichen zu müssen. Während Watch2Gether einer der ältesten und bekanntesten Dienste für gemeinsame Videoabende ist, geht KoalaSync einen ganz anderen, minimalistischen Weg. Welches Tool das richtige für dich ist, hängt stark davon ab, was und wo du schauen möchtest.",
"ALT_W2G_WHEN_W2G_TITLE": "Wann Watch2Gether die bessere Wahl ist",
"ALT_W2G_WHEN_W2G_BODY": "Watch2Gether ist unschlagbar für <strong>YouTube-Watch-Partys</strong>. Dank integrierter Playlist und Suche kann jeder in der Lobby sofort neue Videos suchen, hinzufügen und abspielen. Alle schauen nahtlos das nächste Video zusammen.<br><br>Bei KoalaSync greift auf YouTube die Autoplay-Logik der Website: Nach Videoende wird jeder auf ein anderes Empfehlungsvideo geleitet. Unsere automatische Folgen-Fortsetzung („Episode Lobby“), die auf Netflix oder Disney+ super funktioniert, klappt auf YouTube wegen dieser dynamischen Vorschläge leider nicht. Für reine YouTube-Abende ist Watch2Gether daher die bessere Wahl.",
"ALT_W2G_WHEN_W2G_BODY": "Watch2Gether kann besser passen, wenn bei YouTube-Sessions ein gemeinsamer Raum, Suche und Playlist im Mittelpunkt stehen. Unterstützung und Verhalten hängen vom aktuellen Watch2Gether-Dienst und der Inhaltsquelle ab.",
"ALT_W2G_WHEN_KOALA_TITLE": "Wann KoalaSync besser passt",
"ALT_W2G_WHEN_KOALA_BODY": "KoalaSync glänzt bei <strong>echten Streaming-Diensten</strong> (Netflix, Prime Video, Disney+) und selbstgehosteten Medienservern (Jellyfin, Plex, Emby). Im Gegensatz zu Watch2Gether, das oft keine Premium-Inhalte abspielen kann, synchronisiert KoalaSync den echten Original-Player direkt auf der jeweiligen Website.<br><br>Es funktioniert auf fast jeder Seite mit einem normalen HTML5-Videoplayer. Du musst keinen virtuellen Raum auf einer Drittseite erstellen: Einfach die Extension aktivieren, mit Freunden verbinden und losschauen.",
"ALT_W2G_WHEN_KOALA_BODY": "KoalaSync passt, wenn du den Original-Player auf einer unterstützten Browserseite synchronisieren chtest, einschließlich selbst gehosteter Medien, ohne die Wiedergabe auf eine separate Watch-Party-Seite zu verlagern. Die Kompatibilität hängt weiterhin von Website, Player und Browser ab.",
"ALT_W2G_MAIN_DIFF_TITLE": "Der wichtigste Unterschied",
"ALT_W2G_MAIN_DIFF_BODY": "Watch2Gether ist eine <strong>eigenständige Website</strong> mit integriertem Chat, Playlist und Webcam-Support. KoalaSync ist eine <strong>minimalistische Erweiterung</strong>, die sich direkt in deine normalen Browser-Tabs einklinkt.<br><br>Suchst du ein All-in-One-Paket für YouTube mit Chat? Nimm Watch2Gether. Willst du Filme auf Netflix oder deinem eigenen Server ohne Ablenkung, ohne Umwege und in voller nativer Bildqualität (ohne Server-Komprimierung) genießen? Dann ist KoalaSync die sauberere Lösung.",
"ALT_W2G_MAIN_DIFF_BODY": "Watch2Gether ist eine <strong>eigenständige Website</strong> mit Text-Chat, Playlists, Suche und Webcam-Support. KoalaSync ist eine <strong>minimalistische Erweiterung</strong>, die sich direkt in deine normalen Browser-Tabs einklinkt und ebenfalls Ende-zu-Ende-verschlüsselten Live-Text-Chat bietet.<br><br>Suchst du ein All-in-One-Paket für YouTube mit Playlists, Suche und Webcam-Funktionen? Nimm Watch2Gether. Willst du Filme auf Netflix oder deinem eigenen Server ohne Ablenkung, ohne Umwege und in voller nativer Bildqualität (ohne Server-Komprimierung) genießen? Dann ist KoalaSync die sauberere Lösung.",
"ALT_W2G_LIMITS_TITLE": "Ehrliche Grenzen",
"ALT_W2G_LIMITS_BODY": "KoalaSync hat <strong>keine eigene Playlist oder Suche</strong> wie Watch2Gether. Auf YouTube führt das dazu, dass das automatische Abspielen der nächsten Videos nicht synchron läuft.<br><br>Zudem gibt es <strong>keinen integrierten Chat</strong> und KoalaSync läuft aktuell nur auf Desktop-Browsern. Es ist so gedacht, dass ihr ohnehin über Discord, Teamspeak oder andere Voice-Tools miteinander sprecht.",
"ALT_W2G_LIMITS_BODY": "KoalaSync hat <strong>keine eigenen Playlists oder Videosuche</strong>. Auf YouTube führt das dazu, dass das automatische Abspielen des nächsten Videos nicht synchron läuft.<br><br>KoalaSync bietet Ende-zu-Ende-verschlüsselten Live-Text-Chat, aber keinen eingebauten Webcam-, Sprach- oder Video-Chat und läuft aktuell nur auf Desktop-Browsern. Wenn ihr miteinander reden oder euch sehen wollt, braucht ihr weiterhin Discord, Teamspeak oder ein anderes Call-Tool.",
"ALT_W2G_SUMMARY_TITLE": "Kurz gesagt",
"ALT_W2G_SUMMARY_BODY": "Für spontane YouTube-Sessions mit Playlists und integriertem Chat ist Watch2Gether die beste Wahl. Für echte Premium-Streaming-Plattformen (wie Netflix, Prime Video, Disney+), selbstgehostete Server (Jellyfin, Plex, Emby) und maximale Flexibilität im Browser ist KoalaSync das passendere und schlankere Tool.",
"ALT_W2G_SUMMARY_BODY": "Beide Tools bieten Text-Chat. Für spontane YouTube-Sessions mit Playlists, Suche und Webcam-Funktionen ist Watch2Gether die bessere Wahl. Für Premium-Streaming-Plattformen wie Netflix, Prime Video und Disney+, selbst gehostete Server wie Jellyfin, Plex und Emby, Ende-zu-Ende-verschlüsselten Live-Chat und maximale Flexibilität im Browser ist KoalaSync das passendere und schlankere Tool.",
"ALT_SCENER_TITLE": "KoalaSync vs Scener: Welche Watch-Party-Lösung passt besser?",
"ALT_SCENER_META_DESC": "Ein ehrlicher Vergleich von KoalaSync und Scener. Erfahre, wann Scener mit Webcam-Chat punktet und warum KoalaSync für werbefreie Filmabende die Nase vorn hat.",
"ALT_SCENER_INTRO": "KoalaSync und Scener gehen das Thema Watch-Partys aus zwei völlig verschiedenen Richtungen an. Scener versteht sich als virtuelles Kino mit integriertem Video-Chat und interaktiven Räumen. KoalaSync ist dagegen eine minimalistische, datenschutzfreundliche Browser-Erweiterung, die einfach nur deine Videowiedergabe synchronisiert. Welches Tool besser zu dir passt, hängt vor allem davon ab, wie wichtig dir Webcams und integrierter Chat beim Streamen sind.",
"ALT_SCENER_INTRO": "KoalaSync und Scener gehen das Thema Watch-Partys aus zwei völlig verschiedenen Richtungen an. Scener versteht sich als virtuelles Kino mit integriertem Video-Chat und interaktiven Räumen. KoalaSync ist dagegen eine minimalistische, datenschutzfreundliche Browser-Erweiterung, die die Videowiedergabe synchronisiert und Ende-zu-Ende-verschlüsselten Live-Text-Chat bietet. Welches Tool besser zu dir passt, hängt vor allem davon ab, ob du eingebaute Webcam-, Sprach-, Video- und Social-Funktionen brauchst oder ein schlankes Sync-Tool bevorzugst.",
"ALT_SCENER_WHEN_SCENER_TITLE": "Wann Scener die bessere Wahl ist",
"ALT_SCENER_WHEN_SCENER_BODY": "Scener ist eine solide Wahl, wenn du eine <strong>All-in-One-Social-Plattform</strong> mit integriertem Video- und Audio-Chat direkt im Browserfenster suchst. Scener erstellt einen virtuellen Raum, in dem der Player und die Webcams deiner Freunde nebeneinander angezeigt werden. Dies ist ideal für große, öffentliche Watch-Partys oder virtuelle Streaming-Events, bei denen der soziale Aspekt im Vordergrund steht. Beachte jedoch, dass die kostenlose Basisversion durch Werbung finanziert wird und Scener viele Funktionen (wie werbefreies Schauen und unbegrenzten Video-Chat) hinter einem kostenpflichtigen Premium-Abo ($5.79/Monat oder $39.99/Jahr) versteckt.",
"ALT_SCENER_WHEN_SCENER_BODY": "Scener passt besser, wenn du ein integriertes Social-Erlebnis mit Text-Chat und für Premium-Mitglieder Video- und Audio-Chat möchtest. Auf Computern unterstützt Scener eine festgelegte Auswahl von Streamingdiensten über seine Chrome-Erweiterung.",
"ALT_SCENER_WHEN_KOALA_TITLE": "Wann KoalaSync besser passt",
"ALT_SCENER_WHEN_KOALA_BODY": "KoalaSync punktet bei <strong>Bildqualität, Datenschutz und Einfachheit</strong>:<br><br>• <strong>Keine Accounts</strong>: Du musst dich nicht registrieren, einloggen oder Profile verwalten. Einfach Extension aktivieren, Raumcode teilen und losschauen.<br>• <strong>Echte native Qualität (4K/HDR)</strong>: Scener streamt ein komprimiertes Bild von einem virtuellen Cloud-Server, was zu Qualitätsverlusten und Rucklern führen kann. KoalaSync synchronisiert den echten Original-Player direkt auf deinem Computer. Du schaust Filme also in voller nativer Auflösung deines Streaming-Abos.<br>• <strong>Geringer Datenverbrauch</strong>: Da nur Steuerungsdaten (Play, Pause, Zeitstempel) übertragen werden, verbraucht KoalaSync fast keine zusätzliche Bandbreite. Scener streamt das gesamte Video und Kamerabilder über Cloud-Server, was leistungsschwächere Laptops und Internetleitungen stark beanspruchen kann.<br>• <strong>100 % kostenlos & werbefrei</strong>: Keine monatlichen Gebühren, keine Werbung.<br>• <strong>Lokale Videodateien</strong>: Da KoalaSync lokal läuft, kannst du sogar eigene Videodateien (z. B. MP4) synchron mit Freunden schauen. Ziehe die Datei einfach per Drag-and-Drop in ein Browser-Tab Scener kann das bauartbedingt (da Scener in der Cloud läuft) nicht.",
"ALT_SCENER_WHEN_KOALA_BODY": "KoalaSync passt, wenn du kontofreie Open-Source-Synchronisierung im Original-Player, Ende-zu-Ende-verschlüsselten Live-Text-Chat und optional einen selbst gehosteten Relay bevorzugst. Sprach-, Video- oder Webcam-Chat sind nicht enthalten.",
"ALT_SCENER_MAIN_DIFF_TITLE": "Der wichtigste Unterschied",
"ALT_SCENER_MAIN_DIFF_BODY": "Scener ist ein <strong>kommerzielles soziales Netzwerk</strong> für Watch-Partys, das auf virtuellen Cloud-Browsern basiert und mit Werbung sowie monatlichen Abonnements finanziert wird. KoalaSync ist eine <strong>datenschutzfreundliche, kostenlose Synchronisations-Erweiterung</strong>, die sich direkt in deine Browser-Tabs einklinkt. Sie überlässt den Chat Programmen wie Discord oder Teamspeak und konzentriert sich ganz darauf, deine Wiedergabe flüssig, privat und in bester Originalqualität zu synchronisieren.",
"ALT_SCENER_MAIN_DIFF_BODY": "Scener konzentriert sich auf integrierte Räume und Social-Funktionen. KoalaSync konzentriert sich auf schlanke Synchronisierung im Original-Player und Ende-zu-Ende-verschlüsselten Live-Text-Chat.",
"ALT_SCENER_LIMITS_TITLE": "Ehrliche Grenzen",
"ALT_SCENER_LIMITS_BODY": "KoalaSync bietet <strong>keinen eigenen Chat und keine Webcam-Unterstützung</strong>. Es ist ausschließlich als Sync-Tool gedacht und erfordert ein separates Kommunikations-Tool deiner Wahl. Zudem ist KoalaSync derzeit nur für Desktop-Browser verfügbar. Die Kompatibilität lokaler Videodateien hängt außerdem davon ab, ob dein Browser das jeweilige Videoformat und die Audio-Codecs nativ abspielen kann.",
"ALT_SCENER_LIMITS_BODY": "KoalaSync bietet <strong>Ende-zu-Ende-verschlüsselten Live-Text-Chat, aber keinen Webcam-, Sprach- oder Video-Chat</strong>. Zum Reden oder gegenseitigen Sehen braucht ihr daher ein separates Call-Tool eurer Wahl. Zudem ist KoalaSync derzeit nur für Desktop-Browser verfügbar. Die Kompatibilität lokaler Videodateien hängt außerdem davon ab, ob dein Browser das jeweilige Videoformat und die Audio-Codecs nativ abspielen kann.",
"ALT_SCENER_SUMMARY_TITLE": "Kurz gesagt",
"ALT_SCENER_SUMMARY_BODY": "Für große Watch-Partys mit Webcam-Chat und Social-Features im selben Fenster ist Scener die richtige Wahl, sofern dich Werbung und Premium-Gating nicht stören. Wenn du einfach nur komplett kostenlos, werbefrei und ungestört im echten Player (inklusive Netflix, Prime Video, Disney+ oder sogar lokalen Dateien im Browser) synchron schauen willst und ohnehin in Discord telefonierst, ist KoalaSync die sauberere und performantere Lösung.",
"ALT_SCENER_SUMMARY_BODY": "Wähle Scener für integrierte Social-, Video- und Audiofunktionen auf den unterstützten Diensten. Wähle KoalaSync für kontofreie Open-Source-Browser-Synchronisierung, verschlüsselten Live-Text-Chat und optionales Self-Hosting.",
"ALT_KOSMI_TITLE": "KoalaSync vs Kosmi: Welche Netflix-Party-Alternative passt besser?",
"ALT_KOSMI_META_DESC": "Ein ehrlicher Vergleich von KoalaSync und Kosmi. Erfahre, warum Kosmis bequeme No-Extension-Lösung punktet und wann KoalaSyncs native Bildqualität gewinnt.",
"ALT_KOSMI_INTRO": "Sowohl KoalaSync als auch Kosmi bieten hervorragende Möglichkeiten, um Netflix, YouTube und andere Plattformen synchron mit Freunden zu schauen. Doch technisch arbeiten beide Tools grundlegend verschieden. Kosmi setzt auf virtuelle Cloud-Browser, die ohne Erweiterung direkt im Web laufen. KoalaSync ist eine minimalistische Browser-Erweiterung, die deinen lokalen Player synchronisiert. Welcher Ansatz für dich der bessere ist, hängt von deinen Ansprüchen an Bildqualität, Komfort und Datenschutz ab.",
"ALT_KOSMI_INTRO": "KoalaSync und Kosmi ermöglichen gemeinsames Schauen, sind aber eigenständige Produkte mit unterschiedlichen unterstützten Diensten und Social-Funktionen. KoalaSync synchronisiert einen unterstützten Videoplayer im Desktop-Browser. Prüfe bei jedem Dienst die aktuelle Geräte- und Plattformunterstützung.",
"ALT_KOSMI_WHEN_KOSMI_TITLE": "Wann Kosmi die bessere Wahl ist",
"ALT_KOSMI_WHEN_KOSMI_BODY": "Kosmi ist unschlagbar, wenn du <strong>überhaupt nichts installieren</strong> möchtest oder auf Geräten wie Tablets oder Smartphones mitschauen willst. Da Kosmi über virtuelle Browser in der Cloud läuft, reicht ein einfacher Weblink aus, um beizutreten. Zudem bietet Kosmi eine All-in-One-Plattform: Neben Video-Streaming könnt ihr in den virtuellen Räumen auch Multiplayer-Spiele spielen (z. B. NES/SNES-Klassiker oder Pokertische) und den integrierten Webcam-, Voice- und Text-Chat nutzen. Wenn der Fokus auf einem geselligen Spiele- und Videoabend ohne jegliche Installation liegt, ist Kosmi eine fantastische Lösung.",
"ALT_KOSMI_WHEN_KOSMI_BODY": "Kosmi kann besser passen, wenn du dessen browserbasierte Social-Räume, Spiele und integrierte Kommunikationsfunktionen möchtest. Die Verfügbarkeit kann je nach Gerät, Inhaltsquelle und aktuellem Kosmi-Angebot variieren.",
"ALT_KOSMI_WHEN_KOALA_TITLE": "Wann KoalaSync besser passt",
"ALT_KOSMI_WHEN_KOALA_BODY": "KoalaSync glänzt durch seine <strong>native Wiedergabe, maximale Sicherheit und Performance</strong>:<br><br>• <strong>Keine Sicherheitsrisiken</strong>: Bei Kosmi musst du dich auf einem fremden virtuellen Computer in der Cloud in deinen Netflix- oder Prime-Account einloggen. Bei KoalaSync loggst du dich wie gewohnt auf der echten Streaming-Website in deinem eigenen Browser ein. Deine Passwörter verlassen niemals deinen Computer.<br>• <strong>Volle Bildqualität (4K/HDR)</strong>: Kosmi streamt das Bild des virtuellen Browsers als komprimierten Videofeed an dich. KoalaSync synchronisiert die Player lokal: Du genießt Filme in der vollen, gestochen scharfen Originalqualität deines Abos, ohne Pixelmatsch oder Ruckler.<br>• <strong>Schonend für Internet und Akku</strong>: Da KoalaSync nur Steuerdaten (Play/Pause/Zeitstempel) sendet, verbraucht es fast null Bandbreite. Kosmi streamt permanent ein HD-Video aus der Cloud, was schwache Internetverbindungen überlasten kann.<br>• <strong>Lokale Videodateien</strong>: Wenn du ein eigenes Video synchron schauen willst, ziehst du die MP4-Datei einfach per Drag-and-Drop in ein Browser-Tab. KoalaSync synchronisiert sie sofort. Kosmi kann auf deine lokalen Dateien auf der Festplatte nicht zugreifen.",
"ALT_KOSMI_WHEN_KOALA_BODY": "KoalaSync passt besser, wenn du einen unterstützten Player in deinem eigenen Desktop-Browser synchronisieren, Ende-zu-Ende-verschlüsselten Live-Text-Chat nutzen oder den Relay selbst hosten möchtest. Sprach-, Video- oder Webcam-Chat und eine Mobile-App sind nicht enthalten.",
"ALT_KOSMI_MAIN_DIFF_TITLE": "Der wichtigste Unterschied",
"ALT_KOSMI_MAIN_DIFF_BODY": "Kosmi ist ein <strong>virtueller Treffpunkt</strong> in der Cloud, der einen Remote-Browser per Video-Feed streamt, damit niemand eine Erweiterung installieren muss. KoalaSync ist eine <strong>Schnittstelle für den eigenen Browser</strong>, die sich direkt in den nativen Player auf der echten Website einklinkt. Während Kosmi vielseitiger für Minispiele und Geräte-Kompatibilität ist, bietet KoalaSync das sicherere, flüssigere und schärfere Streaming-Erlebnis im Original-Player.",
"ALT_KOSMI_MAIN_DIFF_BODY": "Kosmi konzentriert sich auf einen integrierten Social-Raum. KoalaSync ist eine Desktop-Browser-Erweiterung, die unterstützte Player synchronisiert und verschlüsselten Live-Text-Chat bietet.",
"ALT_KOSMI_LIMITS_TITLE": "Ehrliche Grenzen",
"ALT_KOSMI_LIMITS_BODY": "KoalaSync erfordert die <strong>Installation einer Browser-Erweiterung</strong> und läuft derzeit nur auf Desktop-PCs (Windows, Mac, Linux). Es hat <strong>keine integrierten Chats oder Webcams</strong>, da es so gedacht ist, dass ihr ohnehin über Discord oder andere Voice-Tools miteinander sprecht. Zudem bietet KoalaSync keine integrierten Spiele wie Kosmi.",
"ALT_KOSMI_LIMITS_BODY": "KoalaSync erfordert die <strong>Installation einer Browser-Erweiterung</strong> und läuft derzeit nur auf Desktop-PCs. Es bietet <strong>Ende-zu-Ende-verschlüsselten Live-Text-Chat, aber keinen Webcam-, Sprach- oder Video-Chat</strong>. Zudem bietet KoalaSync keine integrierten Spiele.",
"ALT_KOSMI_SUMMARY_TITLE": "Kurz gesagt",
"ALT_KOSMI_SUMMARY_BODY": "Für gemütliche Spieleabende inklusive SNES-Klassikern und einfachem Beitreten ohne jegliche Installation auf Smartphones oder Tablets ist Kosmi ideal. Für private Filmabende auf Netflix, Prime Video, Disney+ oder Plex in bester 4K-Originalqualität, mit maximaler Passwort-Sicherheit und minimaler Internet-Belastung ist KoalaSync die deutlich überlegene Wahl.",
"ALT_KOSMI_SUMMARY_BODY": "Wähle Kosmi für dessen integrierte Social-Raum-Funktionen. Wähle KoalaSync für kontofreie Open-Source-Synchronisierung am Desktop, verschlüsselten Live-Text-Chat und optionales Relay-Self-Hosting.",
"ALT_TWOSEVEN_TITLE": "KoalaSync vs TwoSeven: Welche Watch-Party-Lösung passt besser?",
"ALT_TWOSEVEN_META_DESC": "Ein ehrlicher Vergleich von KoalaSync und TwoSeven. Erfahre, wann TwoSeven mit integrierter Webcam punktet und warum KoalaSync für Disney+ und Netflix die Nase vorn hat.",
"ALT_TWOSEVEN_INTRO": "Sowohl KoalaSync als auch TwoSeven nutzen denselben grundlegenden Ansatz für Watch-Partys: Sie synchronisieren den originalen Videoplayer direkt in deinem Browser, anstatt ein komprimiertes Bild aus der Cloud zu streamen. Dadurch bleibt die Bildqualität bei beiden Tools auf Top-Niveau. Doch während TwoSeven als kommerzielles Portal mit integriertem Webcam-Chat und Abonnement-Optionen aufgebaut ist, bleibt KoalaSync eine minimalistische, komplett kostenfreie und datenschutzfreundliche Erweiterung. Welcher Dienst besser zu dir passt, erfährst du hier.",
"ALT_TWOSEVEN_META_DESC": "Ein praktischer Vergleich von KoalaSync und TwoSeven zu Kontopflicht, integrierter Kommunikation, verschlüsseltem Text-Chat und Self-Hosting.",
"ALT_TWOSEVEN_INTRO": "Beide Tools unterstützen synchronisiertes Schauen; unterstützte Dienste und Voraussetzungen können sich jedoch ändern. TwoSeven ist eine kontobasierte Plattform mit integrierten Kommunikationsfunktionen. KoalaSync ist eine kontofreie Erweiterung mit verschlüsseltem Live-Text-Chat und ohne eingebaute Anrufe.",
"ALT_TWOSEVEN_WHEN_TWOSEVEN_TITLE": "Wann TwoSeven die bessere Wahl ist",
"ALT_TWOSEVEN_WHEN_TWOSEVEN_BODY": "TwoSeven ist die richtige Wahl, wenn du eine <strong>All-in-One-Lösung mit integrierter Webcam und Audio-Chat</strong> direkt im Browser suchst. Wenn du deine Freunde beim Schauen unbedingt sehen möchtest, ohne dafür nebenbei Discord oder Teamspeak laufen zu lassen, bietet TwoSeven diese Integration nativ. Zudem läuft der Dienst für YouTube und einige freie Web-Videoplayer ohne zusätzliche Accounts oder Erweiterungen. Er eignet sich gut für kleine Runden von 2 bis 3 Personen, die sich beim Schauen auch visuell austauschen möchten.",
"ALT_TWOSEVEN_WHEN_TWOSEVEN_BODY": "TwoSeven kann besser passen, wenn dir integrierte Webcam- und Audiofunktionen wichtig sind. Prüfe vor der Auswahl die aktuellen Browser-, Konto-, Tarif- und Dienstvoraussetzungen von TwoSeven.",
"ALT_TWOSEVEN_WHEN_KOALA_TITLE": "Wann KoalaSync besser passt",
"ALT_TWOSEVEN_WHEN_KOALA_BODY": "KoalaSync glänzt durch seine <strong>vollkommene Gebührenfreiheit, Privatsphäre und Einfachheit</strong>:<br><br>• <strong>Keine Bezahlschranken (Paywalls)</strong>: Um bei TwoSeven Premium-Dienste wie Disney+ oder Hulu synchronisiert zu schauen, muss mindestens ein Teilnehmer im Raum TwoSeven kostenpflichtig auf Patreon unterstützen (ab $3/Monat). KoalaSync synchronisiert Netflix, Prime Video, Disney+ und selbstgehostete Server (Jellyfin, Plex, Emby) zu 100 % kostenlos und ohne Einschränkungen.<br>• <strong>Absoluter Datenschutz (Keine Tracker/Werbung)</strong>: TwoSeven ist werbefinanziert und nutzt zahlreiche Tracker von Google, Facebook und Co. KoalaSync hat keine Werbung, keine Tracker und verbindet sich ausschließlich mit dem RAM-basierten Relay-Server.<br>• <strong>Keine Registrierung erforderlich</strong>: Bei TwoSeven müssen alle Teilnehmer ein Konto erstellen und eingeloggt sein. KoalaSync kommt komplett ohne Accounts aus. Einfach installieren, Raumcode eingeben und loslegen.<br>• <strong>Schlank und ressourcenschonend</strong>: Die integrierten Webcam- und Audio-Feeds von TwoSeven belasten die CPU und Internetleitung stark. Da KoalaSync die Kommunikation externen Tools wie Discord überlässt, läuft es extrem flüssig und schont den Laptop-Akku.",
"ALT_TWOSEVEN_WHEN_KOALA_BODY": "KoalaSync passt, wenn du kontofreie Open-Source-Synchronisierung, Ende-zu-Ende-verschlüsselten Live-Text-Chat und einen optional selbst gehosteten Relay möchtest. Sprach- und Webcam-Anrufe bleiben in einer separaten Anwendung.",
"ALT_TWOSEVEN_MAIN_DIFF_TITLE": "Der wichtigste Unterschied",
"ALT_TWOSEVEN_MAIN_DIFF_BODY": "TwoSeven ist eine <strong>kommerzielle Plattform</strong> mit Kontozwang, Werbung, P2P-Webcam-Chat und Patreon-Paywalls für bestimmte Streaming-Dienste. KoalaSync ist ein <strong>kostenloses Open-Source-Utility</strong>, das sich ohne Registrierung, Tracker oder zusätzliche Kosten einfach in deine Browser-Tabs einklinkt, um das ungestörte Schauen im Original-Player zu ermöglichen.",
"ALT_TWOSEVEN_MAIN_DIFF_BODY": "TwoSeven verbindet synchronisiertes Schauen mit einer kontobasierten Plattform und integrierten Webcam- und Audiofunktionen. KoalaSync ist eine kontofreie Browser-Erweiterung mit verschlüsseltem Live-Text-Chat; Sprach- und Videoanrufe bleiben getrennt.",
"ALT_TWOSEVEN_LIMITS_TITLE": "Ehrliche Grenzen",
"ALT_TWOSEVEN_LIMITS_BODY": "KoalaSync bietet <strong>keinen eigenen Webcam- oder Audio-Chat</strong> und setzt voraus, dass ihr bereits über Discord oder ein ähnliches Tool telefoniert. Zudem hat KoalaSync keine eingebaute Chatbox auf der Webseite und ist derzeit nur für Desktop-Browser verfügbar.",
"ALT_TWOSEVEN_LIMITS_BODY": "KoalaSync bietet <strong>keinen eingebauten Webcam- oder Audio-Chat</strong>. Wenn ihr euch sehen oder miteinander sprechen wollt, braucht ihr weiterhin Discord oder ein ähnliches Tool. KoalaSync besitzt jedoch ein frei verschiebbares und links oder rechts andockbares, Ende-zu-Ende-verschlüsseltes Live-Text-Chat-Overlay und ist derzeit nur für Desktop-Browser verfügbar.",
"ALT_TWOSEVEN_SUMMARY_TITLE": "Kurz gesagt",
"ALT_TWOSEVEN_SUMMARY_BODY": "Für gemütliche Zweier- oder Dreier-Runden, bei denen man sich unbedingt direkt im Browserfenster über Webcams sehen möchte und bereit ist, für Disney+ ein Patreon-Abo abzuschließen, ist TwoSeven eine nette Option. Für werbefreie, komplett kostenlose und private Filmabende in nativer 4K-Qualität (egal ob Netflix, Prime Video, Disney+ oder Jellyfin) ohne Kontozwang ist KoalaSync die sauberere und sicherere Wahl.",
"ALT_TWOSEVEN_SUMMARY_BODY": "Wähle TwoSeven, wenn dir integrierte Webcam- und Audiofunktionen am wichtigsten sind. Wähle KoalaSync für kontofreie Open-Source-Synchronisierung, verschlüsselten Live-Text-Chat und optionales Self-Hosting.",
"ALT_TRY_KOALASYNC": "KoalaSync ausprobieren",
"MOCK_HOST_CONTROL_LABEL": "Host-Steuerung",
"MOCK_HOST_CONTROL_TOGGLE": "Nur ich darf die Wiedergabe steuern",
@@ -288,5 +288,9 @@
"SELF_SHOW_SETUP": "Setup-Anleitung anzeigen",
"SELF_HIDE_SETUP": "Setup-Anleitung ausblenden",
"HERO_TRUST": "Kostenlos · Open Source · Kein Account nötig",
"COMP_MORE_GUIDES": "Ausführlicher Vergleich und weitere Alternativen"
"COMP_MORE_GUIDES": "Ausführlicher Vergleich und weitere Alternativen",
"NAV_HOME": "Startseite",
"ARIA_SELECT_LANGUAGE": "Sprache auswählen",
"ARIA_MENU": "Menü",
"FOOTER_GUIDES": "Anleitungen & Vergleiche"
}
+51 -47
View File
@@ -14,11 +14,11 @@
"TWITTER_DESCRIPTION": "Host a watch party on Netflix, Emby, Jellyfin, YouTube, Twitch and almost any HTML5 video. Open source, privacy-first, and account-free.",
"NAV_FEATURES": "Features",
"NAV_HOW_IT_WORKS": "How it works",
"SUPPORT_BANNER_TEXT": "KoalaSync cannot access your video site?",
"SUPPORT_BANNER_CTA": "Fix website access",
"SUPPORT_BANNER_TEXT": "Need help with KoalaSync?",
"SUPPORT_BANNER_CTA": "Open Help Center",
"SUPPORT_BANNER_DISMISS": "Dismiss notification",
"HERO_TITLE": "Turn any video into a <span class='hero-highlight'>watch party</span>.",
"HERO_SUBTITLE": "Your remote movie night without lags. No account, no tracking, no ads. Just share a link and press play together.",
"HERO_SUBTITLE": "Watch videos together remotely. No account, tracking, or ads. Share a link and start together.",
"HERO_MASCOT_ALT": "A cute koala standing and looking down at the download buttons",
"DEMO_SYNC": "In sync",
"DEMO_HINT": "Live demo: press play in either tab and KoalaSync keeps both in sync.",
@@ -42,7 +42,7 @@
"USE_CASE_1_DESC": "Sync your movies in real time and talk via Discord, Zoom, or your favorite voice call application. It feels just like sitting in the same room.",
"USE_CASE_2_ALT": "A koala professor presenting on a screen to two remote student koalas",
"USE_CASE_2_TITLE": "Remote Learning",
"USE_CASE_2_DESC": "Analyze online tutorials, lectures, or developer training streams together with classmates or colleagues. Pause and discuss complex steps in perfect harmony.",
"USE_CASE_2_DESC": "Review tutorials, lectures, or training videos together. Pause and discuss complex steps as a group.",
"USE_CASE_3_ALT": "A cute koala couple watching together remotely; one is sitting at a PC and the other is in bed with a laptop, with flying hearts between them",
"USE_CASE_3_TITLE": "Long-Distance Relationships",
"USE_CASE_3_DESC": "Bridge the distance and enjoy date nights. Experience every plot twist, laugh at the same jokes, and share emotional moments at the exact same millisecond.",
@@ -115,8 +115,8 @@
"STEP_2_ILLUS_CREATE": "+ Create New Room",
"STEP_2_ILLUS_MANUAL": "Manual Connect / Advanced",
"STEP_2_ILLUS_COPIED": "Invite link copied!",
"STEP_3_TITLE": "Enjoy Perfect Sync",
"STEP_3_DESC": "Press play. Whether someone pauses, seeks, or plays, everyone's playback stays perfectly in sync without any lag.",
"STEP_3_TITLE": "Watch Together",
"STEP_3_DESC": "Press play. Playback changes are shared with everyone in the room; network and player conditions can cause a brief delay.",
"STEP_3_ILLUS_IN_SYNC": "IN SYNC",
"SELF_TITLE": "For Self-Hosters",
"SELF_SUBTITLE": "Don't trust our server? Maintain full data sovereignty. Deploy your own private relay server in minutes.",
@@ -127,33 +127,33 @@
"BOTTOM_SUBTITLE": "KoalaSync is 100% open source, ad-free and tracking-free. Audit our codebase on GitHub or install the browser extension directly.",
"BOTTOM_MASCOT_ALT": "A cute koala holding a GitHub repository page next to the GitHub mascot Octocat",
"FAQ_TITLE": "Frequently Asked Questions",
"FAQ_SUBTITLE": "Everything you need to know about watching videos together in perfect sync.",
"FAQ_SUBTITLE": "Answers about supported sites, synchronization, privacy, chat and audio processing.",
"FAQ_Q1": "Can I watch Netflix, Emby or Jellyfin together in sync with friends?",
"FAQ_A1": "Yes! KoalaSync works on Netflix, YouTube, Twitch and any website with an HTML5 video player, including Emby, Jellyfin and self-hosted media servers. Install the extension, create a room, share the invite link, and everyone watches in perfect sync.",
"FAQ_A1": "KoalaSync works with many supported websites that use a standard browser video player, including Netflix, YouTube, Twitch, Emby, Jellyfin and self-hosted media servers. Compatibility depends on the website, browser and player and can change when a site updates.",
"FAQ_Q_VIDEO_ACCESS": "Does KoalaSync stream the video to my friends?",
"FAQ_A_VIDEO_ACCESS": "No. KoalaSync does not stream, host, share, or bypass access to video content. Everyone watches the video locally in their own browser and needs their own access to the website or streaming service, such as their own Netflix account. KoalaSync only synchronizes playback actions like play, pause, and seeking.",
"FAQ_Q2": "Is KoalaSync a free alternative to Teleparty?",
"FAQ_A2": "100% free and open source under the MIT License. No premium tiers, no locked features, no ads, everything is included. Unlike Teleparty, KoalaSync's relay server is RAM-only with zero data collection or user accounts required.",
"FAQ_A2": "Yes. KoalaSync is free and open source under the MIT License, with no premium tier, locked features, ads or required user account.",
"FAQ_Q3": "Does KoalaSync work with Emby, Jellyfin and self-hosted media servers?",
"FAQ_A3": "Yes. KoalaSync detects any standard HTML5 video element, which means it works perfectly with Emby, Jellyfin, Plex, and any custom self-hosted streaming setup. You can also self-host the KoalaSync relay server via Docker for complete data sovereignty.",
"FAQ_A3": "Yes. KoalaSync supports many standard browser video players used by Emby, Jellyfin, Plex and custom self-hosted streaming setups. Compatibility depends on the player. You can also self-host the KoalaSync relay server with Docker.",
"FAQ_Q4": "Which browsers and platforms support KoalaSync?",
"FAQ_A4": "Google Chrome, Mozilla Firefox, Microsoft Edge, Opera, Brave, and all Chromium-based browsers. Install it directly from the Chrome Web Store or Firefox Add-ons, no manual configuration needed.",
"FAQ_Q5": "Do all my friends need the extension installed?",
"FAQ_A5": "Yes, each participant needs KoalaSync installed, but there's no sign-up or account creation. Simply click the invitation link, install the extension if you haven't already, and you'll join the room automatically in seconds.",
"FAQ_Q6": "Is my data safe? What if I don't trust the official server?",
"FAQ_A6": "Completely safe. KoalaSync's relay server is RAM-only, all session data is permanently deleted the moment you leave the room. No database, no logging, no analytics, no accounts. And if you prefer full control: you can self-host your own private relay server via Docker in minutes. The entire codebase is open source and auditable on GitHub.",
"FAQ_A6": "The official KoalaSync relay does not store room, sync, watch, or chat history in a database. Active room data exists temporarily in memory and is removed when the room becomes empty or expires after inactivity. Technical connection logs may be created for security and abuse prevention; their retention depends on the server configuration. Chat messages are end-to-end encrypted and are not stored as server-side history. You can also self-host the relay with Docker.",
"FAQ_Q7": "Can I fix quiet dialogue and loud explosions when watching videos in the browser?",
"FAQ_A7": "Yes. KoalaSync 2.2.0 includes a built-in audio compressor that reduces the gap between quiet and loud parts of your video. It runs locally via the Web Audio API, no audio data ever leaves your computer. Open the popup, create a room, go to Settings → Audio Processing, toggle the compressor on, and pick a preset. Works on Netflix, Prime Video, Disney+, YouTube, and any HTML5 video site.",
"FAQ_A7": "Yes. KoalaSync includes an audio compressor that reduces the difference between quiet and loud parts of a video. It runs locally through the Web Audio API, so audio data is not sent to the relay. Open the popup, create or join a room, then enable it under Settings → Audio Processing.",
"HOWTO_TITLE": "How to synchronize videos with friends using KoalaSync",
"HOWTO_DESC": "Watch any video in perfect sync with friends across YouTube, Netflix, Twitch and more. No sign-up, no tracking.",
"HOWTO_DESC": "Watch supported videos together on YouTube, Netflix, Twitch and more. No sign-up or tracking.",
"HOWTO_STEP_1_NAME": "Install the browser extension",
"HOWTO_STEP_1_TEXT": "Add KoalaSync to your browser from Chrome Web Store or Firefox Add-ons. It works on any website with a video element.",
"HOWTO_STEP_1_TEXT": "Add KoalaSync from the Chrome Web Store or Firefox Add-ons. Open a supported page and select a video detected by the extension.",
"HOWTO_STEP_2_NAME": "Create a room",
"HOWTO_STEP_2_TEXT": "Open the extension popup and click Create New Room. The invite link is automatically copied to your clipboard.",
"HOWTO_STEP_3_NAME": "Share and sync",
"HOWTO_STEP_3_TEXT": "Send the invite link to your friends. Pick a video tab and enjoy synchronized playback in real time.",
"FOOTER_MIT": "Open source under the MIT License.",
"FOOTER_RAM": "No relay logs. No tracking. Website access logs are deleted after 7 days.",
"FOOTER_RAM": "No chat or watch history. No tracking. Website access logs are deleted after 7 days.",
"FOOTER_LEGAL": "Legal Notice",
"FOOTER_PRIVACY": "Privacy Policy",
"MOCK_01": "Active Room",
@@ -201,17 +201,17 @@
"ALT_INDEX_FOOTER_LINK": "Guides & comparisons",
"ALT_TELEPARTY_TITLE": "KoalaSync vs Teleparty: which watch party tool should you use?",
"ALT_TELEPARTY_META_DESC": "A practical comparison of KoalaSync and Teleparty for watch parties, built-in chat, mobile support, browser video sync, self-hosted media servers and open-source use.",
"ALT_TELEPARTY_INTRO": "KoalaSync and Teleparty both help with the same annoying problem: watching something together without doing the whole \"3, 2, 1, play\" routine. They just take a different approach.<br><br>Teleparty makes the most sense if you want a built-in chat or if you want to watch on mobile. KoalaSync is more for desktop browser watch parties where you are already talking on Discord, Teamspeak or another voice call and just want playback to stay in sync.<br><br>The main reason I built KoalaSync was flexibility. It is meant to work with almost any website that uses a normal browser video player, not just a fixed list of platforms. That includes self-hosted setups like Emby, Jellyfin and Plex, local/private domains, and a lot of normal video pages in the browser.",
"ALT_TELEPARTY_INTRO": "KoalaSync and Teleparty both help with the same annoying problem: watching something together without doing the whole \"3, 2, 1, play\" routine. Both include text chat, but they take a different approach.<br><br>Teleparty makes the most sense if you want its more integrated watch-party experience or want to watch on mobile. KoalaSync is designed for desktop browser watch parties across a wider range of normal browser video players. Its built-in text chat is end-to-end encrypted and live-only, while voice or video calls remain separate.<br><br>The main reason I built KoalaSync was flexibility. It is meant to work with almost any website that uses a normal browser video player, not just a fixed list of platforms. That includes self-hosted setups like Emby, Jellyfin and Plex, local/private domains, and a lot of normal video pages in the browser.",
"ALT_TELEPARTY_WHEN_TELE_TITLE": "When Teleparty may still make sense",
"ALT_TELEPARTY_WHEN_TELE_BODY": "Teleparty can still be the better choice if you want the chat to be part of the watch party tool itself. KoalaSync does not currently have a built-in chat. It assumes you are already talking somewhere else, like Discord, Teamspeak, WhatsApp, Signal or a normal call.<br><br>Mobile is the other big reason. Teleparty has an app, while KoalaSync currently only works on desktop browsers. If you want to sync Netflix on a phone or tablet, KoalaSync is not the right tool for that right now.<br><br>So if chat and mobile support are the things you care about most, Teleparty may simply fit your setup better.",
"ALT_TELEPARTY_WHEN_TELE_BODY": "Teleparty can still be the better choice if you prefer its more integrated watch-party experience or need mobile support. Both tools include text chat, so chat alone is no longer the deciding difference. KoalaSync's chat is end-to-end encrypted and live-only; voice and video calls still use a separate app.<br><br>Mobile is the other big reason. Teleparty has an app, while KoalaSync currently only works on desktop browsers. If you want to sync Netflix on a phone or tablet, KoalaSync is not the right tool for that right now.<br><br>So if mobile support and a more integrated platform are the things you care about most, Teleparty may simply fit your setup better.",
"ALT_TELEPARTY_WHEN_KOALA_TITLE": "When KoalaSync may fit better",
"ALT_TELEPARTY_WHEN_KOALA_BODY": "KoalaSync fits better if you want a flexible desktop browser sync tool instead of something tied to a short platform list.<br><br>It works with almost any website that uses a normal browser video player. That is especially useful for Emby, Jellyfin, Plex, private media servers, local domains, or smaller video sites that classic watch party tools usually ignore.<br><br>It is also free, open source, does not require accounts, and can be self-hosted if you want to run your own relay. I built it because I wanted something privacy-friendly that worked with my own Emby setup, not another closed platform just for a few big streaming sites.",
"ALT_TELEPARTY_MAIN_DIFF_TITLE": "The main difference",
"ALT_TELEPARTY_MAIN_DIFF_BODY": "Teleparty is more of a watch party product with chat and mobile support. KoalaSync is more of a flexible sync layer for browser video.<br><br>That sounds like a small difference, but it changes the use case a lot. If you need a full watch party app with chat inside it, Teleparty makes sense. If you already have voice chat and just want the video controls to stay synced across browsers, KoalaSync is the simpler fit.",
"ALT_TELEPARTY_MAIN_DIFF_BODY": "Teleparty is more of an integrated watch-party product with mobile support. KoalaSync is a flexible desktop-browser sync layer with end-to-end encrypted live text chat.<br><br>That sounds like a small difference, but it changes the use case a lot. If you need a mobile watch-party app, Teleparty makes sense. If you want broad browser-player support, including self-hosted media, without giving the relay readable chat messages, KoalaSync is the simpler fit.",
"ALT_TELEPARTY_LIMITS_TITLE": "Honest limitations",
"ALT_TELEPARTY_LIMITS_BODY": "KoalaSync is not magic and it is not trying to hide its limits. It currently works only on desktop browsers. It has no built-in chat and no mobile app.<br><br>Compatibility also depends on the browser and the video player. KoalaSync should work with almost any normal browser video player, but websites can change their players, DRM behavior or browser restrictions at any time.<br><br>KoalaSync is not affiliated with Netflix, Disney+, Prime Video, YouTube, Teleparty, Emby, Jellyfin, Plex or any other brand mentioned here. The names are only used to explain typical use cases.",
"ALT_TELEPARTY_LIMITS_BODY": "KoalaSync is not magic and it is not trying to hide its limits. It currently works only on desktop browsers. It includes end-to-end encrypted text chat, but no voice, video or webcam chat, and it has no mobile app.<br><br>Compatibility also depends on the browser and the video player. KoalaSync should work with almost any normal browser video player, but websites can change their players, DRM behavior or browser restrictions at any time.<br><br>KoalaSync is not affiliated with Netflix, Disney+, Prime Video, YouTube, Teleparty, Emby, Jellyfin, Plex or any other brand mentioned here. The names are only used to explain typical use cases.",
"ALT_TELEPARTY_SUMMARY_TITLE": "Summary",
"ALT_TELEPARTY_SUMMARY_BODY": "If you want built-in chat or mobile support, Teleparty may be the better fit. If you want free, open-source desktop browser sync that works with almost any normal video player, including self-hosted media servers like Emby, Jellyfin and Plex, KoalaSync is probably the better match.",
"ALT_TELEPARTY_SUMMARY_BODY": "Both tools include text chat. If you need mobile support or prefer Teleparty's more integrated watch-party experience, Teleparty may be the better fit. If you want free, open-source desktop browser sync with end-to-end encrypted live chat that works with almost any normal video player, including self-hosted media servers like Emby, Jellyfin and Plex, KoalaSync is probably the better match.",
"ALT_SCREEN_TITLE": "KoalaSync vs screen sharing: when is video sync better?",
"ALT_SCREEN_META_DESC": "A practical comparison of KoalaSync and screen sharing for remote movie nights, playback control, video quality, privacy, subtitles and browser-based watching.",
"ALT_SCREEN_INTRO": "Screen sharing works, and sometimes it is honestly enough. One person shares their screen, everyone else watches, and you are done.<br><br>But for longer movie nights or watching episodes together, it can get annoying. The video quality depends on the sharers upload, CPU, browser, and whatever compression Discord, Teamspeak, Zoom or another call app applies. Also, only one person really controls the playback.",
@@ -222,61 +222,61 @@
"ALT_SCREEN_WHY_CLEANER_TITLE": "Why KoalaSync can feel cleaner",
"ALT_SCREEN_WHY_CLEANER_BODY": "KoalaSync does not stream your screen. Everyone watches the video locally in their own browser, and KoalaSync only keeps playback actions like play, pause and seeking in sync.<br><br>That means everyone can use fullscreen on their own device, use their own subtitle or audio settings if the player supports it, and keep the voice chat separate in Discord, Teamspeak or another call.<br><br>For movie nights, that can feel a lot cleaner than one person broadcasting their screen. It is especially useful for browser-based setups like Emby, Jellyfin, Plex, local files opened in the browser, private domains, or other websites with normal browser video players.",
"ALT_SCREEN_LIMITS_TITLE": "Honest limitations",
"ALT_SCREEN_LIMITS_BODY": "KoalaSync is not a replacement for a voice call. If you want to talk while watching, you still use Discord, Teamspeak, Signal, WhatsApp or whatever you normally use.<br><br>Everyone also needs access to the video or page on their own device. KoalaSync does not bypass subscriptions, logins, DRM, regional restrictions or platform rules.<br><br>It is currently desktop-browser only, has no built-in chat or voice feature, and compatibility always depends on the browser and the video player.",
"ALT_SCREEN_LIMITS_BODY": "KoalaSync includes end-to-end encrypted text chat, but it is not a replacement for a voice or video call. If you want to talk while watching, you still use Discord, Teamspeak, Signal, WhatsApp or whatever you normally use.<br><br>Everyone also needs access to the video or page on their own device. KoalaSync does not bypass subscriptions, logins, DRM, regional restrictions or platform rules.<br><br>It is currently desktop-browser only, has no built-in voice, video or webcam chat, and compatibility always depends on the browser and the video player.",
"ALT_SCREEN_SUMMARY_TITLE": "Summary",
"ALT_SCREEN_SUMMARY_BODY": "Screen sharing is the simplest option when you just want something quick and do not care much about quality, privacy or individual control. KoalaSync is a better fit when everyone should watch locally in their own browser and the session should feel more like actually watching together.",
"ALT_W2G_TITLE": "KoalaSync vs Watch2Gether: Which watch party solution fits better?",
"ALT_W2G_META_DESC": "An honest comparison between KoalaSync and Watch2Gether. Discover when KoalaSync's flexibility is unbeatable and why Watch2Gether is still the top choice for YouTube.",
"ALT_W2G_META_DESC": "A practical comparison of KoalaSync and Watch2Gether for shared rooms, playlists, browser-player synchronization and encrypted text chat.",
"ALT_W2G_INTRO": "KoalaSync and Watch2Gether both solve the same problem: watching videos in sync with friends online without manually coordinating. While Watch2Gether is one of the oldest and most popular hubs for shared movie nights, KoalaSync takes a completely different, lightweight approach. Which tool is right for you depends heavily on what and where you want to watch.",
"ALT_W2G_WHEN_W2G_TITLE": "When Watch2Gether is the better choice",
"ALT_W2G_WHEN_W2G_BODY": "Watch2Gether is unbeatable for <strong>YouTube watch parties</strong>. With its built-in playlist and search, anyone in the room can easily find, add, and play new videos. Everyone watches the next video together seamlessly.<br><br>With KoalaSync on YouTube, the site's default autoplay takes over, redirecting each user to a different recommended video. Our automatic next-episode feature (\"Episode Lobby\"), which works great on Netflix or Disney+, doesn't work on YouTube due to these dynamic recommendations. For pure YouTube nights, Watch2Gether is the better choice.",
"ALT_W2G_WHEN_W2G_BODY": "Watch2Gether may fit better for YouTube sessions where a shared room, search and playlist are central. Support and behavior depend on the current Watch2Gether service and content source.",
"ALT_W2G_WHEN_KOALA_TITLE": "When KoalaSync fits better",
"ALT_W2G_WHEN_KOALA_BODY": "KoalaSync shines on <strong>real streaming services</strong> (Netflix, Prime Video, Disney+) and self-hosted media servers (Jellyfin, Plex, Emby). Unlike Watch2Gether, which often cannot play premium content at all, KoalaSync syncs the native player directly on the website.<br><br>It works on almost any site with a standard HTML5 video player. You don't need to set up a virtual room on a third-party site: just activate the extension, join a lobby, and start watching.",
"ALT_W2G_WHEN_KOALA_BODY": "KoalaSync is a good fit when you want to synchronize the original player on a supported browser page, including self-hosted media, without moving playback into a separate watch-party site. Compatibility still depends on the website, player, and browser.",
"ALT_W2G_MAIN_DIFF_TITLE": "The main difference",
"ALT_W2G_MAIN_DIFF_BODY": "Watch2Gether is a <strong>standalone website</strong> with built-in chat, playlists, and webcam support. KoalaSync is a <strong>minimalist browser extension</strong> that hooks directly into your normal browser tabs.<br><br>Looking for an all-in-one package for YouTube with built-in chat? Use Watch2Gether. Want to watch movies on Netflix or your own server without distractions, in native quality (no server compression)? KoalaSync is the cleaner solution.",
"ALT_W2G_MAIN_DIFF_BODY": "Watch2Gether is a <strong>standalone website</strong> with text chat, playlists, search, and webcam support. KoalaSync is a <strong>minimalist browser extension</strong> that hooks directly into your normal browser tabs and also includes end-to-end encrypted live text chat.<br><br>Looking for an all-in-one package for YouTube with playlists, search, and webcam features? Use Watch2Gether. Want to watch movies on Netflix or your own server without distractions, in native quality (no server compression)? KoalaSync is the cleaner solution.",
"ALT_W2G_LIMITS_TITLE": "Honest limitations",
"ALT_W2G_LIMITS_BODY": "KoalaSync <strong>does not have built-in playlists or video search</strong>. On YouTube, this means that autoplaying the next video won't stay in sync.<br><br>Additionally, there is <strong>no built-in chat</strong>, and KoalaSync currently only works on desktop browsers. It is designed under the assumption that you are already talking via Discord, Teamspeak, or other voice tools anyway.",
"ALT_W2G_LIMITS_BODY": "KoalaSync <strong>does not have built-in playlists or video search</strong>. On YouTube, this means that autoplaying the next video won't stay in sync.<br><br>KoalaSync does include end-to-end encrypted live text chat, but no built-in webcam, voice or video chat, and it currently only works on desktop browsers. If you want to talk or see each other, you still need Discord, Teamspeak, or another call tool.",
"ALT_W2G_SUMMARY_TITLE": "In short",
"ALT_W2G_SUMMARY_BODY": "For spontaneous YouTube sessions with playlists and built-in chat, Watch2Gether is the best choice. For premium streaming platforms (like Netflix, Prime Video, Disney+), self-hosted servers (Jellyfin, Plex, Emby), and maximum browser flexibility, KoalaSync is the more suitable and lightweight tool.",
"ALT_W2G_SUMMARY_BODY": "Both tools include text chat. For spontaneous YouTube sessions with playlists, search, and webcam features, Watch2Gether is the better choice. For premium streaming platforms (like Netflix, Prime Video, Disney+), self-hosted servers (Jellyfin, Plex, Emby), end-to-end encrypted live chat, and maximum browser flexibility, KoalaSync is the more suitable and lightweight tool.",
"ALT_SCENER_TITLE": "KoalaSync vs Scener: Which watch party platform fits better?",
"ALT_SCENER_META_DESC": "An honest comparison of KoalaSync and Scener. Discover when Scener's webcam chat wins and why KoalaSync is best for distraction-free movie nights.",
"ALT_SCENER_INTRO": "KoalaSync and Scener approach watch parties from two completely different angles. Scener is a virtual movie theater with built-in video chat and interactive rooms. KoalaSync, on the other hand, is a minimalist, privacy-friendly browser extension that simply synchronizes playback controls. Which tool fits you best depends mostly on how important built-in webcam and chat features are to you.",
"ALT_SCENER_INTRO": "KoalaSync and Scener approach watch parties from two completely different angles. Scener is a virtual movie theater with built-in video chat and interactive rooms. KoalaSync, on the other hand, is a minimalist, privacy-friendly browser extension that synchronizes playback controls and includes end-to-end encrypted live text chat. Which tool fits you best depends mostly on whether you need built-in webcam, voice, video, and social features or prefer a lightweight sync tool.",
"ALT_SCENER_WHEN_SCENER_TITLE": "When Scener is the better choice",
"ALT_SCENER_WHEN_SCENER_BODY": "Scener is a solid choice if you want an <strong>all-in-one social platform</strong> with built-in video and audio chat directly in your browser. Scener creates a virtual room where the player and your friends' webcams are displayed side-by-side. This is ideal for large, public watch parties or virtual streaming events where the social aspect is front and center. However, keep in mind that the free basic version is ad-supported, and Scener locks many features (such as ad-free viewing and unlimited video chat) behind a monthly subscription ($5.79/month or $39.99/year).",
"ALT_SCENER_WHEN_SCENER_BODY": "Scener is the better fit if you want an integrated social experience with text chat and, for Premium members, video and audio chat. On computers it supports a defined set of streaming services through its Chrome extension.",
"ALT_SCENER_WHEN_KOALA_TITLE": "When KoalaSync fits better",
"ALT_SCENER_WHEN_KOALA_BODY": "KoalaSync shines when it comes to <strong>video quality, privacy, and simplicity</strong>:<br><br>• <strong>No accounts</strong>: You don't need to register, log in, or manage profiles. Just activate the extension, share a room code, and start watching.<br>• <strong>True native quality (4K/HDR)</strong>: Scener streams compressed video from a virtual cloud server, which can lead to quality loss and lagging. KoalaSync synchronizes the native player directly on your computer, meaning you watch movies in the full resolution of your streaming subscription.<br>• <strong>Low bandwidth usage</strong>: Because only control data (play, pause, timestamps) is transmitted, KoalaSync uses virtually no extra bandwidth. Scener streams the entire video and camera feeds over cloud servers, which can heavily strain slower internet connections and older laptops.<br>• <strong>100% free and ad-free</strong>: No monthly subscription fees, no advertisements, and no trackers.<br>• <strong>Local video files</strong>: Since KoalaSync runs locally, you can even watch your own video files (like MP4) in sync with friends. Just drag and drop the file into a browser tab Scener cannot do this because its player runs in the cloud.",
"ALT_SCENER_WHEN_KOALA_BODY": "KoalaSync fits if you prefer account-free, open-source synchronization in the original browser player, end-to-end encrypted live text chat, and an optional self-hosted relay. It does not include voice, video, or webcam chat.",
"ALT_SCENER_MAIN_DIFF_TITLE": "The main difference",
"ALT_SCENER_MAIN_DIFF_BODY": "Scener is a <strong>commercial social network</strong> for watch parties that is powered by virtual cloud browsers and funded through ads and monthly subscriptions. KoalaSync is a <strong>privacy-first, free synchronization utility</strong> that hooks directly into your browser tabs. It leaves voice/video chat to applications like Discord or Teamspeak, focusing entirely on keeping your playback fluid, private, and in its native original quality.",
"ALT_SCENER_MAIN_DIFF_BODY": "Scener focuses on integrated rooms and social features. KoalaSync focuses on lightweight synchronization in the original browser player and end-to-end encrypted live text chat.",
"ALT_SCENER_LIMITS_TITLE": "Honest limitations",
"ALT_SCENER_LIMITS_BODY": "KoalaSync offers <strong>no built-in chat or webcam support</strong>. It is purely a synchronization tool and requires a separate voice call tool of your choice. Additionally, KoalaSync is currently desktop-only, and local video file compatibility depends entirely on whether your browser natively supports playing the video format and audio codecs.",
"ALT_SCENER_LIMITS_BODY": "KoalaSync offers <strong>end-to-end encrypted live text chat, but no webcam, voice, or video chat</strong>. Talking or seeing each other therefore requires a separate call tool of your choice. Additionally, KoalaSync is currently desktop-only, and local video file compatibility depends entirely on whether your browser natively supports playing the video format and audio codecs.",
"ALT_SCENER_SUMMARY_TITLE": "In short",
"ALT_SCENER_SUMMARY_BODY": "For large watch parties with built-in webcam chat and social features, Scener is the right choice, provided you don't mind ads and premium paywalls. If you want a completely free, ad-free, and distraction-free experience in the native player (including Netflix, Prime Video, Disney+, or local browser files) and you are already in a voice call, KoalaSync is the cleaner and more performant solution.",
"ALT_SCENER_SUMMARY_BODY": "Choose Scener for integrated social, video, and audio features on its supported services. Choose KoalaSync for account-free, open-source browser synchronization, encrypted live text chat, and optional self-hosting.",
"ALT_KOSMI_TITLE": "KoalaSync vs Kosmi: Which Netflix party alternative fits better?",
"ALT_KOSMI_META_DESC": "An honest comparison of KoalaSync and Kosmi. Discover why Kosmi's no-extension setup is convenient and when KoalaSync's native video quality wins.",
"ALT_KOSMI_INTRO": "Both KoalaSync and Kosmi offer great ways to watch Netflix, YouTube, and other platforms in sync with friends. However, their underlying technologies are fundamentally different. Kosmi uses virtual cloud browsers that run directly in your web browser without installing extensions. KoalaSync is a lightweight extension that synchronizes your local video player. Which approach fits you best depends on your priorities regarding video quality, convenience, and privacy.",
"ALT_KOSMI_INTRO": "KoalaSync and Kosmi both support watching together, but they are separate products with different supported services and social features. KoalaSync synchronizes a supported video player in your desktop browser. Check each service for its current device and platform support.",
"ALT_KOSMI_WHEN_KOSMI_TITLE": "When Kosmi is the better choice",
"ALT_KOSMI_WHEN_KOSMI_BODY": "Kosmi is unbeatable if you want to <strong>avoid installing anything</strong> or want to watch on devices like tablets and smartphones. Because Kosmi runs virtual browsers in the cloud, all it takes to join is a simple web link. Furthermore, Kosmi is an all-in-one hangout platform: in addition to synchronized streaming, you can play multiplayer games (like retro NES/SNES classics or poker) and use the built-in webcam, voice, and text chat. If you want a social game night and watch party without setup, Kosmi is a fantastic solution.",
"ALT_KOSMI_WHEN_KOSMI_BODY": "Kosmi may fit better if you want its browser-based social rooms, games, and built-in communication features. Availability can vary by device, content source, and current Kosmi plan.",
"ALT_KOSMI_WHEN_KOALA_TITLE": "When KoalaSync fits better",
"ALT_KOSMI_WHEN_KOALA_BODY": "KoalaSync shines when it comes to <strong>native playback quality, security, and performance</strong>:<br><br>• <strong>No security risks</strong>: With Kosmi, you have to log into your Netflix or Prime account on a remote virtual computer in the cloud. With KoalaSync, you log in securely on the official streaming website in your own browser, just like you normally do. Your passwords never leave your machine.<br>• <strong>Full video quality (4K/HDR)</strong>: Kosmi streams the virtual browser's screen as a compressed video feed, which can result in pixelation and buffering. KoalaSync syncs the native players locally: you enjoy movies in the full, crystal-clear resolution of your subscription.<br>• <strong>Lag-free & bandwidth-friendly</strong>: Since KoalaSync only sends control commands (play/pause/timestamps), it uses almost zero bandwidth. Kosmi constantly streams HD video feeds, which can overwhelm slower connections.<br>• <strong>Local video files</strong>: If you want to watch a private video, just drag and drop the MP4 file into a browser tab and sync it. Kosmi cannot access files on your local hard drive.",
"ALT_KOSMI_WHEN_KOALA_BODY": "KoalaSync fits better if you want to synchronize a supported player in your own desktop browser, use end-to-end encrypted live text chat, or self-host the relay. It does not include voice, video, webcam chat, or a mobile app.",
"ALT_KOSMI_MAIN_DIFF_TITLE": "The main difference",
"ALT_KOSMI_MAIN_DIFF_BODY": "Kosmi is a <strong>virtual cloud hangout</strong> that streams a remote browser's screen so no one has to install an extension. KoalaSync is a <strong>local browser sync layer</strong> that hooks directly into the native player on the official streaming website. While Kosmi is more versatile for mini-games and device compatibility, KoalaSync offers a more secure, fluid, and sharp streaming experience in the original player.",
"ALT_KOSMI_MAIN_DIFF_BODY": "Kosmi focuses on an all-in-one social room. KoalaSync is a desktop browser extension that synchronizes supported players and provides encrypted live text chat.",
"ALT_KOSMI_LIMITS_TITLE": "Honest limitations",
"ALT_KOSMI_LIMITS_BODY": "KoalaSync <strong>requires installing a browser extension</strong> and currently only runs on desktop PCs. It <strong>has no built-in chat or webcams</strong>, as it is designed under the assumption that you are already talking via Discord or other voice tools anyway. It also does not offer built-in games.",
"ALT_KOSMI_LIMITS_BODY": "KoalaSync <strong>requires installing a browser extension</strong> and currently only runs on desktop PCs. It includes <strong>end-to-end encrypted live text chat, but no webcam, voice, or video chat</strong>. It also does not offer built-in games.",
"ALT_KOSMI_SUMMARY_TITLE": "In short",
"ALT_KOSMI_SUMMARY_BODY": "For casual game nights with retro classics and easy access on mobile devices without any installation, Kosmi is ideal. For premium movie nights on Netflix, Prime Video, Disney+, or Plex in full 4K quality, with maximum security and minimal bandwidth impact, KoalaSync is the clear winner.",
"ALT_KOSMI_SUMMARY_BODY": "Choose Kosmi for its integrated social-room features. Choose KoalaSync for account-free, open-source desktop synchronization, encrypted live text chat, and optional relay self-hosting.",
"ALT_TWOSEVEN_TITLE": "KoalaSync vs TwoSeven: Which watch party solution fits better?",
"ALT_TWOSEVEN_META_DESC": "An honest comparison of KoalaSync and TwoSeven. Discover when TwoSeven's built-in webcam wins and why KoalaSync is best for Disney+ and Netflix.",
"ALT_TWOSEVEN_INTRO": "Both KoalaSync and TwoSeven use the same basic approach for watch parties: they synchronize the original video player directly in your browser, rather than streaming a compressed video feed from the cloud. This ensures top-tier video quality for both tools. However, while TwoSeven is a commercial portal with built-in webcam chat and subscription tiers, KoalaSync remains a minimalist, completely free, and privacy-friendly extension. Find out which service fits you best here.",
"ALT_TWOSEVEN_META_DESC": "A practical comparison of KoalaSync and TwoSeven, including account requirements, integrated communication, encrypted text chat and self-hosting.",
"ALT_TWOSEVEN_INTRO": "Both tools support synchronized viewing, but their current service support and requirements can change. TwoSeven is an account-based platform with integrated communication features. KoalaSync is an account-free extension with encrypted live text chat and no built-in calls.",
"ALT_TWOSEVEN_WHEN_TWOSEVEN_TITLE": "When TwoSeven is the better choice",
"ALT_TWOSEVEN_WHEN_TWOSEVEN_BODY": "TwoSeven is the right choice if you want an <strong>all-in-one solution with integrated webcam and audio chat</strong> directly in your browser. If you want to see your friends while watching without having to run Discord or Teamspeak in the background, TwoSeven offers this integration natively. Additionally, it works for YouTube and a few open web video players without extensions. It is well-suited for small groups of 2 to 3 people who want to share a visual connection while viewing.",
"ALT_TWOSEVEN_WHEN_TWOSEVEN_BODY": "TwoSeven may fit better if its integrated webcam and audio features are important to you. Check TwoSevens current browser, account, plan and supported-service requirements before choosing it.",
"ALT_TWOSEVEN_WHEN_KOALA_TITLE": "When KoalaSync fits better",
"ALT_TWOSEVEN_WHEN_KOALA_BODY": "KoalaSync shines with its <strong>complete lack of fees, privacy, and simplicity</strong>:<br><br>• <strong>No paywalls</strong>: To watch premium services like Disney+ or Hulu in sync on TwoSeven, at least one room member must support them on Patreon (starting at $3/month). KoalaSync synchronizes Netflix, Prime Video, Disney+, and self-hosted media servers (Jellyfin, Plex, Emby) 100% free with zero restrictions.<br>• <strong>Absolute privacy (No trackers/ads)</strong>: TwoSeven is ad-supported and uses multiple trackers from Google, Facebook, and others. KoalaSync has no ads, no trackers, and communicates exclusively with the RAM-only relay server.<br>• <strong>No registration required</strong>: TwoSeven requires all participants to create an account and log in. KoalaSync is completely account-free. Just install, enter a room code, and start watching.<br>• <strong>Lightweight and resource-friendly</strong>: TwoSeven's integrated webcam and audio feeds place a heavy load on your CPU and network. By leaving communication to tools like Discord, KoalaSync runs extremely smoothly and preserves battery life.",
"ALT_TWOSEVEN_WHEN_KOALA_BODY": "KoalaSync fits if you want account-free, open-source synchronization, end-to-end encrypted live text chat, and the option to self-host the relay. Voice and webcam calls remain in a separate application.",
"ALT_TWOSEVEN_MAIN_DIFF_TITLE": "The main difference",
"ALT_TWOSEVEN_MAIN_DIFF_BODY": "TwoSeven is a <strong>commercial platform</strong> with registration requirements, ads, peer-to-peer webcam chats, and Patreon paywalls for specific streaming services. KoalaSync is a <strong>free, open-source utility</strong> that hooks directly into your browser tabs without accounts, trackers, or extra costs, allowing for distraction-free watching in the native player.",
"ALT_TWOSEVEN_MAIN_DIFF_BODY": "TwoSeven combines synchronized viewing with an account-based platform and integrated webcam and audio features. KoalaSync is an account-free browser extension with encrypted live text chat; voice and video calls remain separate.",
"ALT_TWOSEVEN_LIMITS_TITLE": "Honest limitations",
"ALT_TWOSEVEN_LIMITS_BODY": "KoalaSync offers <strong>no built-in webcam or audio chat</strong> and assumes that you are already on a call via Discord or a similar tool. Additionally, KoalaSync has no built-in chat box overlay and is currently desktop-only.",
"ALT_TWOSEVEN_LIMITS_BODY": "KoalaSync offers <strong>no built-in webcam or audio chat</strong>, so seeing or talking to each other still requires Discord or a similar tool. It does include a floating and dockable end-to-end encrypted live text-chat overlay and is currently desktop-only.",
"ALT_TWOSEVEN_SUMMARY_TITLE": "In short",
"ALT_TWOSEVEN_SUMMARY_BODY": "For small groups of two or three people who want to see each other via webcams directly in the browser and don't mind supporting a Patreon for Disney+ support, TwoSeven is a nice option. For ad-free, completely free, and private movie nights in native 4K quality (whether Netflix, Prime Video, Disney+, or Jellyfin) without registrations, KoalaSync is the cleaner and safer choice.",
"ALT_TWOSEVEN_SUMMARY_BODY": "Choose TwoSeven if integrated webcam and audio features matter most. Choose KoalaSync for account-free, open-source synchronization, encrypted live text chat, and optional self-hosting.",
"ALT_TRY_KOALASYNC": "Try KoalaSync",
"MOCK_HOST_CONTROL_LABEL": "Host Control",
"MOCK_HOST_CONTROL_TOGGLE": "Only I can control playback",
@@ -288,5 +288,9 @@
"SELF_SHOW_SETUP": "Show setup guide",
"SELF_HIDE_SETUP": "Hide setup guide",
"HERO_TRUST": "Free · Open source · No account needed",
"COMP_MORE_GUIDES": "Full comparison and more alternatives"
"COMP_MORE_GUIDES": "Full comparison and more alternatives",
"NAV_HOME": "Home",
"ARIA_SELECT_LANGUAGE": "Select language",
"ARIA_MENU": "Menu",
"FOOTER_GUIDES": "Guides & comparisons"
}
+52 -48
View File
@@ -14,11 +14,11 @@
"TWITTER_DESCRIPTION": "Organiza una watch party en Netflix, Emby, Jellyfin, YouTube, Twitch y casi cualquier vídeo HTML5. Open source, privada y sin cuenta.",
"NAV_FEATURES": "Características",
"NAV_HOW_IT_WORKS": "Cómo funciona",
"SUPPORT_BANNER_TEXT": "¿KoalaSync no puede acceder al sitio del vídeo?",
"SUPPORT_BANNER_CTA": "Reparar acceso al sitio",
"SUPPORT_BANNER_TEXT": "¿Necesitas ayuda con KoalaSync?",
"SUPPORT_BANNER_CTA": "Abrir el centro de ayuda",
"SUPPORT_BANNER_DISMISS": "Cerrar aviso",
"HERO_TITLE": "Convierte cualquier vídeo en una <span class='hero-highlight'>watch party</span>.",
"HERO_SUBTITLE": "Tu noche de cine a distancia, sin retrasos. Sin cuenta, sin rastreo, sin anuncios. Comparte un enlace y dale al play juntos.",
"HERO_SUBTITLE": "Ved vídeos juntos a distancia. Sin cuenta, seguimiento ni anuncios. Comparte un enlace y empezad juntos.",
"HERO_MASCOT_ALT": "Un lindo koala de pie mirando hacia abajo a los botones de descarga",
"DEMO_SYNC": "Sincronizado",
"DEMO_HINT": "Demo en vivo: pulsa play en cualquiera de las pestañas y KoalaSync mantiene ambas sincronizadas.",
@@ -42,7 +42,7 @@
"USE_CASE_1_DESC": "Sincroniza tus películas en tiempo real y habla por Discord, Zoom o tu aplicación de voz favorita. Es como estar en la misma habitación.",
"USE_CASE_2_ALT": "Un profesor koala presentando en una pantalla a dos estudiantes koala remotos",
"USE_CASE_2_TITLE": "Aprendizaje a distancia",
"USE_CASE_2_DESC": "Analiza tutoriales en línea, conferencias o directos de programación con compañeros o colegas. Pausa y discute pasos complejos en perfecta armonía.",
"USE_CASE_2_DESC": "Analizad juntos tutoriales, clases o vídeos de formación. Pausad y comentad los pasos complejos.",
"USE_CASE_3_ALT": "Una linda pareja de koalas viendo juntos de forma remota; uno está sentado frente a una PC y el otro en la cama con una computadora portátil, con corazones volando entre ellos",
"USE_CASE_3_TITLE": "Relaciones a larga distancia",
"USE_CASE_3_DESC": "Reduce la distancia y disfruta de noches de citas. Vive cada giro de la trama, ríete de los mismos chistes y comparte momentos emotivos en el mismo milisegundo exacto.",
@@ -115,8 +115,8 @@
"STEP_2_ILLUS_CREATE": "+ Crear nueva sala",
"STEP_2_ILLUS_MANUAL": "Conexión manual / Avanzado",
"STEP_2_ILLUS_COPIED": "¡Enlace de invitación copiado!",
"STEP_3_TITLE": "Disfrutar de la sincronización",
"STEP_3_DESC": "Pulsa reproducir. Cuando alguien pause, avance o reproduzca, el vídeo de todos se mantendrá en sincronía en tiempo real y sin retrasos.",
"STEP_3_TITLE": "Vedlo juntos",
"STEP_3_DESC": "Pulsa reproducir. Los cambios se comparten con todos los participantes; la red y el reproductor pueden causar un breve retraso.",
"STEP_3_ILLUS_IN_SYNC": "SINCRONIZADO",
"SELF_TITLE": "Para auto-alojadores",
"SELF_SUBTITLE": "¿No te fías de nuestro servidor? Mantén la soberanía total de tus datos. Despliega tu propio servidor de retransmisión en minutos.",
@@ -127,33 +127,33 @@
"BOTTOM_SUBTITLE": "KoalaSync es 100% de código abierto, sin anuncios y sin rastreadores. Audita nuestro código en GitHub o instala la extensión directamente.",
"BOTTOM_MASCOT_ALT": "Un lindo koala junto a Octocat, la mascota de GitHub",
"FAQ_TITLE": "Preguntas frecuentes",
"FAQ_SUBTITLE": "Todo lo que necesitas saber para ver vídeos en perfecta sincronía.",
"FAQ_SUBTITLE": "Respuestas sobre sitios compatibles, sincronización, privacidad, chat y procesamiento de audio.",
"FAQ_Q1": "¿Puedo ver Netflix, Emby o Jellyfin sincronizado con amigos?",
"FAQ_A1": "¡Sí! KoalaSync funciona en Netflix, YouTube, Twitch y cualquier web con reproductor HTML5, incluyendo Emby, Jellyfin y servidores multimedia propios. Instala la extensión, crea una sala, comparte el enlace y todos veréis lo mismo al mismo tiempo.",
"FAQ_A1": "KoalaSync funciona con muchos sitios compatibles que usan un reproductor de vídeo estándar del navegador, como Netflix, YouTube, Twitch, Emby, Jellyfin y servidores multimedia autohospedados. La compatibilidad depende del sitio, el navegador y el reproductor, y puede cambiar cuando se actualiza un sitio.",
"FAQ_Q_VIDEO_ACCESS": "¿KoalaSync transmite el video a mis amigos?",
"FAQ_A_VIDEO_ACCESS": "No. KoalaSync no transmite, aloja, comparte ni evita el acceso al contenido de video. Cada persona ve el video localmente en su propio navegador y necesita su propio acceso al sitio web o servicio de streaming, por ejemplo su propia cuenta de Netflix. KoalaSync solo sincroniza acciones de reproducción como reproducir, pausar y avanzar o retroceder.",
"FAQ_Q2": "¿Es KoalaSync una alternativa gratuita a Teleparty?",
"FAQ_A2": "Es 100% gratuito y de código abierto (Licencia MIT). Sin suscripciones, sin funciones bloqueadas y sin anuncios. A diferencia de Teleparty, el servidor de KoalaSync solo usa RAM: no hay bases de datos ni cuentas de usuario.",
"FAQ_A2": "Sí. KoalaSync es gratuito y de código abierto bajo la licencia MIT, sin plan prémium, funciones bloqueadas, anuncios ni cuentas obligatorias.",
"FAQ_Q3": "¿Funciona KoalaSync con Emby, Jellyfin y servidores propios?",
"FAQ_A3": "Sí. KoalaSync detecta cualquier elemento de video HTML5 estándar, por lo que es perfecto para Emby, Jellyfin, Plex y cualquier plataforma de streaming personalizada. También puedes alojar tu propio servidor con Docker.",
"FAQ_A3": "Sí. KoalaSync admite muchos reproductores de vídeo estándar usados por Emby, Jellyfin, Plex y sistemas de streaming autohospedados. La compatibilidad depende del reproductor. También puedes alojar el servidor relay de KoalaSync con Docker.",
"FAQ_Q4": "¿Qué navegadores y plataformas son compatibles?",
"FAQ_A4": "Google Chrome, Mozilla Firefox, Microsoft Edge, Opera, Brave y todos los navegadores basados en Chromium. Instálalo directamente desde las tiendas oficiales sin configuraciones manuales.",
"FAQ_Q5": "¿Todos mis amigos necesitan tener la extensión instalada?",
"FAQ_A5": "Sí, cada participante necesita KoalaSync, pero no hace falta registrarse. Solo hay que hacer clic en el enlace, instalar la extensión si no se tiene y la unión a la sala es automática.",
"FAQ_Q6": "¿Están seguros mis datos? ¿Y si no confío en el servidor oficial?",
"FAQ_A6": "Totalmente seguros. El servidor de KoalaSync funciona solo en RAM: todos los datos de la sesión se borran permanentemente en cuanto sales de la sala. Sin registros ni analíticas. Si prefieres el control total, puedes auto-alojar tu propio servidor privado mediante Docker.",
"FAQ_A6": "El relay oficial de KoalaSync no guarda historiales de salas, sincronización, reproducción ni chat en una base de datos. Los datos de una sala activa solo permanecen temporalmente en memoria y se eliminan cuando la sala queda vacía o caduca por inactividad. Pueden generarse registros técnicos de conexión para seguridad y prevención de abusos; su conservación depende de la configuración del servidor. Los mensajes del chat están cifrados de extremo a extremo y no se guardan como historial en el servidor.",
"FAQ_Q7": "¿Puedo arreglar los diálogos bajos y las explosiones fuertes al ver vídeos?",
"FAQ_A7": "Sí. KoalaSync incluye un compresor de audio integrado que equilibra las partes bajas y altas del sonido. Funciona localmente mediante la Web Audio API: ningún dato de audio sale de tu equipo. Ve a Configuración → Procesamiento de audio y activa el compresor.",
"FAQ_A7": "Sí. KoalaSync incluye un compresor de audio que reduce la diferencia entre las partes suaves y fuertes del vídeo. Funciona localmente mediante Web Audio API, por lo que el audio no se envía al relay. Actívalo en Configuración → Procesamiento de audio.",
"HOWTO_TITLE": "Cómo sincronizar videos con amigos usando KoalaSync",
"HOWTO_DESC": "Mira cualquier video en sincronía con amigos en YouTube, Netflix, Twitch y más. Sin registros ni rastreo.",
"HOWTO_DESC": "Ved vídeos compatibles juntos en YouTube, Netflix, Twitch y más. Sin registro ni seguimiento.",
"HOWTO_STEP_1_NAME": "Instala la extensión",
"HOWTO_STEP_1_TEXT": "Añade KoalaSync a tu navegador desde la Chrome Web Store o Firefox Add-ons. Funciona en cualquier web con reproductor de vídeo.",
"HOWTO_STEP_1_TEXT": "Instala KoalaSync desde Chrome Web Store o Firefox Add-ons. Abre una página compatible y selecciona un vídeo detectado por la extensión.",
"HOWTO_STEP_2_NAME": "Crea una sala",
"HOWTO_STEP_2_TEXT": "Abre la extensión y haz clic en 'Crear nueva sala'. El enlace de invitación se copiará automáticamente.",
"HOWTO_STEP_3_NAME": "Comparte y sincroniza",
"HOWTO_STEP_3_TEXT": "Envía el enlace a tus amigos. Selecciona la pestaña del video y disfrutad de la reproducción en tiempo real.",
"FOOTER_MIT": "Código abierto bajo la Licencia MIT.",
"FOOTER_RAM": "No almacenamos datos. Retransmisión pura basada en RAM.",
"FOOTER_RAM": "Sin historial de chat ni reproducción. Sin seguimiento. Los registros de acceso web se eliminan después de 7 días.",
"FOOTER_LEGAL": "Aviso Legal",
"FOOTER_PRIVACY": "Política de Privacidad",
"MOCK_01": "Sala activa",
@@ -191,7 +191,7 @@
"FOOTER_LEGAL_LINK": "imprint",
"FOOTER_PRIVACY_LINK": "privacy",
"FOOTER_DISCLAIMER": "KoalaSync no está afiliado, respaldado ni asociado con Netflix, Disney+, Amazon, YouTube, Twitch ni ninguna otra plataforma de streaming. Todas las marcas comerciales pertenecen a sus respectivos dueños.",
"FOOTER_SUPPORT": "Support KoalaSync",
"FOOTER_SUPPORT": "Apoyar KoalaSync",
"ALT_INDEX_TITLE": "KoalaSync - Alternativas y comparativas",
"ALT_INDEX_META_DESC": "Explora comparativas honestas y guías detalladas entre KoalaSync y otras extensiones de watch party y soluciones de streaming.",
"ALT_INDEX_HEADING": "Guías y comparativas",
@@ -201,17 +201,17 @@
"ALT_INDEX_FOOTER_LINK": "Guías y comparativas",
"ALT_TELEPARTY_TITLE": "KoalaSync frente a Teleparty: ¿qué herramienta de visionado conjunto deberías usar?",
"ALT_TELEPARTY_META_DESC": "Una comparativa práctica entre KoalaSync y Teleparty para ver contenido en grupo, analizando aspectos como el chat integrado, la compatibilidad móvil, la sincronización de vídeo en el navegador, los servidores multimedia autohospedados y el uso de código abierto.",
"ALT_TELEPARTY_INTRO": "Tanto KoalaSync como Teleparty ayudan a resolver el mismo problema molesto: ver algo juntos sin tener que recurrir a la típica rutina de «3, 2, 1, ¡reproducir!». Simplemente adoptan enfoques diferentes.<br><br>Teleparty es la opción más lógica si buscas un chat integrado o si quieres ver contenido desde un dispositivo móvil. KoalaSync está más orientado a sesiones de visionado en navegadores de escritorio, donde ya te comunicas a través de Discord, Teamspeak u otra aplicación de voz y solo necesitas que la reproducción se mantenga sincronizada.<br><br>La razón principal por la que creé KoalaSync fue la flexibilidad. Está diseñado para funcionar con casi cualquier sitio web que utilice un reproductor de vídeo estándar de navegador, no solo con una lista fija de plataformas. Esto incluye configuraciones autohospedadas como Emby, Jellyfin y Plex, dominios locales o privados y muchas páginas de vídeo convencionales.",
"ALT_TELEPARTY_INTRO": "Tanto KoalaSync como Teleparty ayudan a resolver el mismo problema molesto: ver algo juntos sin tener que recurrir a la típica rutina de «3, 2, 1, ¡reproducir!». Simplemente adoptan enfoques diferentes.<br><br>Teleparty tiene más sentido si deseas una experiencia de visualización en grupo más integrada o si quieres mirar en el móvil. KoalaSync está diseñado para grupos de visualización de navegadores de escritorio en una gama más amplia de reproductores de vídeo de navegador normales. Su chat de texto integrado está cifrado de extremo a extremo y es solo en vivo, mientras que las llamadas de voz o video permanecen separadas.<br><br>La razón principal por la que creé KoalaSync fue la flexibilidad. Está diseñado para funcionar con casi cualquier sitio web que utilice un reproductor de vídeo estándar de navegador, no solo con una lista fija de plataformas. Esto incluye configuraciones autohospedadas como Emby, Jellyfin y Plex, dominios locales o privados y muchas páginas de vídeo convencionales.",
"ALT_TELEPARTY_WHEN_TELE_TITLE": "Cuándo puede seguir siendo útil Teleparty",
"ALT_TELEPARTY_WHEN_TELE_BODY": "Teleparty puede seguir siendo la mejor opción si prefieres que el chat forme parte de la propia herramienta de visionado. Actualmente, KoalaSync no cuenta con chat integrado; parte de la premisa de que ya te estás comunicando por otro medio, como Discord, Teamspeak, WhatsApp, Signal o una llamada convencional.<br><br>La compatibilidad móvil es otro factor clave. Teleparty dispone de aplicación propia, mientras que KoalaSync, por ahora, solo funciona en navegadores de escritorio. Si quieres sincronizar Netflix en un teléfono o tableta, KoalaSync no es la herramienta adecuada en este momento.<br><br>Por tanto, si el chat y la compatibilidad móvil son tus prioridades, es posible que Teleparty se adapte mejor a tus necesidades.",
"ALT_TELEPARTY_WHEN_TELE_BODY": "Teleparty aún puede ser la mejor opción si prefieres una experiencia de visualización más integrada o si necesitas soporte móvil. Ambas herramientas incluyen chat de texto, por lo que el chat por sí solo ya no es la diferencia decisiva. El chat de KoalaSync está cifrado de extremo a extremo y es solo en vivo; Las llamadas de voz y vídeo siguen utilizando una aplicación independiente.<br><br>La compatibilidad móvil es otro factor clave. Teleparty dispone de aplicación propia, mientras que KoalaSync, por ahora, solo funciona en navegadores de escritorio. Si quieres sincronizar Netflix en un teléfono o tableta, KoalaSync no es la herramienta adecuada en este momento.<br><br>Entonces, si lo que más le importa es soporte móvil y una plataforma más integrada, Teleparty simplemente puede adaptarse mejor a su configuración.",
"ALT_TELEPARTY_WHEN_KOALA_TITLE": "Cuándo es mejor usar KoalaSync",
"ALT_TELEPARTY_WHEN_KOALA_BODY": "KoalaSync es una mejor opción si buscas una herramienta flexible para sincronizar la reproducción de vídeo en navegadores de escritorio, en lugar de algo limitado a una lista reducida de plataformas.<br><br>Funciona con casi cualquier sitio web que utilice el reproductor de vídeo estándar del navegador. Esto resulta especialmente útil para Emby, Jellyfin, Plex, servidores multimedia privados, dominios locales o sitios de vídeo más pequeños que las herramientas convencionales de visionado compartido suelen ignorar.<br><br>Además, es gratuito, de código abierto, no requiere crear cuentas y permite el autohospedaje si deseas gestionar tu propio servidor de retransmisión (relay). Lo creé porque buscaba una solución respetuosa con la privacidad que funcionara con mi propia configuración de Emby, y no otra plataforma cerrada limitada a unos pocos grandes servicios de streaming.",
"ALT_TELEPARTY_MAIN_DIFF_TITLE": "La diferencia principal",
"ALT_TELEPARTY_MAIN_DIFF_BODY": "Teleparty está concebido principalmente como una herramienta para ver contenido en grupo, con funciones de chat y compatibilidad móvil. KoalaSync, en cambio, funciona más bien como una capa flexible de sincronización de vídeo para navegadores.<br><br>Puede parecer una diferencia menor, pero cambia considerablemente el caso de uso. Si necesitas una aplicación completa para ver contenido en grupo que incluya chat integrado, Teleparty es la opción lógica. Si ya dispones de chat de voz y solo quieres mantener sincronizados los controles de vídeo entre distintos navegadores, KoalaSync es la solución más sencilla.",
"ALT_TELEPARTY_MAIN_DIFF_BODY": "Teleparty es una plataforma de visionado en grupo más integrada y compatible con dispositivos móviles. KoalaSync es una capa flexible de sincronización para navegadores de escritorio con chat de texto en vivo cifrado de extremo a extremo.<br><br>Puede parecer una diferencia menor, pero cambia considerablemente el caso de uso. Si necesitas una aplicación móvil para ver contenido en grupo, Teleparty tiene sentido. Si buscas compatibilidad con una amplia variedad de reproductores de navegador, incluidos servidores multimedia autohospedados, sin entregar mensajes de chat legibles al servidor relay, KoalaSync es la opción más sencilla.",
"ALT_TELEPARTY_LIMITS_TITLE": "Limitaciones reales",
"ALT_TELEPARTY_LIMITS_BODY": "KoalaSync no hace milagros ni pretende ocultar sus limitaciones. Actualmente, solo funciona en navegadores de escritorio; no cuenta con chat integrado ni aplicación móvil.<br><br>La compatibilidad también depende del navegador y del reproductor de vídeo utilizado. Aunque KoalaSync debería funcionar con casi cualquier reproductor estándar de navegador, los sitios web pueden modificar sus reproductores, el comportamiento del DRM o las restricciones del navegador en cualquier momento.<br><br>KoalaSync no tiene ninguna relación con Netflix, Disney+, Prime Video, YouTube, Teleparty, Emby, Jellyfin, Plex ni ninguna otra marca mencionada aquí. Dichos nombres se utilizan únicamente para ilustrar casos de uso habituales.",
"ALT_TELEPARTY_LIMITS_BODY": "KoalaSync no es mágico y no intenta ocultar sus límites. Actualmente sólo funciona en navegadores de escritorio. Incluye chat de texto cifrado de extremo a extremo, pero no chat de voz, video o cámara web, y no tiene aplicación móvil.<br><br>La compatibilidad también depende del navegador y del reproductor de vídeo utilizado. Aunque KoalaSync debería funcionar con casi cualquier reproductor estándar de navegador, los sitios web pueden modificar sus reproductores, el comportamiento del DRM o las restricciones del navegador en cualquier momento.<br><br>KoalaSync no tiene ninguna relación con Netflix, Disney+, Prime Video, YouTube, Teleparty, Emby, Jellyfin, Plex ni ninguna otra marca mencionada aquí. Dichos nombres se utilizan únicamente para ilustrar casos de uso habituales.",
"ALT_TELEPARTY_SUMMARY_TITLE": "Resumen",
"ALT_TELEPARTY_SUMMARY_BODY": "Si buscas chat integrado o compatibilidad con dispositivos móviles, Teleparty podría ser la mejor opción. Si prefieres una solución gratuita y de código abierto para sincronizar navegadores de escritorio que funcione con casi cualquier reproductor de vídeo convencional, incluidos servidores multimedia de alojamiento propio como Emby, Jellyfin y Plex, KoalaSync es probablemente la alternativa más adecuada.",
"ALT_TELEPARTY_SUMMARY_BODY": "Ambas herramientas incluyen chat de texto. Si necesita soporte móvil o prefiere la experiencia de visualización en grupo más integrada de Teleparty, Teleparty puede ser la mejor opción. Si desea una sincronización gratuita y de código abierto del navegador de escritorio con chat en vivo cifrado de extremo a extremo que funcione con casi cualquier reproductor de video normal, incluidos servidores multimedia autohospedados como Emby, Jellyfin y Plex, KoalaSync es probablemente la mejor opción.",
"ALT_SCREEN_TITLE": "KoalaSync frente a compartir pantalla: ¿cuándo es mejor la sincronización de vídeo?",
"ALT_SCREEN_META_DESC": "Una comparativa práctica entre KoalaSync y la función de compartir pantalla para noches de cine a distancia, analizando aspectos como el control de reproducción, la calidad de vídeo, la privacidad, los subtítulos y la visualización desde el navegador.",
"ALT_SCREEN_INTRO": "Compartir pantalla funciona y, sinceramente, a veces es suficiente. Una persona comparte su pantalla, las demás la ven y listo.<br><br>Sin embargo, para sesiones de cine más largas o para ver episodios en grupo, puede resultar molesto. La calidad del vídeo depende de la velocidad de subida, la CPU y el navegador de quien comparte, así como de la compresión que apliquen aplicaciones como Discord, Teamspeak, Zoom u otras plataformas de videollamada. Además, solo una persona tiene el control real de la reproducción.",
@@ -222,61 +222,61 @@
"ALT_SCREEN_WHY_CLEANER_TITLE": "Por qué KoalaSync puede resultar más limpio",
"ALT_SCREEN_WHY_CLEANER_BODY": "KoalaSync no transmite tu pantalla. Cada persona ve el video localmente en su propio navegador, y KoalaSync solo sincroniza las acciones de reproducción, como iniciar, pausar y avanzar o retroceder.<br><br>Esto significa que todos pueden usar la pantalla completa en su propio dispositivo, utilizar sus propios ajustes de subtítulos o audio (si el reproductor lo permite) y mantener el chat de voz separado en Discord, Teamspeak o cualquier otra aplicación de llamadas.<br><br>Para las noches de cine, esto puede resultar mucho más limpio que tener a una persona transmitiendo su pantalla. Es especialmente útil para configuraciones basadas en navegador como Emby, Jellyfin, Plex, archivos locales abiertos en el navegador, dominios privados u otros sitios web que utilicen reproductores de video estándar del navegador.",
"ALT_SCREEN_LIMITS_TITLE": "Limitaciones reales",
"ALT_SCREEN_LIMITS_BODY": "KoalaSync no sustituye a una llamada de voz. Si quieres hablar mientras ves el contenido, puedes seguir usando Discord, Teamspeak, Signal, WhatsApp o cualquier otra herramienta que utilices habitualmente.<br><br>Además, cada participante necesita acceder al vídeo o a la página web desde su propio dispositivo. KoalaSync no elude suscripciones, inicios de sesión, DRM, restricciones regionales ni las normas de las plataformas.<br><br>Actualmente solo funciona en navegadores de escritorio, no incluye funciones de chat o voz, y la compatibilidad depende siempre del navegador y del reproductor de vídeo.",
"ALT_SCREEN_LIMITS_BODY": "KoalaSync incluye chat de texto cifrado de extremo a extremo, pero no reemplaza una llamada de voz o video. Si quieres hablar mientras miras, aún usas Discord, Teamspeak, Signal, WhatsApp o lo que uses normalmente.<br><br>Además, cada participante necesita acceder al vídeo o a la página web desde su propio dispositivo. KoalaSync no elude suscripciones, inicios de sesión, DRM, restricciones regionales ni las normas de las plataformas.<br><br>Actualmente es solo para navegador de escritorio, no tiene chat de voz, video o cámara web incorporado, y la compatibilidad siempre depende del navegador y el reproductor de video.",
"ALT_SCREEN_SUMMARY_TITLE": "Resumen",
"ALT_SCREEN_SUMMARY_BODY": "Compartir pantalla es la opción más sencilla cuando buscas algo rápido y no te preocupa demasiado la calidad, la privacidad o el control individual. KoalaSync es una mejor alternativa cuando cada persona debe ver el contenido localmente en su propio navegador y se busca una experiencia que realmente se asemeje a verlo juntos.",
"ALT_W2G_TITLE": "KoalaSync vs Watch2Gether: ¿Qué solución de fiesta de visualización se adapta mejor?",
"ALT_W2G_META_DESC": "Una comparación honesta entre KoalaSync y Watch2Gether. Descubra cuándo la flexibilidad de KoalaSync es inmejorable y por qué Watch2Gether sigue siendo la mejor opción para YouTube.",
"ALT_W2G_META_DESC": "Comparación práctica de KoalaSync y Watch2Gether: salas compartidas, listas, sincronización del reproductor y chat cifrado.",
"ALT_W2G_INTRO": "KoalaSync y Watch2Gether resuelven el mismo problema: ver vídeos sincronizados con amigos en línea sin coordinarlos manualmente. Si bien Watch2Gether es uno de los centros más antiguos y populares para noches de películas compartidas, KoalaSync adopta un enfoque liviano y completamente diferente. La herramienta adecuada para usted depende en gran medida de qué y dónde desea mirar.",
"ALT_W2G_WHEN_W2G_TITLE": "Cuando Watch2Gether es la mejor opción",
"ALT_W2G_WHEN_W2G_BODY": "Watch2Gether es inmejorable para las <strong>fiestas de visualización de YouTube</strong>. Con su lista de reproducción y búsqueda integradas, cualquier persona en la sala puede buscar, agregar y reproducir nuevos videos fácilmente. Todos ven el siguiente video juntos sin problemas.<br><br>Con KoalaSync en YouTube, la reproducción automática predeterminada del sitio toma el control y redirige a cada usuario a un video recomendado diferente. Nuestra función automática del siguiente episodio (\"Episode Lobby\"), que funciona muy bien en Netflix o Disney+, no funciona en YouTube debido a estas recomendaciones dinámicas. Para las noches puramente de YouTube, Watch2Gether es la mejor opción.",
"ALT_W2G_WHEN_W2G_BODY": "Watch2Gether puede encajar mejor para sesiones de YouTube centradas en una sala, búsqueda y lista compartidas. La compatibilidad depende del servicio y la fuente actuales.",
"ALT_W2G_WHEN_KOALA_TITLE": "Cuando KoalaSync se adapta mejor",
"ALT_W2G_WHEN_KOALA_BODY": "KoalaSync brilla en <strong>servicios de streaming reales</strong> (Netflix, Prime Video, Disney+) y servidores de medios autohospedados (Jellyfin, Plex, Emby). A diferencia de Watch2Gether, que a menudo no puede reproducir ningún contenido premium, KoalaSync sincroniza el reproductor nativo directamente en el sitio web.<br><br>Funciona en casi cualquier sitio con un reproductor de vídeo HTML5 estándar. No es necesario configurar una sala virtual en un sitio de terceros: simplemente active la extensión, únase a un lobby y comience a mirar.",
"ALT_W2G_WHEN_KOALA_BODY": "KoalaSync es una buena opción si quieres sincronizar el reproductor original de una página compatible, incluidos medios autohospedados, sin trasladar la reproducción a otra web de visionado compartido. La compatibilidad depende del sitio, el reproductor y el navegador.",
"ALT_W2G_MAIN_DIFF_TITLE": "La principal diferencia",
"ALT_W2G_MAIN_DIFF_BODY": "Watch2Gether es un <strong>sitio web independiente</strong> con chat integrado, listas de reproducción y soporte para cámara web. KoalaSync es una <strong>extensión de navegador minimalista</strong> que se conecta directamente a las pestañas normales de tu navegador.<br><br>¿Buscas un paquete todo en uno para YouTube con chat integrado? Utilice Watch2Gether. ¿Quieres ver películas en Netflix o en tu propio servidor sin distracciones, en calidad nativa (sin compresión del servidor)? KoalaSync es la solución más limpia.",
"ALT_W2G_MAIN_DIFF_BODY": "Watch2Gether es un <strong>sitio web independiente</strong> con chat de texto, listas de reproducción, búsqueda y soporte para cámara web. KoalaSync es una <strong>extensión de navegador minimalista</strong> que se conecta directamente a las pestañas normales de tu navegador y también incluye chat de texto en vivo cifrado de extremo a extremo.<br><br>¿Buscas un paquete todo en uno para YouTube con listas de reproducción, búsqueda y funciones de cámara web? Usa Watch2Gether. ¿Quieres ver películas en Netflix o en tu propio servidor sin distracciones, en calidad nativa (sin compresión del servidor)? KoalaSync es la solución más limpia.",
"ALT_W2G_LIMITS_TITLE": "Limitaciones honestas",
"ALT_W2G_LIMITS_BODY": "KoalaSync <strong>no tiene listas de reproducción ni búsqueda de vídeos integradas</strong>. En YouTube, esto significa que la reproducción automática del siguiente vídeo no permanecerá sincronizada.<br><br>Además, <strong>no hay chat integrado</strong> y KoalaSync actualmente solo funciona en navegadores de escritorio. Está diseñado bajo el supuesto de que ya estás hablando a través de Discord, Teamspeak u otras herramientas de voz de todos modos.",
"ALT_W2G_LIMITS_BODY": "KoalaSync <strong>no tiene listas de reproducción ni búsqueda de vídeos integradas</strong>. En YouTube, esto significa que la reproducción automática del siguiente vídeo no permanecerá sincronizada.<br><br>KoalaSync incluye chat de texto en vivo cifrado de extremo a extremo, pero no incluye cámara web ni chat de voz o vídeo integrados, y actualmente solo funciona en navegadores de escritorio. Si quieren hablar o verse por videollamada, todavía necesitan Discord, Teamspeak u otra herramienta de llamadas.",
"ALT_W2G_SUMMARY_TITLE": "En breve",
"ALT_W2G_SUMMARY_BODY": "Para sesiones espontáneas de YouTube con listas de reproducción y chat integrado, Watch2Gether es la mejor opción. Para plataformas de streaming premium (como Netflix, Prime Video, Disney+), servidores autohospedados (Jellyfin, Plex, Emby) y máxima flexibilidad del navegador, KoalaSync es la herramienta más adecuada y ligera.",
"ALT_W2G_SUMMARY_BODY": "Ambas herramientas incluyen chat de texto. Para sesiones espontáneas de YouTube con listas de reproducción, búsqueda y funciones de cámara web, Watch2Gether es la mejor opción. Para plataformas de streaming premium (como Netflix, Prime Video, Disney+), servidores autohospedados (Jellyfin, Plex, Emby), chat en vivo cifrado de extremo a extremo y máxima flexibilidad del navegador, KoalaSync es la herramienta más adecuada y liviana.",
"ALT_SCENER_TITLE": "KoalaSync vs Scener: ¿Qué plataforma de watch party se adapta mejor?",
"ALT_SCENER_META_DESC": "Una comparación honesta de KoalaSync y Scener. Descubra cuándo gana el chat con cámara web de Scener y por qué KoalaSync es mejor para noches de cine sin distracciones.",
"ALT_SCENER_INTRO": "KoalaSync y Scener abordan las fiestas de visualización desde dos ángulos completamente diferentes. Scener es una sala de cine virtual con video chat integrado y salas interactivas. KoalaSync, por otro lado, es una extensión de navegador minimalista y respetuosa con la privacidad que simplemente sincroniza los controles de reproducción. La herramienta que mejor se adapte a sus necesidades depende principalmente de la importancia que tengan para usted las funciones integradas de cámara web y chat.",
"ALT_SCENER_INTRO": "KoalaSync y Scener abordan las fiestas de visualización desde dos ángulos completamente diferentes. Scener es una sala de cine virtual con video chat integrado y salas interactivas. KoalaSync, por otro lado, es una extensión de navegador minimalista y respetuosa con la privacidad que sincroniza los controles de reproducción e incluye chat de texto en vivo cifrado de extremo a extremo. La herramienta que mejor se adapte a tus necesidades depende principalmente de si necesitas una cámara web integrada, voz, vídeo y funciones sociales o si prefieres una herramienta de sincronización ligera.",
"ALT_SCENER_WHEN_SCENER_TITLE": "Cuando Scener es la mejor opción",
"ALT_SCENER_WHEN_SCENER_BODY": "Scener es una opción sólida si desea una <strong>plataforma social todo en uno</strong> con chat de audio y video integrado directamente en su navegador. Scener crea una sala virtual donde el reproductor y las cámaras web de tus amigos se muestran una al lado de la otra. Esto es ideal para grandes fiestas públicas o eventos de transmisión virtual donde el aspecto social está al frente y al centro. Sin embargo, tenga en cuenta que la versión básica gratuita cuenta con publicidad y Scener bloquea muchas funciones (como visualización sin publicidad y video chat ilimitado) detrás de una suscripción mensual ($5,79/mes o $39,99/año).",
"ALT_SCENER_WHEN_SCENER_BODY": "Scener encaja mejor si buscas una experiencia social integrada con chat de texto y, para miembros Premium, chat de vídeo y audio. En ordenadores admite una lista definida de servicios mediante su extensión para Chrome.",
"ALT_SCENER_WHEN_KOALA_TITLE": "Cuando KoalaSync se adapta mejor",
"ALT_SCENER_WHEN_KOALA_BODY": "KoalaSync brilla cuando se trata de <strong>calidad de vídeo, privacidad y simplicidad</strong>:<br><br>• <strong>Sin cuentas</strong>: no es necesario registrarse, iniciar sesión ni administrar perfiles. Simplemente active la extensión, comparta un código de sala y comience a mirar.<br>• <strong>Calidad nativa verdadera (4K/HDR)</strong>: Scener transmite video comprimido desde un servidor virtual en la nube, lo que puede provocar pérdida de calidad y retrasos. KoalaSync sincroniza el reproductor nativo directamente en su computadora, lo que significa que puede ver películas en la resolución completa de su suscripción de transmisión.<br>• <strong>Bajo uso de ancho de banda</strong>: debido a que solo se transmiten datos de control (reproducción, pausa, marcas de tiempo), KoalaSync prácticamente no usa ancho de banda adicional. Scener transmite todo el video y las transmisiones de la cámara a través de servidores en la nube, lo que puede sobrecargar las conexiones a Internet más lentas y las computadoras portátiles más antiguas.<br>• <strong>100% gratis y sin publicidad</strong>: sin tarifas de suscripción mensual, sin publicidad ni rastreadores.<br>• <strong>Archivos de video locales</strong>: dado que KoalaSync se ejecuta localmente, incluso puedes ver tus propios archivos de video (como MP4) en sincronización con amigos. Simplemente arrastre y suelte el archivo en una pestaña del navegador; Scener no puede hacer esto porque su reproductor se ejecuta en la nube.",
"ALT_SCENER_WHEN_KOALA_BODY": "KoalaSync encaja si prefieres sincronización de código abierto sin cuenta en el reproductor original, chat de texto en vivo cifrado de extremo a extremo y un relay autohospedado opcional. No incluye chat de voz, vídeo ni cámara web.",
"ALT_SCENER_MAIN_DIFF_TITLE": "La principal diferencia",
"ALT_SCENER_MAIN_DIFF_BODY": "Scener es una <strong>red social comercial</strong> para grupos de visualización que funciona con navegadores virtuales en la nube y se financia a través de anuncios y suscripciones mensuales. KoalaSync es una <strong>utilidad de sincronización gratuita que prioriza la privacidad</strong> y se conecta directamente a las pestañas de su navegador. Deja el chat de voz/vídeo en manos de aplicaciones como Discord o Teamspeak, centrándose por completo en mantener la reproducción fluida, privada y en su calidad original nativa.",
"ALT_SCENER_MAIN_DIFF_BODY": "Scener se centra en salas integradas y funciones sociales. KoalaSync se centra en sincronizar el reproductor original y ofrecer chat de texto en vivo cifrado de extremo a extremo.",
"ALT_SCENER_LIMITS_TITLE": "Limitaciones honestas",
"ALT_SCENER_LIMITS_BODY": "KoalaSync no ofrece <strong>soporte integrado para chat o cámara web</strong>. Es puramente una herramienta de sincronización y requiere una herramienta de llamada de voz independiente de su elección. Además, KoalaSync actualmente es solo para escritorio y la compatibilidad de archivos de video locales depende completamente de si su navegador admite de forma nativa la reproducción del formato de video y códecs de audio.",
"ALT_SCENER_LIMITS_BODY": "KoalaSync ofrece <strong>chat de texto en vivo cifrado de extremo a extremo, pero no chat con cámara web, voz o vídeo</strong>. Por lo tanto, hablar o verse requiere una herramienta de llamada independiente de su elección. Además, KoalaSync actualmente es solo para escritorio y la compatibilidad de archivos de video locales depende completamente de si su navegador admite de forma nativa la reproducción del formato de video y códecs de audio.",
"ALT_SCENER_SUMMARY_TITLE": "En breve",
"ALT_SCENER_SUMMARY_BODY": "Para grandes grupos de visualización con chat con cámara web integrado y funciones sociales, Scener es la opción correcta, siempre que no le importen los anuncios y los muros de pago premium. Si desea una experiencia completamente gratuita, sin publicidad y sin distracciones en el reproductor nativo (incluidos Netflix, Prime Video, Disney+ o archivos del navegador local) y ya está en una llamada de voz, KoalaSync es la solución más limpia y eficaz.",
"ALT_SCENER_SUMMARY_BODY": "Elige Scener para funciones sociales, de vídeo y audio integradas en sus servicios compatibles. Elige KoalaSync para sincronización sin cuenta y de código abierto, chat cifrado en vivo y autohospedaje opcional.",
"ALT_KOSMI_TITLE": "KoalaSync vs Kosmi: ¿Qué alternativa de fiesta de Netflix encaja mejor?",
"ALT_KOSMI_META_DESC": "Una comparación honesta de KoalaSync y Kosmi. Descubra por qué la configuración sin extensiones de Kosmi es conveniente y cuándo gana la calidad de video nativa de KoalaSync.",
"ALT_KOSMI_INTRO": "Tanto KoalaSync como Kosmi ofrecen excelentes formas de ver Netflix, YouTube y otras plataformas sincronizadas con amigos. Sin embargo, sus tecnologías subyacentes son fundamentalmente diferentes. Kosmi utiliza navegadores virtuales en la nube que se ejecutan directamente en su navegador web sin instalar extensiones. KoalaSync es una extensión liviana que sincroniza su reproductor de video local. El enfoque que mejor se adapte a sus necesidades depende de sus prioridades en cuanto a calidad de vídeo, comodidad y privacidad.",
"ALT_KOSMI_INTRO": "KoalaSync y Kosmi permiten ver contenido en grupo, pero son productos distintos con servicios y funciones sociales diferentes. KoalaSync sincroniza un reproductor compatible en el navegador de escritorio. Consulta la compatibilidad actual de cada servicio.",
"ALT_KOSMI_WHEN_KOSMI_TITLE": "Cuando Kosmi es la mejor opción",
"ALT_KOSMI_WHEN_KOSMI_BODY": "Kosmi es inmejorable si quieres <strong>evitar instalar nada</strong> o quieres mirar en dispositivos como tabletas y teléfonos inteligentes. Debido a que Kosmi ejecuta navegadores virtuales en la nube, todo lo que se necesita para unirse es un simple enlace web. Además, Kosmi es una plataforma de reunión todo en uno: además de la transmisión sincronizada, puedes jugar juegos multijugador (como clásicos retro de NES/SNES o póquer) y usar la cámara web integrada, el chat de voz y de texto. Si quieres una noche de juegos sociales y una fiesta para ver sin configuración, Kosmi es una solución fantástica.",
"ALT_KOSMI_WHEN_KOSMI_BODY": "Kosmi puede encajar mejor si buscas sus salas sociales en el navegador, juegos y funciones de comunicación integradas. La disponibilidad puede variar según el dispositivo, la fuente y el plan actual.",
"ALT_KOSMI_WHEN_KOALA_TITLE": "Cuando KoalaSync se adapta mejor",
"ALT_KOSMI_WHEN_KOALA_BODY": "KoalaSync brilla cuando se trata de <strong>calidad de reproducción nativa, seguridad y rendimiento</strong>:<br><br>• <strong>Sin riesgos de seguridad</strong>: con Kosmi, debes iniciar sesión en tu cuenta de Netflix o Prime en una computadora virtual remota en la nube. Con KoalaSync, inicias sesión de forma segura en el sitio web oficial de transmisión en tu propio navegador, tal como lo haces normalmente. Sus contraseñas nunca salen de su máquina.<br>• <strong>Calidad de vídeo completa (4K/HDR)</strong>: Kosmi transmite la pantalla del navegador virtual como una señal de vídeo comprimida, lo que puede provocar pixelación y almacenamiento en búfer. KoalaSync sincroniza los reproductores nativos localmente: usted disfruta de las películas con la resolución completa y nítida de su suscripción.<br>• <strong>Sin demoras y compatible con el ancho de banda</strong>: dado que KoalaSync solo envía comandos de control (reproducir/pausar/marcas de tiempo), utiliza casi cero ancho de banda. Kosmi transmite constantemente transmisiones de video HD, lo que puede abrumar las conexiones más lentas.<br>• <strong>Archivos de video locales</strong>: si desea ver un video privado, simplemente arrastre y suelte el archivo MP4 en una pestaña del navegador y sincronícelo. Kosmi no puede acceder a los archivos de su disco duro local.",
"ALT_KOSMI_WHEN_KOALA_BODY": "KoalaSync encaja mejor si quieres sincronizar un reproductor compatible en tu navegador de escritorio, usar chat de texto en vivo cifrado de extremo a extremo o alojar el relay. No incluye llamadas ni aplicación móvil.",
"ALT_KOSMI_MAIN_DIFF_TITLE": "La principal diferencia",
"ALT_KOSMI_MAIN_DIFF_BODY": "Kosmi es un <strong>lugar virtual en la nube</strong> que transmite la pantalla de un navegador remoto para que nadie tenga que instalar una extensión. KoalaSync es una <strong>capa de sincronización del navegador local</strong> que se conecta directamente al reproductor nativo en el sitio web oficial de streaming. Si bien Kosmi es más versátil en cuanto a minijuegos y compatibilidad con dispositivos, KoalaSync ofrece una experiencia de transmisión más segura, fluida y nítida en el reproductor original.",
"ALT_KOSMI_MAIN_DIFF_BODY": "Kosmi se centra en una sala social integrada. KoalaSync es una extensión de escritorio que sincroniza reproductores compatibles y ofrece chat de texto cifrado en vivo.",
"ALT_KOSMI_LIMITS_TITLE": "Limitaciones honestas",
"ALT_KOSMI_LIMITS_BODY": "KoalaSync <strong>requiere la instalación de una extensión del navegador</strong> y actualmente solo se ejecuta en PC de escritorio. <strong>No tiene chat ni cámaras web integrados</strong>, ya que está diseñado bajo el supuesto de que de todos modos ya estás hablando a través de Discord u otras herramientas de voz. Tampoco ofrece juegos integrados.",
"ALT_KOSMI_LIMITS_BODY": "KoalaSync <strong>requiere la instalación de una extensión del navegador</strong> y actualmente solo se ejecuta en PC de escritorio. Incluye <strong>chat de texto en vivo cifrado de extremo a extremo, pero no chat con cámara web, voz o vídeo</strong>. Tampoco ofrece juegos integrados.",
"ALT_KOSMI_SUMMARY_TITLE": "En breve",
"ALT_KOSMI_SUMMARY_BODY": "Para noches de juegos casuales con clásicos retro y fácil acceso en dispositivos móviles sin instalación, Kosmi es ideal. Para noches de películas premium en Netflix, Prime Video, Disney+ o Plex en calidad total 4K, con máxima seguridad y mínimo impacto en el ancho de banda, KoalaSync es el claro ganador.",
"ALT_KOSMI_SUMMARY_BODY": "Elige Kosmi por sus funciones sociales integradas. Elige KoalaSync para sincronización de escritorio sin cuenta y de código abierto, chat cifrado y relay autohospedado opcional.",
"ALT_TWOSEVEN_TITLE": "KoalaSync vs TwoSeven: ¿Qué solución de fiesta de visualización se adapta mejor?",
"ALT_TWOSEVEN_META_DESC": "Una comparación honesta de KoalaSync y TwoSeven. Descubra cuándo gana la cámara web integrada de TwoSeven y por qué KoalaSync es mejor para Disney+ y Netflix.",
"ALT_TWOSEVEN_INTRO": "Tanto KoalaSync como TwoSeven utilizan el mismo enfoque básico para las fiestas de visualización: sincronizan el reproductor de vídeo original directamente en su navegador, en lugar de transmitir un vídeo comprimido desde la nube. Esto garantiza una calidad de vídeo de primer nivel para ambas herramientas. Sin embargo, si bien TwoSeven es un portal comercial con chat con cámara web integrado y niveles de suscripción, KoalaSync sigue siendo una extensión minimalista, completamente gratuita y respetuosa con la privacidad. Descubra aquí qué servicio se adapta mejor a sus necesidades.",
"ALT_TWOSEVEN_META_DESC": "Comparación práctica de KoalaSync y TwoSeven: cuentas, comunicación integrada, chat cifrado y autohospedaje.",
"ALT_TWOSEVEN_INTRO": "Ambas herramientas permiten el visionado sincronizado, pero sus servicios y requisitos pueden cambiar. TwoSeven usa cuentas y ofrece comunicación integrada. KoalaSync no requiere cuenta, ofrece chat de texto cifrado y no incluye llamadas.",
"ALT_TWOSEVEN_WHEN_TWOSEVEN_TITLE": "Cuando TwoSeven es la mejor opción",
"ALT_TWOSEVEN_WHEN_TWOSEVEN_BODY": "TwoSeven es la elección correcta si desea una <strong>solución todo en uno con cámara web integrada y chat de audio</strong> directamente en su navegador. Si quieres ver a tus amigos mientras miran sin tener que ejecutar Discord o Teamspeak en segundo plano, TwoSeven ofrece esta integración de forma nativa. Además, funciona para YouTube y algunos reproductores de vídeo web abiertos sin extensiones. Es ideal para grupos pequeños de 2 a 3 personas que quieran compartir una conexión visual mientras miran.",
"ALT_TWOSEVEN_WHEN_TWOSEVEN_BODY": "TwoSeven puede encajar mejor si te importan la cámara web y el audio integrados. Comprueba sus requisitos actuales de navegador, cuenta, plan y servicios compatibles.",
"ALT_TWOSEVEN_WHEN_KOALA_TITLE": "Cuando KoalaSync se adapta mejor",
"ALT_TWOSEVEN_WHEN_KOALA_BODY": "KoalaSync brilla por su <strong>falta total de tarifas, privacidad y simplicidad</strong>:<br><br>• <strong>Sin muros de pago</strong>: para ver servicios premium como Disney+ o Hulu sincronizados en TwoSeven, al menos un miembro de la sala debe apoyarlos en Patreon (a partir de $3/mes). KoalaSync sincroniza Netflix, Prime Video, Disney+ y servidores multimedia autohospedados (Jellyfin, Plex, Emby) de forma 100 % gratuita y sin restricciones.<br>• <strong>Privacidad absoluta (sin rastreadores ni anuncios):</strong> TwoSeven cuenta con publicidad y utiliza múltiples rastreadores de Google, Facebook y otros. KoalaSync no tiene anuncios ni rastreadores y se comunica exclusivamente con el servidor de retransmisión de solo RAM.<br>• <strong>No es necesario registrarse</strong>: TwoSeven requiere que todos los participantes creen una cuenta e inicien sesión. KoalaSync no tiene ninguna cuenta. Simplemente instálelo, ingrese un código de sala y comience a mirar.<br>• <strong>Ligero y con pocos recursos</strong>: la cámara web integrada y las transmisiones de audio de TwoSeven suponen una gran carga para su CPU y su red. Al dejar la comunicación en manos de herramientas como Discord, KoalaSync funciona de manera extremadamente fluida y preserva la duración de la batería.",
"ALT_TWOSEVEN_WHEN_KOALA_BODY": "KoalaSync encaja si buscas sincronización de código abierto sin cuenta, chat de texto en vivo cifrado de extremo a extremo y la opción de alojar tu propio relay. Las llamadas de voz y cámara web se realizan en otra aplicación.",
"ALT_TWOSEVEN_MAIN_DIFF_TITLE": "La principal diferencia",
"ALT_TWOSEVEN_MAIN_DIFF_BODY": "TwoSeven es una <strong>plataforma comercial</strong> con requisitos de registro, anuncios, chats con cámara web de igual a igual y muros de pago de Patreon para servicios de transmisión específicos. KoalaSync es una <strong>utilidad gratuita de código abierto</strong> que se conecta directamente a las pestañas de su navegador sin cuentas, rastreadores ni costos adicionales, lo que permite mirar contenido sin distracciones en el reproductor nativo.",
"ALT_TWOSEVEN_MAIN_DIFF_BODY": "TwoSeven combina el visionado sincronizado con una plataforma basada en cuentas y funciones integradas de cámara web y audio. KoalaSync es una extensión sin cuenta con chat de texto en vivo cifrado; las llamadas permanecen separadas.",
"ALT_TWOSEVEN_LIMITS_TITLE": "Limitaciones honestas",
"ALT_TWOSEVEN_LIMITS_BODY": "KoalaSync no ofrece <strong>cámara web ni chat de audio integrados</strong> y asume que ya estás en una llamada a través de Discord o una herramienta similar. Además, KoalaSync no tiene un cuadro de chat superpuesto incorporado y actualmente es solo para escritorio.",
"ALT_TWOSEVEN_LIMITS_BODY": "KoalaSync no ofrece <strong>cámara web ni chat de audio integrados</strong>, por lo que verse o hablar entre sí aún requiere Discord o una herramienta similar. Incluye una superposición de chat de texto en vivo, flotante y acoplable, cifrado de extremo a extremo, y actualmente es solo para escritorio.",
"ALT_TWOSEVEN_SUMMARY_TITLE": "En breve",
"ALT_TWOSEVEN_SUMMARY_BODY": "Para grupos pequeños de dos o tres personas que quieren verse a través de cámaras web directamente en el navegador y no les importa apoyar a Patreon para Disney+, TwoSeven es una buena opción. Para noches de cine privadas, completamente gratuitas y sin publicidad en calidad nativa 4K (ya sea Netflix, Prime Video, Disney+ o Jellyfin) sin registros, KoalaSync es la opción más limpia y segura.",
"ALT_TWOSEVEN_SUMMARY_BODY": "Elige TwoSeven si lo más importante son la cámara web y el audio integrados. Elige KoalaSync para sincronización sin cuenta y de código abierto, chat cifrado en vivo y autohospedaje opcional.",
"ALT_TRY_KOALASYNC": "Prueba KoalaSync",
"MOCK_HOST_CONTROL_LABEL": "Control del anfitrión",
"MOCK_HOST_CONTROL_TOGGLE": "Solo yo puedo controlar la reproducción",
@@ -288,5 +288,9 @@
"SELF_SHOW_SETUP": "Mostrar guía de instalación",
"SELF_HIDE_SETUP": "Ocultar guía de instalación",
"HERO_TRUST": "Gratis · Código abierto · Sin cuenta",
"COMP_MORE_GUIDES": "Comparativa completa y más alternativas"
"COMP_MORE_GUIDES": "Comparativa completa y más alternativas",
"NAV_HOME": "Inicio",
"ARIA_SELECT_LANGUAGE": "Seleccionar idioma",
"ARIA_MENU": "Menú",
"FOOTER_GUIDES": "Guías y comparativas"
}

Some files were not shown because too many files have changed in this diff Show More