mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
Compare commits
197 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 213d2867fc | |||
| 90a58eb507 | |||
| 72113b6504 | |||
| a0954079a1 | |||
| 9251ae6aff | |||
| 511e042b0b | |||
| 372c384a19 | |||
| 79715046a8 | |||
| 2ae79386a0 | |||
| 883e49433e | |||
| 59135b319e | |||
| ae2238e3bc | |||
| 77790a279c | |||
| 2df048621b | |||
| 17c3ea295a | |||
| 5d3cee4271 | |||
| 1149de7ab7 | |||
| 4882e058f1 | |||
| 8d7085c97e | |||
| 630869260b | |||
| e9d735cd39 | |||
| aee3588f43 | |||
| 7f898e6eb4 | |||
| d2d4f5f3ea | |||
| 6e625e3a38 | |||
| 6aa2f53e6b | |||
| 2b485fed53 | |||
| fe13275b5d | |||
| 61d0d64c7d | |||
| abcb10a1c3 | |||
| 03121477a8 | |||
| 285c04e00d | |||
| 1989eb639b | |||
| fd48ce8481 | |||
| 649c8796a9 | |||
| 142153a131 | |||
| a334605dd5 | |||
| 7e7e60f267 | |||
| 354500873c | |||
| 9e0d294758 | |||
| 36c4c492d3 | |||
| e9dc299fc9 | |||
| ee0d68d8d3 | |||
| f2eee6dc41 | |||
| 8fee98834c | |||
| 771da1cf82 | |||
| ab2d7747fd | |||
| 8f9676cc5a | |||
| 477a814a79 | |||
| cb84709358 | |||
| 43a7bb0d57 | |||
| e1e86307b1 | |||
| e934fcf484 | |||
| d4181b2eaf | |||
| 66d650f49c | |||
| 2cde14b260 | |||
| db8bf8f647 | |||
| 81001f2890 | |||
| 861ffc306a | |||
| 6f6aac9c18 | |||
| e4984b2d03 | |||
| 18205f6573 | |||
| a4bbddf3a4 | |||
| b577f06988 | |||
| 61514b9b6f | |||
| 62046acb81 | |||
| 8b6a1412cc | |||
| 6e5a790707 | |||
| 0f4b1d7217 | |||
| af0da75d38 | |||
| 63414bfcdd | |||
| af1b66c6b7 | |||
| cc96fc1771 | |||
| 9e8a446d97 | |||
| b987d13612 | |||
| c175364f9a | |||
| 0168484382 | |||
| 99ecf9c9a8 | |||
| f58ab54f45 | |||
| 104602edec | |||
| 9d6a3650c4 | |||
| 986b2a6e56 | |||
| fdf20b6bf6 | |||
| 3ce64b8c41 | |||
| 6f8bc40023 | |||
| 6f203405f9 | |||
| 949075b267 | |||
| 4fb8e0f53a | |||
| bd33ea4804 | |||
| 9cd3fc1ee2 | |||
| 44f9a78bb0 | |||
| e27319908d | |||
| af4ac943a9 | |||
| 9fd4e870f3 | |||
| 16cb138eeb | |||
| 17c9da292c | |||
| c7c124dac7 | |||
| ea0970df72 | |||
| 7614534818 | |||
| 72d4f04526 | |||
| 7fea11de0a | |||
| fbef3d180c | |||
| fecaa8b54d | |||
| 8175f82f0b | |||
| 04df35bbe6 | |||
| 21383b8b9c | |||
| 9cddac47ef | |||
| 1f1844e99b | |||
| aa52d319e1 | |||
| 05246ac485 | |||
| 6412a9e6ff | |||
| ee1c2947ce | |||
| df8133fb5b | |||
| e25ae25bbb | |||
| 1d049e1745 | |||
| 6aa42668e9 | |||
| 71fdea78f3 | |||
| 5b7ee654e4 | |||
| 7718885511 | |||
| 12bca85356 | |||
| b4bf297a5b | |||
| 1e417e3379 | |||
| d7d795f076 | |||
| a45cfb063e | |||
| ee28f9d275 | |||
| 21936e7f71 | |||
| a45e52c6ee | |||
| a0b4f1a2bb | |||
| 4a8fdfbe70 | |||
| 9127b1f8dd | |||
| e3a27db611 | |||
| 861bb05ec2 | |||
| 9f02a47d98 | |||
| c3827fa4b7 | |||
| 61ba32addf | |||
| 140e2c0d00 | |||
| cc53494cc0 | |||
| fd3c3e6cdb | |||
| 7d6ca9fd68 | |||
| 84526cf8e9 | |||
| c16bf30ad5 | |||
| e559b41c93 | |||
| a9687219e0 | |||
| dfe0b42252 | |||
| d29c842e96 | |||
| 07839a907b | |||
| 78efb04b6a | |||
| e093a8afae | |||
| b8484f77d6 | |||
| 8d7b7f68f3 | |||
| 2848a4450c | |||
| 4c519a81e0 | |||
| db70828c82 | |||
| fd0ad310ce | |||
| 917afd795e | |||
| da681e9e3f | |||
| 37df4b267c | |||
| 793248053c | |||
| 1aa95079cf | |||
| 0976e765c3 | |||
| ee2c284cb4 | |||
| 883807afd7 | |||
| 46b2004f78 | |||
| 3fd02246ab | |||
| 14c9ab1d5e | |||
| 0127d62a49 | |||
| 2d94cc10e4 | |||
| c5c140d31c | |||
| 5a53abc636 | |||
| f0328fb865 | |||
| e1e18aa26c | |||
| cece816e00 | |||
| 9690868e33 | |||
| cb6d7eb98c | |||
| ab6356c5ad | |||
| 5460ac3a01 | |||
| 60c80ad7fb | |||
| c8df8b6d08 | |||
| 49712a7151 | |||
| 23aa0c8a15 | |||
| ae3b837bb4 | |||
| 14ab6b2d50 | |||
| 82b90d86a9 | |||
| 537c8f3c60 | |||
| 6b81668e16 | |||
| d6a23f2eb5 | |||
| 573847486b | |||
| e5a4032710 | |||
| 44d452a82f | |||
| 82d0bd4eb8 | |||
| d3ca2a2378 | |||
| b7622a8347 | |||
| 3a733934c5 | |||
| 25ad3e8d59 | |||
| 223c3949d2 | |||
| 3392f0b3a7 | |||
| 72ea5d1ee9 |
@@ -2,7 +2,7 @@
|
||||
name: 🚀 Feature Request
|
||||
about: Suggest a new feature or enhancement for KoalaSync
|
||||
title: ''
|
||||
labels: enhancement
|
||||
labels: feature
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,22 +111,22 @@ 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"
|
||||
|
||||
# 7. website/sitemap.xml — lastmod dates
|
||||
sed -i "s/<lastmod>[0-9-]*<\/lastmod>/<lastmod>$(date +%Y-%m-%d)<\/lastmod>/g" website/sitemap.xml
|
||||
echo " ✓ website/sitemap.xml -> lastmod $(date +%Y-%m-%d)"
|
||||
|
||||
echo "Version injection complete."
|
||||
|
||||
- name: Commit and push version updates back to main
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add extension/manifest.base.json shared/constants.js package.json website/version.json website/template.html README.md website/sitemap.xml
|
||||
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:
|
||||
|
||||
+16
@@ -43,6 +43,22 @@ extension/shared/
|
||||
|
||||
# Auto-generated website build output
|
||||
website/www/
|
||||
website/.avif-cache.json
|
||||
|
||||
# Temporary scratch files
|
||||
scratch/
|
||||
|
||||
# AI Assistants and Agents
|
||||
.agents/
|
||||
.antigravity/
|
||||
.gemini/
|
||||
.claude/
|
||||
.codex/
|
||||
.opencode/
|
||||
.cursor/
|
||||
.cursorrules
|
||||
.copilot/
|
||||
.cline/
|
||||
.clinerules
|
||||
.windsurfrules
|
||||
.codeium/
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ representative at an online or offline event.
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the project maintainer at **koaladev@koalamail.rocks**.
|
||||
reported to the project maintainer at **koalasync@koalastuff.net**.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All project maintainers are obligated to respect the privacy and security of the
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<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.5.2-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.0-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>
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<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.5.2 Release!</b> — See what's changed</a></p>
|
||||
<p align="center"><a href="docs/CHANGELOG.md"><b>New v3.0.0 Release!</b> — See what's changed</a></p>
|
||||
|
||||
### 🌟 Why KoalaSync?
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
- **Global Synchronization**: Synchronize Play, Pause, and Seeking on any website with a `<video>` tag.
|
||||
- **Episode Auto-Sync**: Perfectly sync series binges. All peers wait until everyone has loaded the next episode before starting together.
|
||||
- **Host Control & Co-Hosts**: Room hosts can lock playback control to trusted controllers while guests keep watching in sync.
|
||||
- **Smart Matching**: Automatically highlights tabs containing matching video titles.
|
||||
- **Dual Heartbeat Architecture**: Robust session tracking that prevents ghost rooms and stale connections.
|
||||
- **Efficient Relay**: Minimal overhead WebSocket message forwarding.
|
||||
@@ -58,7 +59,7 @@ The easiest and safest way to install KoalaSync is directly through the official
|
||||
|
||||
### 🌐 Localization & Translations
|
||||
|
||||
Both the official KoalaSync website and the **v2.0 Browser Extension** feature full dynamic localization:
|
||||
Both the official KoalaSync website and the browser extension feature dynamic localization:
|
||||
- **Available Languages**: Support is included for 15 languages: English (`en`), German (`de`), French (`fr`), Spanish (`es`), Portuguese (Brazil) (`pt-BR`), Russian (`ru`), Italian (`it`), Polish (`pl`), Turkish (`tr`), Dutch (`nl`), Japanese (`ja`), Korean (`ko`), Chinese (Simplified) (`zh`), Ukrainian (`uk`), and European Portuguese (`pt`).
|
||||
- **Real-Time Extension Localization**: Inside the extension Settings panel, users can swap languages instantly. The entire interface, notifications, Empty States, and onboarding guides re-translate dynamically in real-time.
|
||||
- **Contributing**: We welcome community translations for both the website and the extension! Please refer directly to the [TRANSLATION.md](docs/TRANSLATION.md) guide for step-by-step instructions on how to audit, refine, or add new languages.
|
||||
@@ -86,16 +87,20 @@ npm run build:extension
|
||||
The compiled artifacts will be available in the `dist/` directory.
|
||||
|
||||
#### For Self-Hosting (Docker)
|
||||
Deploy your own private relay server using our official image:
|
||||
For local development or a simple private relay, use the root Compose file:
|
||||
```bash
|
||||
# Pull the latest image
|
||||
docker pull ghcr.io/shik3i/koalasync:latest
|
||||
|
||||
# Or use our example compose file
|
||||
cp examples/docker-compose.caddy.example.yml docker-compose.yml
|
||||
docker-compose up -d
|
||||
cp server/.env.example server/.env
|
||||
# Edit server/.env and set SERVER_SALT to a unique random value:
|
||||
# openssl rand -base64 32
|
||||
docker compose up -d --build
|
||||
```
|
||||
The server will be available at `ws://localhost:3000`. See [Docker network compose](examples/docker-compose.caddy.example.yml) or [Static IP compose](examples/docker-compose.ip.example.yml) for ready-to-use Docker Compose files.
|
||||
The local relay will be available at `ws://localhost:3000`.
|
||||
|
||||
For production, use the official image and one of the ready-to-edit examples:
|
||||
- [Docker network compose](examples/docker-compose.caddy.example.yml): for a Caddy or reverse-proxy network. This file does not publish `localhost:3000`; Caddy routes traffic to the container.
|
||||
- [Static IP compose](examples/docker-compose.ip.example.yml): for a pre-existing Docker network with a fixed container IP.
|
||||
|
||||
In every real deployment, set a unique `SERVER_SALT`. Without it the relay still starts, but it warns that room-password hashes are using the public fallback salt from the repository.
|
||||
|
||||
To connect your extension to a self-hosted server, open the popup → **Room** tab → select **Custom Server** → enter your server's WebSocket URL (e.g., `ws://localhost:3000`).
|
||||
|
||||
@@ -134,6 +139,7 @@ gh attestation verify dist/koalasync-chrome.zip \
|
||||
- **[PROTOCOL.md](docs/PROTOCOL.md)**: WebSocket protocol specification and event reference.
|
||||
- **[ROADMAP.md](docs/ROADMAP.md)**: Planned features, backlog, and rejected ideas.
|
||||
- **[SECURITY.md](SECURITY.md)**: Disclosure policy and security practices.
|
||||
- **[AI_INIT.md](docs/AI_INIT.md)**: Maintainer and agent onboarding notes for safe code changes.
|
||||
- **[Caddyfile.example](examples/Caddyfile.example)**: Production Caddy configuration for website and relay.
|
||||
|
||||
---
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ The following components are within scope for security reports:
|
||||
|
||||
Instead, email the project maintainer privately:
|
||||
|
||||
**`koalasync_admin@koalamail.rocks`**
|
||||
**`koalasync@koalastuff.net`**
|
||||
|
||||
Encrypt sensitive findings with our PGP key (available on request).
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Ready-to-paste copy for product listings, launch pages, directory submissions, a
|
||||
|
||||
## 2. Three-Sentence Overview
|
||||
|
||||
> KoalaSync is a lightweight browser extension that keeps you and your friends perfectly in sync on YouTube, Netflix, Twitch, Prime Video, Jellyfin, Emby, and almost any other site with an HTML5 video player — press play once and everyone stays together. It's built privacy-first: no accounts, no telemetry, and the official relay server runs entirely in volatile RAM with zero persistence, so nothing about your sessions is ever stored. Open source under the MIT license and fully self-hostable with a single Docker command, KoalaSync is a transparent watch-party tool that works everywhere and respects your data sovereignty.
|
||||
> KoalaSync is a lightweight browser extension that keeps you and your friends perfectly in sync on YouTube, Netflix, Twitch, Prime Video, Jellyfin, Emby, and almost any other site with an HTML5 video player — press play once and everyone stays together. It's built privacy-first: no accounts, no telemetry, and the official relay server runs entirely in volatile RAM with zero persistence, so nothing about your sessions is ever stored. Open source under the MIT license and fully self-hostable with a single Docker Compose command, KoalaSync is a transparent watch-party tool that works everywhere and respects your data sovereignty.
|
||||
|
||||
---
|
||||
|
||||
@@ -38,7 +38,7 @@ Most watch-party tools fall into one of two traps: they only work on a short all
|
||||
|
||||
### Built for people who actually want to read the code
|
||||
|
||||
KoalaSync is MIT-licensed open source, built by a solo developer. Audit it, fork it, change it. The extension is dependency-free with a direct Socket.IO wire implementation — no opaque libraries, no framework bloat, no surprise third-party SDKs. Want full sovereignty? Self-host your own relay with a single Docker command and keep all watch-party coordination traffic inside your own infrastructure. The official public relay is there when you don't care, self-hosting is there when you do.
|
||||
KoalaSync is MIT-licensed open source, built by a solo developer. Audit it, fork it, change it. The extension is dependency-free with a direct Socket.IO wire implementation — no opaque libraries, no framework bloat, no surprise third-party SDKs. Want full sovereignty? Set a unique `SERVER_SALT` in the supplied Compose file, then start your own relay with a single Docker Compose command and keep all watch-party coordination traffic inside your own infrastructure. The official public relay is there when you don't care, self-hosting is there when you do.
|
||||
|
||||
### Little touches you'll notice
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
KoalaSync: Private Watch Parties for Emby, Jellyfin, Plex, Netflix & YouTube
|
||||
|
||||
Tired of counting down "3, 2, 1, Play" over voice chat? KoalaSync keeps you and your friends perfectly in sync. Whether you are streaming from your own self-hosted media server like Emby, Jellyfin or Plex, or watching on a major platform like Netflix, Prime Video or YouTube — KoalaSync is designed for smooth, browser-based watch parties.
|
||||
Tired of counting down "3, 2, 1, Play" over voice chat? KoalaSync keeps you and your friends perfectly in sync. Whether you are streaming from your own self-hosted media server like Emby, Jellyfin or Plex, or watching on a major platform like Netflix, Prime Video or YouTube, KoalaSync is designed for smooth, browser-based watch parties.
|
||||
|
||||
|
||||
✨ CORE FEATURES
|
||||
@@ -12,7 +12,7 @@ No account required. No tracking. Just create a room, invite your friends, and s
|
||||
• Smart Binge-Watching: When a new episode loads, KoalaSync automatically pauses the lobby until everyone is ready. No spoilers, no one left behind.
|
||||
• Smart Audio Compressor: Tired of quiet dialogue and suddenly loud action scenes? Balance whispering, explosions, and music with a single click while you watch.
|
||||
• One-Click Invites: Send a smart invite link to your friends. When they open it, KoalaSync automatically configures the room so they can join instantly.
|
||||
• 13 Languages: Enjoy a native experience with a fully translated user interface.
|
||||
• 15 Languages: Enjoy a native experience in English, German, French, Spanish, Brazilian and European Portuguese, Russian, Italian, Polish, Turkish, Dutch, Japanese, Korean, Simplified Chinese, and Ukrainian.
|
||||
|
||||
|
||||
|
||||
@@ -33,14 +33,14 @@ KoalaSync is built for private watch parties without unnecessary data collection
|
||||
3. Share the invite link with your friends.
|
||||
4. Open your favorite streaming site or media server.
|
||||
5. Select the active video tab.
|
||||
6. Press play — everyone stays perfectly in sync.
|
||||
6. Press play. Everyone stays perfectly in sync.
|
||||
|
||||
|
||||
|
||||
⚙️ UNDER THE HOOD
|
||||
KoalaSync is lightweight, transparent, and built with privacy in mind.
|
||||
|
||||
• On-Demand Relay: Playback state is synchronized through a custom WebSocket-based relay server. No persistent connection — the relay is only active while you're in a room. No background traffic, no idle connections.
|
||||
• On-Demand Relay: Playback state is synchronized through a custom WebSocket-based relay server. There is no permanent background connection. The relay connection is maintained only while you're in a room and closes when you leave or automatically after two hours without a selected-video heartbeat.
|
||||
• No Media Streaming: KoalaSync does not stream, proxy, upload, download, or redistribute any video content. Everyone watches from their own browser on the original website.
|
||||
• Temporary Room State Only: The relay server only coordinates room state such as play, pause, seek position, active target, nickname, and readiness status.
|
||||
• Docker Self-Hosting: The relay server can be self-hosted with Docker if you prefer to run your own private instance.
|
||||
|
||||
+29
-27
@@ -7,7 +7,7 @@ Welcome to the KoalaSync project. This file is the primary entry point for any d
|
||||
> - **Font Stack**: Use a modern system font stack (e.g., -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif) to maintain a premium look without external dependencies. Prohibit the use of `@import` or `<link>` for external font services.
|
||||
>
|
||||
> [!IMPORTANT]
|
||||
> **Caveman Communication Protocol**: To conserve context window and tokens, AI agents MUST communicate with the user in **caveman language** (broken, short German/English hybrid caveman style, e.g., "Ich Antigravity. Ich machen Git pull. Code fertig.") at all times. This rule is highly strict and must NOT be broken under any circumstances unless the user explicitly requests to drop it.
|
||||
> **Communication Standard**: Be concise, concrete, and professional. Match the user's language when practical, explain risky changes before making them, and report exactly what was changed and verified. Do not use joke protocols or intentionally broken language in contributor-facing work.
|
||||
|
||||
---
|
||||
|
||||
@@ -21,28 +21,30 @@ KoalaSync is a specialized tool for **synchronized video playback** across multi
|
||||
- `extension/`: Browser Extension (Chrome & Firefox, Manifest V3). Contains background service worker, content scripts, and popup UI.
|
||||
- `server/`: Node.js Relay Server using Socket.IO (WebSocket-only).
|
||||
- `website/`: **Landing Page** & Invitation Bridge (Marketing, Tutorials, and Downloads).
|
||||
- **`build.cjs`**: Zero-dependency static site compiler. Translates `template.html` + `locales/*.json` → `www/`. Also minifies CSS/JS automatically.
|
||||
- **`www/` is auto-generated**: Never edit files in `www/` directly. Always edit source files (`template.html`, `style.css`, `app.js`, `lang-init.js`, `locales/*.json`) and run `node website/build.cjs` to regenerate. CSS/JS are output as `.min.*` files — a built-in cleanup step removes stale artifacts on each build.
|
||||
- `shared/`: **Single Source of Truth** for protocol constants and event names.
|
||||
- **`build.cjs`**: Static site compiler using the repository's build dependencies. Translates `template.html` + `locales/*.json` → `www/`, generates the sitemap, and minifies HTML/CSS/JS.
|
||||
- **`www/` is auto-generated**: Never edit files in `www/` directly. Always edit source files (`template.html`, `style.css`, `styles/*.css`, `app.js`, `lang-init.js`, `locales/*.json`) and run `node website/build.cjs` to regenerate. `style.css` is the development manifest; `style.legacy.css` is an unloaded, byte-identical migration reference. Production creates page-specific and render-priority CSS bundles from `styles/*.css`; CSS/JS are output as `.min.*` files and stale generated assets are removed on each build.
|
||||
- `shared/`: **Single Source of Truth** for protocol constants, event names, blacklist data, and generated usernames.
|
||||
- `scripts/`: Development utilities (e.g., `build-extension.cjs`).
|
||||
- `docker-compose.yml`: Root-level orchestration for the relay server.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Single Source of Truth**: `shared/constants.js` and `shared/blacklist.js` are the master files. They must be synchronized to the `extension/shared/` directory using `node scripts/build-extension.cjs`.
|
||||
> **Single Source of Truth**: `shared/constants.js`, `shared/blacklist.js`, and `shared/names.js` are the master shared files. They must be synchronized to the `extension/shared/` directory using `node scripts/build-extension.cjs`.
|
||||
> - **Extension Modules** (`background.js`, `popup.js`) import directly from `./shared/constants.js`.
|
||||
> - **Content Scripts** (`content.js`) use a **marker-injected synchronous copy** of the constants. The build script automatically replaces the marked blocks — no manual mirroring needed.
|
||||
|
||||
## 3. Mandatory Reading
|
||||
Before touching any code, you MUST read the following documents in order:
|
||||
1. [ARCHITECTURE.md](ARCHITECTURE.md) – Detailed communication flows, Dual Heartbeat, and two-phase sync protocol.
|
||||
2. [extension/README.md](../extension/README.md) – Extension components, tab structure, and loading process.
|
||||
3. [SYNC_GUIDE.md](SYNC_GUIDE.md) – Protocol constants and synchronization requirements.
|
||||
1. [../README.md](../README.md) – User, developer, and self-hosting overview.
|
||||
2. [README.md](README.md) – Documentation map by role.
|
||||
3. [ARCHITECTURE.md](ARCHITECTURE.md) – Detailed communication flows, Dual Heartbeat, and two-phase sync protocol.
|
||||
4. [../extension/README.md](../extension/README.md) – Extension components, tab structure, and loading process.
|
||||
5. [SYNC_GUIDE.md](SYNC_GUIDE.md) – Protocol constants and synchronization requirements.
|
||||
|
||||
## 4. The "Vanilla JS Mirror" Pattern
|
||||
To avoid boot-time race conditions in Manifest V3 without a bundler, the following architectural trade-off is enforced:
|
||||
- **Synchronous Execution**: `content.js` MUST execute synchronously to catch early media events.
|
||||
- **Automated Injection**: The build script (`node scripts/build-extension.cjs`) automatically injects `EVENTS` and `HEARTBEAT_INTERVAL` into `content.js` using marker-based replacement (see `../scripts/README.md` for marker details).
|
||||
- **Maintenance**: After modifying `shared/constants.js`, simply run the build script. No manual mirroring is required.
|
||||
- **Automated Injection**: The build script (`node scripts/build-extension.cjs`) automatically injects `EVENTS`, `HEARTBEAT_INTERVAL`, and episode utility functions into `content.js` using marker-based replacement (see `../scripts/README.md` for marker details).
|
||||
- **Maintenance**: After modifying any root `shared/` file, run the build script. No manual mirroring is required.
|
||||
|
||||
## 5. File Responsibility Map
|
||||
|
||||
@@ -52,26 +54,24 @@ To avoid boot-time race conditions in Manifest V3 without a bundler, the followi
|
||||
| `content.js` | Video element detection, media control, event origin detection (loop prevention) |
|
||||
| `popup.js` | UI rendering, user input handling, peer display, invitation link generation |
|
||||
| `bridge.js` | Landing page ↔ extension communication for invitation join flow |
|
||||
| `episode-utils.js` | Shared episode parsing, imported by background and injected into content |
|
||||
| `title-privacy.js` | Tab/media title privacy normalization and sanitization |
|
||||
| `page-api-seek-overrides.js` | Page-level seek bridge for site-specific player APIs |
|
||||
| `server/index.js` | Room management, message relay, rate limiting, authentication, peer lifecycle |
|
||||
|
||||
## 6. Design Guidelines
|
||||
The popup UI follows a strict design system. Do not modify these variables or the layout structure without explicit approval.
|
||||
- **Font**: System font stack. **MANDATORY**: No external CDNs or Google Fonts to ensure 100% privacy.
|
||||
- **Popup Width**: Fixed at `320px`.
|
||||
- **Tab Structure**: Must maintain the **Room**, **Sync**, **Settings**, and **Dev** tabs.
|
||||
- **CSS Variables**:
|
||||
| Variable | Value | Purpose |
|
||||
| :--- | :--- | :--- |
|
||||
| `--bg` | `#0f172a` | Main background |
|
||||
| `--card` | `#1e293b` | Form and info cards |
|
||||
| `--accent` | `#6366f1` | Primary actions and branding |
|
||||
| `--success` | `#22c55e` | Success states / Online dot |
|
||||
| `--error` | `#ef4444` | Errors / Offline dot |
|
||||
- **Tab Structure**: Must maintain the visible **Room**, **Sync**, **Settings**, and **Status** tabs. The hidden **Dev** tab is available only for developer diagnostics.
|
||||
- **Appearance modes**: Preserve `system`, `light`, and `dark` behavior and the Eucalyptus, Cyber, and Graphite palettes.
|
||||
- **CSS variables**: Use the semantic tokens defined in `popup.html` (`--bg`, `--card`, `--accent`, `--text`, `--text-muted`, `--success`, `--warning`, and `--error`). Do not hard-code the retired indigo/slate palette or bypass palette-specific token overrides.
|
||||
|
||||
## 7. Non-Negotiables (Core Logic)
|
||||
The following features are critical and must not be removed or fundamentally altered:
|
||||
- **Two-Phase Force Sync**: The `Prepare` → `ACK` → `Execute` flow ensures all peers are buffered before playback resumes.
|
||||
- **Episode Auto-Sync**: Ensures series binges stay perfectly synced. A lobby initiates during title transitions, freezing peers until everyone is ready.
|
||||
- **Host Control Mode**: In `host-only` rooms, only the host and promoted controllers may initiate room-moving playback events. Both client-side UX and server-side gates must remain intact.
|
||||
- **Dual Heartbeat**:
|
||||
- **Background Heartbeat (1m)**: Ensures session persistence even without a video element.
|
||||
- **Content Heartbeat (15s)**: Transmits current video metadata (time, title).
|
||||
@@ -80,8 +80,9 @@ The following features are critical and must not be removed or fundamentally alt
|
||||
- **Platform Specifics**: Specialized click-logic for YouTube (`.ytp-play-button`) and Twitch.
|
||||
- **pollSeekReady()**: Polling mechanism that checks `video.readyState` before acknowledging sync.
|
||||
- **SW Keep-alive**: Use of `chrome.alarms` to prevent the Manifest V3 Service Worker from suspending.
|
||||
- **Diagnostics**: The "Dev" tab provides real-time access to the underlying `<video>` state for troubleshooting.
|
||||
- **Diagnostics**: The visible **Status** tab provides real-time access to connection state, ping, logs, history, and underlying `<video>` state for troubleshooting.
|
||||
- **Persistence**: `peerId` and `username` must be stored to remain stable across sessions.
|
||||
- **Title Privacy**: Do not accidentally reintroduce tab/media title sharing when users disabled or reduced it.
|
||||
- **Room ID Format**: Room IDs are restricted to `[a-zA-Z0-9-]` only (alphanumeric + hyphens). This is enforced server-side.
|
||||
|
||||
## 8. Technical Constraints
|
||||
@@ -89,7 +90,8 @@ The following features are critical and must not be removed or fundamentally alt
|
||||
- **Manual Protocol**: `background.js` implements a subset of the Socket.IO wire protocol natively.
|
||||
- **Server Transport**: Restricted to `websocket` only. Polling is disabled.
|
||||
- **Docker Context**: The Docker build must run from the **Repo Root**.
|
||||
- **Manifest Settings**: `run_at` must remain `document_idle`, and `all_frames` must remain `false`.
|
||||
- **Content Script Scope**: `bridge.js` is the only static content script in the manifest and runs on `https://sync.koalastuff.net/*` at `document_start`. Video control scripts are injected only into the selected tab via `chrome.scripting`; do not add broad persistent video content-script matches.
|
||||
- **Self-Hosting Salt**: Production/self-hosted relay examples must include a unique `SERVER_SALT` so room-password hashes are not derived with the public fallback salt.
|
||||
- **Strict Backward & Forward Compatibility (Store Delay Rule)**: Browser extensions are distributed through stores (e.g., Chrome Web Store, Firefox Add-ons) which can take up to 2 weeks to approve updates. Therefore, the server MUST NOT reject older extension clients unless a critical protocol version bump is explicitly authorized, and new extension versions MUST remain fully operational when connected to older servers (e.g., by silently falling back if a new feature is not supported). This is a core architectural requirement.
|
||||
|
||||
## 9. Security & Deployment
|
||||
@@ -117,7 +119,7 @@ Before starting any task, committing, or pushing, you **MUST** run `git pull --r
|
||||
> **AI AGENTS MUST FOLLOW THIS EXACT SEQUENCE WHEN RELEASING A NEW VERSION OR TAGGING.**
|
||||
>
|
||||
> **🚫 NO MANUAL VERSION BUMPING**: You MUST **NEVER** manually modify the version strings in `package.json`, `extension/manifest.base.json`, or `website/version.json`. The GitHub Actions CI pipeline automatically extracts the version from the git tag (e.g. `v2.0.5` -> `2.0.5`), injects it into all target files, and commits the updates back to `main` with `[skip ci]`. Manual bumps will cause merge conflicts and build failures.
|
||||
> - **Website Versioning**: **NEVER** manually modify the version fallback strings in `website/index.html`. The website dynamically fetches the latest version and release date from `website/version.json` at runtime using `website/app.js`. Manual bumps in the HTML file are completely redundant and should be avoided.
|
||||
> - **Website Versioning**: **NEVER** manually modify generated version strings in `website/www/`. The website build injects version data from `website/version.json` into generated output.
|
||||
1. **MANDATORY SYNTAX & LINT CHECKS**: Before staging, committing, or pushing any changes, you **MUST** run both checks on every modified JavaScript file:
|
||||
- **Syntax Validation**: Run `node -c` on every single modified JavaScript file (e.g., `node -c extension/background.js` and `node -c extension/content.js`). **NEVER** commit or push code that fails this check.
|
||||
- **ESLint Validation**: Run `npm run lint` (or `npx eslint .`). The output must show **zero errors and zero warnings**. ESLint is configured to catch undefined variables, unused vars, unreachable code, and other semantic issues. **NEVER** commit or push code that fails this check.
|
||||
@@ -141,18 +143,18 @@ Before starting any task, committing, or pushing, you **MUST** run `git pull --r
|
||||
3. Implement the handler in `server/index.js` and `background.js`.
|
||||
|
||||
### Making Website Changes
|
||||
1. Edit source files in `website/` (`template.html`, `style.css`, `app.js`, `lang-init.js`, or `locales/*.json`).
|
||||
1. Edit source files in `website/` (`template.html`, `style.css`, `styles/*.css`, `app.js`, `lang-init.js`, or `locales/*.json`).
|
||||
2. Run the compiler: `node website/build.cjs`. This generates the multilingual pages in `www/` and minifies CSS/JS.
|
||||
3. Verify the output: `node --check website/www/app.js && node --check website/www/lang-init.js`.
|
||||
3. Verify the sources and generated contract: `node --check website/app.js`, `node --check website/lang-init.js`, `node scripts/test-website-locales.mjs`, and `node scripts/test-website-theme.mjs`.
|
||||
4. Test locally: `npx serve website/www` or `python3 -m http.server 8080 -d website/www`.
|
||||
5. Commit both source changes and the updated `www/` output.
|
||||
5. Commit the source changes only. `website/www/` is generated and gitignored.
|
||||
|
||||
### Testing Locally
|
||||
1. Run the build script: `node scripts/build-extension.cjs`.
|
||||
2. Load `dist/chrome/` as an "Unpacked Extension" in Chrome (or `dist/firefox/` in Firefox).
|
||||
3. Start the server from the root: `docker-compose up --build`.
|
||||
3. Start the server from the root: `docker compose up --build`.
|
||||
4. Use **different browser profiles** or vendors to test multi-peer logic.
|
||||
5. Use the **Dev tab** to verify real-time video element metadata.
|
||||
5. Use the **Status tab** to verify real-time connection state, logs, and video element metadata.
|
||||
|
||||
### Locking Old Versions
|
||||
1. Update `MIN_VERSION` in the server's `.env` file to the minimum acceptable version.
|
||||
|
||||
+76
-8
@@ -4,20 +4,88 @@ All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## Unreleased
|
||||
## [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
|
||||
- **Extension: Host-access recovery races** — Prevents stale permission grants, reinjection retries, closed tabs, and rapid target changes from reactivating or clearing the wrong video tab.
|
||||
- **Extension: Cross-browser host patterns** — Uses Firefox-compatible match patterns for local servers while preserving exact Chromium port scopes.
|
||||
- **Extension: Force-sync target integrity** — Keeps sampled playback time and acknowledgements bound to the selected tab during recovery and target changes.
|
||||
|
||||
## [v2.6.2] — 2026-07-15
|
||||
|
||||
### Added
|
||||
- **Website: Site-access recovery guide** — Added an English help page and a localized banner across every landing-page language.
|
||||
|
||||
### Fixed
|
||||
- **Extension: Withheld website access recovery** — Detects browser-withheld host access, shows a localized allow-access action, requests only the selected website origin, and resumes the selected tab after access is granted.
|
||||
- **Extension: Target-tab reliability** — Hardened permission recovery against rapid tab changes, navigation, closed tabs, service-worker restoration, and stale pending state.
|
||||
- **Browser compatibility** — Uses Chrome's toolbar access request only when available, with a direct user-gesture permission fallback for Firefox and older Chromium browsers.
|
||||
|
||||
---
|
||||
|
||||
## [v2.6.1] — 2026-07-15
|
||||
|
||||
### Fixed
|
||||
- **Extension: Episode Lobby peer list** — Peer names in the lobby are now rendered as text instead of markup. A peer could previously put HTML in their username and have it rendered in everyone else's popup, which allowed loading remote images (leaking viewer IP addresses) and spoofing the readiness badges. Scripts were already blocked by the extension's content security policy.
|
||||
|
||||
### Changed
|
||||
- **Release checks: AMO validation** — `npm run verify` now runs Mozilla's `addons-linter` against the built Firefox artifact with `--warnings-as-errors`, and ESLint enforces `no-unsanitized`, so upload-blocking issues surface locally instead of at submission time.
|
||||
|
||||
---
|
||||
|
||||
## [v2.6.0] — 2026-07-15
|
||||
|
||||
### Added
|
||||
- **Extension: Appearance controls** — Added localized system, light, and dark theme options with an early theme initializer to avoid flashes during popup startup.
|
||||
|
||||
### Changed
|
||||
- **Extension: Popup and settings redesign** — Unified controls, status surfaces, colors, icons, badges, and accessibility behavior; settings are now organized into mutually exclusive accordion groups.
|
||||
|
||||
### Fixed
|
||||
- **Extension: Episode Lobby reliability** — Prevented the lobby from remaining stuck in a loading state and stopped episode transitions from triggering on non-episodic media.
|
||||
- **Extension: Tab-title normalization** — Notification counters such as `(14)`, `[7]`, and `(99+)` are removed reliably without stripping legitimate large numeric titles.
|
||||
|
||||
---
|
||||
|
||||
## [v2.5.4] — 2026-07-08
|
||||
|
||||
### Changed
|
||||
- **Extension: Refined clutter blacklist** — Removed `localhost` and cloud storage providers (`drive.google.com`, `dropbox.com`, `onedrive.live.com`, `icloud.com`) from the domains blacklist so that local web development servers and tabs hosting video files in cloud storage are no longer hidden under "Hide Clutter Tabs".
|
||||
|
||||
---
|
||||
|
||||
## [v2.5.3] — 2026-07-02
|
||||
|
||||
### Fixed
|
||||
- **Extension: Disney+ force sync and seek reliability** — The v2.5.2 Disney+ page-API integration leaked blob-relative `<video>` time into force sync, seeks, and heartbeats when the page-API bridge had no fresh data, which presented as "force sync does nothing on Disney+". Time/duration accessors now refuse to return native values on Disney+ (returning null/0) so stale bridge data degrades to a clean no-op instead of broadcasting garbage. `FORCE_SYNC_PREPARE` and `SEEK` payloads are now validated as finite before being relayed, and the popup's force-sync flow fails cleanly with a clear error rather than sending NaN through.
|
||||
- **Extension: Force-sync no longer routed twice** — A popup-initiated `FORCE_SYNC_PREPARE` was being delivered to the content script twice (once by the generic popup route and once by the force-sync-specific route), causing a double seek. The generic route is now scoped to play/pause/seek only.
|
||||
- **Extension: Disney+ Host Control Mode classification** — `hcmIsLive` previously read the native `<video>` duration to detect live streams, which on Disney+ is blob-relative garbage and falsely classified every stream as live (disabling snap-back and the desync dialog). The native-duration live signal is now skipped on Disney+ while YouTube/Twitch live detection via `Infinity` duration is preserved.
|
||||
- **Extension: Disney+ episode auto-sync and lobby readiness** — Episode-transition detection and the episode-lobby "ready" poll now read the playhead through the gated time accessor, so they no longer rely on blob-relative `currentTime` on Disney+.
|
||||
- **Extension: Force-sync median no longer skewed by peers without a known time** — A peer broadcasting `currentTime: null` (e.g. a Disney+ peer whose page-API bridge was not yet ready, or a freshly joined peer) was coerced to `0` by the jump-to-others median calculation, dragging the sync target toward the start of the video. Null and empty peer times are now excluded before the median.
|
||||
- **Extension: Force-sync `jump-to-me` retry on Disney+** — When the content script responds but the page-API bridge has not yet delivered a finite time (typical during the first ~250 ms after a Disney+ player loads), the popup now retries once without redundantly re-injecting the content script.
|
||||
- **Extension: Empty-string seek payload rejection** — The internal seek-time coercion no longer treats `''` as `0`; an empty-string `targetTime`/`currentTime` is rejected as invalid.
|
||||
- **Extension: Disney+ force sync and seeking** — Fixed force sync on Disney+ failing or jumping to wrong positions. The extension now relies solely on accurate player-API time and fails cleanly when it isn't available yet, instead of falling back to unreliable raw video data.
|
||||
- **Extension: Force-sync accuracy** — When syncing to the group, peers whose current position isn't known yet (e.g. a Disney+ peer that just loaded) no longer pull the sync target toward the start of the video.
|
||||
- **Extension: Disney+ Host Control Mode** — Regular Disney+ content is no longer misclassified as a live stream, which had silently disabled the desync dialog and snap-back for guests in host-controlled rooms. YouTube and Twitch live detection is unchanged.
|
||||
- **Extension: Disney+ episode auto-sync** — Episode transitions and the "waiting for peers" lobby flow now work reliably on Disney+ again.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# 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: 500 Unicode code points.
|
||||
- Decrypted text is untrusted. Escape HTML before applying the supported limited
|
||||
Markdown formatting.
|
||||
- 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.
|
||||
- The overlay uses a Shadow DOM and never changes host-page layout.
|
||||
- On `fullscreenchange`, its host moves into `document.fullscreenElement` and remains
|
||||
visible.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
@@ -18,8 +18,9 @@ KoalaSync is a **privacy-preserving watch-party sync tool**, not a security prod
|
||||
|
||||
- **Rooms are ephemeral.** They exist for a few hours and are auto-reaped. There are no
|
||||
accounts, no persistent storage, no money, and no sensitive content on the relay.
|
||||
- **The relay is a dumb, stateless message bus.** It forwards play/pause/seek between
|
||||
peers who *chose* to watch together and joined via an invite link shared out-of-band.
|
||||
- **The relay is a zero-persistence, in-memory message bus.** It temporarily holds room
|
||||
and peer state while forwarding play/pause/seek between peers who *chose* to watch
|
||||
together and joined via an invite link shared out-of-band.
|
||||
- **Participants are invited.** Anyone in a room was let in. The social contract is
|
||||
"we're watching a video together," not "mutually distrusting parties."
|
||||
|
||||
|
||||
+60
-1
@@ -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.
|
||||
|
||||
+37
-5
@@ -1,8 +1,40 @@
|
||||
# Technical Documentation
|
||||
|
||||
This directory contains deep-dives into the KoalaSync protocol and architecture.
|
||||
This directory contains deep-dives into the KoalaSync protocol, architecture, roadmap, and operational guidelines.
|
||||
|
||||
- [HOW_IT_WORKS.md](HOW_IT_WORKS.md): Step-by-step walkthrough of every user flow, from room creation to synchronized playback. Ideal for store reviewers and manual testers.
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md): Communication flows, Dual Heartbeat, and Sync logic.
|
||||
- [SYNC_GUIDE.md](SYNC_GUIDE.md): Protocol constants and sync requirements.
|
||||
- [TRANSLATION.md](TRANSLATION.md): Translation and localization guide for the extension and website.
|
||||
## Start Here by Role
|
||||
|
||||
- **Users and reviewers**: Start with [HOW_IT_WORKS.md](HOW_IT_WORKS.md), then [PRIVACY.md](PRIVACY.md) and [TESTED_SERVICES.md](TESTED_SERVICES.md).
|
||||
- **Self-hosters**: Start with the root [README.md](../README.md), then [devops.md](devops.md), [PROTOCOL.md](PROTOCOL.md), and the examples in `../examples/`.
|
||||
- **Contributors**: Start with [../CONTRIBUTING.md](../CONTRIBUTING.md), then [ARCHITECTURE.md](ARCHITECTURE.md), [SYNC_GUIDE.md](SYNC_GUIDE.md), and the README for the subdirectory you are editing.
|
||||
- **AI agents**: Start with [AI_INIT.md](AI_INIT.md), then read the relevant subdirectory README before changing files.
|
||||
- **Security reviewers**: Start with [SECURITY.md](../SECURITY.md), [KNOWN_LIMITATIONS.md](KNOWN_LIMITATIONS.md), [PRIVACY.md](PRIVACY.md), and [PROTOCOL.md](PROTOCOL.md).
|
||||
|
||||
## 🏗️ Core Architecture & Design
|
||||
|
||||
- **[ARCHITECTURE.md](ARCHITECTURE.md)**: Overview of the communication flows, Dual Heartbeat architecture, and synchronization logic.
|
||||
- **[HOW_IT_WORKS.md](HOW_IT_WORKS.md)**: Step-by-step walkthrough of every user flow, from room creation to synchronized playback. Ideal for store reviewers and manual testers.
|
||||
- **[host-control-mode.md](host-control-mode.md)**: Design, requirements, and edge cases of the Host Control feature.
|
||||
- **[AI_INIT.md](AI_INIT.md)**: Maintainer and AI-agent onboarding: non-negotiables, workflow order, and safety checks.
|
||||
|
||||
## 📡 Protocol & Synchronization
|
||||
|
||||
- **[PROTOCOL.md](PROTOCOL.md)**: Low-level message format and payload descriptions for the KoalaSync sync protocol.
|
||||
- **[SYNC_GUIDE.md](SYNC_GUIDE.md)**: Guide on keeping protocol constants synchronized across the workspace.
|
||||
|
||||
## 📋 Compatibility, Roadmap & Contribution
|
||||
|
||||
- **[TESTED_SERVICES.md](TESTED_SERVICES.md)**: Status of compatibility with major streaming services and contribution guidelines for testing new platforms.
|
||||
- **[KNOWN_LIMITATIONS.md](KNOWN_LIMITATIONS.md)**: Threat model and accepted design limitations (NOFIX entries) for security audits.
|
||||
- **[PRIVACY.md](PRIVACY.md)**: Privacy model and data-handling policy for users, reviewers, and contributors.
|
||||
- **[ROADMAP.md](ROADMAP.md)**: Planned features, backlog items, and rejected proposals.
|
||||
- **[TRANSLATION.md](TRANSLATION.md)**: Guide for native speakers to contribute and audit dynamic extension/website translations.
|
||||
|
||||
## 🚀 DevOps & Releases
|
||||
|
||||
- **[devops.md](devops.md)**: Guide on the automated tag-based release pipeline.
|
||||
- **[CHANGELOG.md](CHANGELOG.md)**: Detailed history of releases and changes.
|
||||
|
||||
---
|
||||
|
||||
*For high-level project information and developer setup instructions, refer to the root [README.md](../README.md).*
|
||||
|
||||
+77
-11
@@ -30,6 +30,83 @@
|
||||
|
||||
*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
|
||||
chat bubble over the selected player opens a dockable, detachable, resizable overlay.
|
||||
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, emoji reactions, 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 +126,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
|
||||
|
||||
+7
-5
@@ -10,8 +10,9 @@ You MUST run the build script in any of the following scenarios:
|
||||
1. **After a fresh `git clone` or `git pull`** (as the synced files are ignored by git).
|
||||
2. **After modifying** `shared/constants.js`.
|
||||
3. **After modifying** `shared/blacklist.js`.
|
||||
4. **Before committing** changes to the repository if any protocol-related files were touched.
|
||||
5. **Before deploying** the server or releasing the extension.
|
||||
4. **After modifying** `shared/names.js`.
|
||||
5. **Before committing** changes to the repository if any shared protocol or extension-mirrored files were touched.
|
||||
6. **Before deploying** the server or releasing the extension.
|
||||
|
||||
## How to sync
|
||||
|
||||
@@ -24,10 +25,11 @@ npm run build:extension
|
||||
|
||||
## What does it do?
|
||||
The build script performs the following actions:
|
||||
1. Synchronizes protocol constants by copying `shared/constants.js`, `shared/blacklist.js`, and `shared/README.md` into `extension/shared/`.
|
||||
1. Synchronizes shared files by copying `shared/constants.js`, `shared/blacklist.js`, `shared/names.js`, and `shared/README.md` into `extension/shared/`.
|
||||
2. Injects `EVENTS`, `HEARTBEAT_INTERVAL`, and `episode-utils.js` functions (`extractEpisodeId`, `sameEpisode`) into `content.js` via marker-based replacement.
|
||||
3. Compiles browser-specific manifest files.
|
||||
4. Packages the final ready-to-publish extension artifacts for Chrome and Firefox into the `dist/` directory.
|
||||
3. Injects browser-specific uninstall URL constants into `background.js` and a build timestamp into `popup.html`.
|
||||
4. Compiles browser-specific manifest files.
|
||||
5. Packages the final ready-to-publish extension artifacts for Chrome and Firefox into the `dist/` directory.
|
||||
|
||||
## Protocol Versioning
|
||||
The system enforces a strict `protocolVersion` check during the `JOIN_ROOM` handshake.
|
||||
|
||||
+44
-24
@@ -1,38 +1,58 @@
|
||||
# Tested Streaming Services
|
||||
# 🎬 Tested Streaming Services & Compatibility
|
||||
|
||||
This document tracks which streaming platforms and media servers have been tested with KoalaSync.
|
||||
This document tracks which streaming platforms and media servers are supported by the KoalaSync extension.
|
||||
|
||||
| Service | Sync Works | Media Title | Episode Auto-Sync | Notes |
|
||||
|---------|:----------:|:-----------:|:-----------------:|-------|
|
||||
| **YouTube** | ✅ Full | ✅ Full | ❌ | Individual videos, not episodes — no episode auto-sync. |
|
||||
| **Twitch** | ✅ Full | ✅ Full | ❌ | Individual streams/VODs, not episodes — no episode auto-sync. |
|
||||
| **Netflix** | ✅ Full | ❌ | ❌ | No media title exposed. |
|
||||
| **Emby** | ✅ Full | ✅ Full | ✅ Full | Best-in-class support. |
|
||||
| **Jellyfin** | ✅ Full | ✅ Full | ✅ Full | — |
|
||||
| **Plex** | Not tested | Not tested | Not tested | — |
|
||||
| **Disney+** | ✅ Full | ⚠️ Partial | ❌ | Series title only (e.g. "The Simpsons"), no episode info. |
|
||||
| **Prime Video** | ✅ Full | ✅ Full | ❌ | — |
|
||||
| **HBO Max / Max** | Not tested | Not tested | Not tested | — |
|
||||
| **Crunchyroll** | Not tested | Not tested | Not tested | — |
|
||||
| **Vimeo** | Not tested | Not tested | Not tested | — |
|
||||
| **Dailymotion** | Not tested | Not tested | Not tested | — |
|
||||
| **ARD / ZDF Mediathek** | Not tested | Not tested | Not tested | — |
|
||||
> [!TIP]
|
||||
> **Contributions are highly welcome!** 🤝 Anyone can easily update this list. If you have tested a streaming service (whether it works, has issues, or is not yet listed), please help the project by submitting a quick Pull Request. See the [How to Contribute](#how-to-contribute) guide below!
|
||||
|
||||
## Legend
|
||||
---
|
||||
|
||||
## Compatibility Matrix
|
||||
|
||||
| Service | Sync Works | Media Title | Episode Auto-Sync | Last Tested | Tested By | Extension Version | Notes |
|
||||
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :--- |
|
||||
| **YouTube** | ✅ Full | ✅ Full | ❌ N/A | — | — | — | Individual videos, not episodes. |
|
||||
| **Twitch** | ✅ Full | ✅ Full | ❌ N/A | — | — | — | Individual streams/VODs. |
|
||||
| **Netflix** | ✅ Full | ❌ | ❌ | — | — | — | No media title exposed. |
|
||||
| **Emby** | ✅ Full | ✅ Full | ✅ Full | — | — | — | Best-in-class support. |
|
||||
| **Jellyfin** | ✅ Full | ✅ Full | ✅ Full | — | — | — | — |
|
||||
| **Plex** | Not tested | Not tested | Not tested | — | — | — | — |
|
||||
| **Disney+** | ✅ Full | ⚠️ Partial | ❌ | — | — | — | Series title only (e.g. "The Simpsons"), no episode info. |
|
||||
| **Prime Video** | ✅ Full | ✅ Full | ❌ | — | — | — | — |
|
||||
| **HBO Max / Max** | Not tested | Not tested | Not tested | — | — | — | — |
|
||||
| **Crunchyroll** | Not tested | Not tested | Not tested | — | — | — | — |
|
||||
| **Vimeo** | Not tested | Not tested | Not tested | — | — | — | — |
|
||||
| **Dailymotion** | Not tested | Not tested | Not tested | — | — | — | — |
|
||||
| **ARD / ZDF Mediathek** | Not tested | Not tested | Not tested | — | — | — | — |
|
||||
| **Vix** | ✅ Full | ✅ Full | ✅ Full | — | — | — | Everything works correctly. |
|
||||
|
||||
### Legend
|
||||
|
||||
| Symbol | Meaning |
|
||||
|--------|---------|
|
||||
| :---: | :--- |
|
||||
| ✅ Full | Works without limitations. |
|
||||
| ⚠️ Partial | Works with caveats (see Notes). |
|
||||
| ❌ N/A | Not applicable or not supported. |
|
||||
| ❌ | Not supported / does not work. |
|
||||
| ❌ N/A | Not applicable (feature does not exist on the platform). |
|
||||
| **Not tested** | Has not been tested yet. |
|
||||
|
||||
---
|
||||
|
||||
## How to Contribute
|
||||
|
||||
Tested a service that's not listed? Found different behavior than documented?
|
||||
Updating this compatibility list is quick and easy! You don't need deep coding skills to contribute:
|
||||
|
||||
1. Test KoalaSync on the service with two browser profiles
|
||||
2. Use the extension's **Dev tab** to check `readyState`, `currentTime`, and media title
|
||||
3. Open a GitHub issue or PR updating this table
|
||||
1. **Fork the Repository**: Click the **Fork** button at the top of the [KoalaSync GitHub Repository](https://github.com/Shik3i/KoalaSync).
|
||||
2. **Edit this File**: Open [docs/TESTED_SERVICES.md](TESTED_SERVICES.md) in your fork's browser editor (or clone it locally) and update the table with your testing details.
|
||||
3. **Commit & Push**: Commit your changes with a clear message (e.g., `docs: update Netflix compatibility status`).
|
||||
4. **Create a Pull Request**: Submit the Pull Request (PR) from your fork to our `main` branch.
|
||||
|
||||
> [!NOTE]
|
||||
> **Reporting Problems:** If you notice a bug or partial support on a service, please open a [GitHub Issue](https://github.com/Shik3i/KoalaSync/issues) describing the problem, and link it in the **Notes** column of the table.
|
||||
>
|
||||
> _If you are unsure how to create/link an issue, don't worry! Simply submit the PR anyway, and the maintainers will gladly create and link the issue for you._
|
||||
|
||||
---
|
||||
|
||||
## Technical Background
|
||||
|
||||
|
||||
@@ -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.
|
||||
+13
-2
@@ -1,6 +1,8 @@
|
||||
import noUnsanitized from "eslint-plugin-no-unsanitized";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["coverage/**", "dist/**", "node_modules/**", "scratch/**"]
|
||||
ignores: ["coverage/**", "dist/**", "node_modules/**", "scratch/**", "website/www/**"]
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
@@ -18,6 +20,8 @@ export default [
|
||||
setInterval: "readonly",
|
||||
clearTimeout: "readonly",
|
||||
clearInterval: "readonly",
|
||||
requestAnimationFrame: "readonly",
|
||||
cancelAnimationFrame: "readonly",
|
||||
fetch: "readonly",
|
||||
CustomEvent: "readonly",
|
||||
MutationObserver: "readonly",
|
||||
@@ -43,7 +47,14 @@ export default [
|
||||
process: "readonly",
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
"no-unsanitized": noUnsanitized
|
||||
},
|
||||
rules: {
|
||||
// Mirrors the AMO validator's "Unsafe assignment to innerHTML" check, so a
|
||||
// rejection at upload time surfaces here instead.
|
||||
"no-unsanitized/property": "error",
|
||||
"no-unsanitized/method": "error",
|
||||
"no-undef": "error",
|
||||
"no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", "caughtErrorsIgnorePattern": "^_" }],
|
||||
"no-unreachable": "error",
|
||||
@@ -58,7 +69,7 @@ export default [
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["server/**/*.js", "scripts/**/*.js", "scripts/**/*.cjs", "website/build.cjs"],
|
||||
files: ["server/**/*.js", "scripts/**/*.js", "scripts/**/*.cjs", "website/build.cjs", "website/**/*.cjs"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
require: "readonly",
|
||||
|
||||
@@ -18,6 +18,14 @@
|
||||
# # Clean URLs support (resolves /join to join.html, etc.)
|
||||
# try_files {path} {path}.html {path}/
|
||||
# file_server
|
||||
#
|
||||
# # Serve the themed 404 page for unknown URLs (without this block,
|
||||
# # Caddy returns an empty 404 response)
|
||||
# handle_errors {
|
||||
# @notfound expression {err.status_code} == 404
|
||||
# rewrite @notfound /404.html
|
||||
# file_server
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# syncserver.koalastuff.net {
|
||||
@@ -57,6 +65,14 @@ sync.koalastuff.net {
|
||||
try_files {path} {path}.html {path}/
|
||||
file_server
|
||||
|
||||
# Serve the themed 404 page for unknown URLs (without this block,
|
||||
# Caddy returns an empty 404 response)
|
||||
handle_errors {
|
||||
@notfound expression {err.status_code} == 404
|
||||
rewrite @notfound /404.html
|
||||
file_server
|
||||
}
|
||||
|
||||
# Static Caching for high-performance PageSpeed (1 year with validation)
|
||||
@static {
|
||||
file
|
||||
|
||||
@@ -9,6 +9,7 @@ services:
|
||||
- MIN_VERSION=1.0.0 # Minimum client version allowed to connect
|
||||
- MAX_ROOMS=100 # Maximum number of rooms that can exist
|
||||
- MAX_PEERS_PER_ROOM=25 # Maximum number of peers allowed per room
|
||||
- SERVER_SALT=CHANGE_ME_GENERATE_WITH_OPENSSL_RAND_BASE64_32 # Required: unique random salt for room-password hashes
|
||||
- ADMIN_METRICS_TOKEN= # Optional: 32+ char random token for aggregate-only /health metrics
|
||||
pids_limit: 2048 # Limits the container to 2048 process IDs for safety
|
||||
networks: # Attaches the service to the networks listed below
|
||||
|
||||
@@ -11,6 +11,7 @@ services:
|
||||
- MIN_VERSION=1.0.0 # Minimum client version allowed to connect
|
||||
- MAX_ROOMS=100 # Maximum number of rooms that can exist
|
||||
- MAX_PEERS_PER_ROOM=25 # Maximum number of peers allowed per room
|
||||
- SERVER_SALT=CHANGE_ME_GENERATE_WITH_OPENSSL_RAND_BASE64_32 # Required: unique random salt for room-password hashes
|
||||
- ADMIN_METRICS_TOKEN= # Optional: 32+ char random token for aggregate-only /health metrics
|
||||
pids_limit: 2048 # Limits the container to 2048 process IDs for safety
|
||||
networks: # Attaches the service to the networks listed below
|
||||
|
||||
+86
-32
@@ -1,52 +1,106 @@
|
||||
# KoalaSync Browser Extension
|
||||
|
||||
A Manifest V3 Browser Extension (Chrome & Firefox) for synchronized video playback across any website.
|
||||
This directory contains the Manifest V3 browser extension for Chrome and Firefox. It owns the popup UI, background service worker, content-script video control, invitation bridge, audio processing, and all browser-local settings.
|
||||
|
||||
## Where You Are
|
||||
|
||||
- `manifest.base.json` is the source manifest. The build script creates browser-specific `manifest.json` files in `dist/chrome/` and `dist/firefox/`.
|
||||
- `background.js` is the long-lived coordinator: WebSocket client, room state, host-control authority, heartbeat, reconnects, tab selection, and content-script injection.
|
||||
- `content.js` runs in the selected video tab. It detects video state, applies remote play/pause/seek, handles episode transitions, and applies local audio processing.
|
||||
- `popup.html` and `popup.js` implement the visible extension UI.
|
||||
- `extension/shared/` is generated by `npm run build:extension` from the root `shared/` directory. Do not edit it directly.
|
||||
|
||||
## Key Features
|
||||
- **Manifest V3**: Optimized Service Worker architecture with session persistence.
|
||||
- **Pure Vanilla JS**: No external dependencies or heavy libraries.
|
||||
- **Smart Peer IDs**: Hexadecimal IDs combined with customizable Usernames for easy identification.
|
||||
- **On-Demand Connection**: The service worker only maintains a WebSocket connection while you're in a room. No persistent background connections — privacy-first architecture. Based on `connectIntent` flag that gates all reconnect attempts.
|
||||
- **Live Diagnostics**: Built-in "Dev" tab for real-time video state debugging (ReadyState, CurrentTime, etc.).
|
||||
- **Dynamic i18n (Multi-Language)**: Fully localized in 13 languages (`en`, `de`, `fr`, `es`, `it`, `pl`, `tr`, `nl`, `ja`, `ko`, `pt-BR`, `pt`, `ru`) with auto-detected fallback and dynamic on-the-fly language selectors.
|
||||
|
||||
- **Manifest V3**: Service-worker architecture with session persistence and explicit keep-alive handling.
|
||||
- **Pure Vanilla JS**: No extension runtime dependencies and no bundler inside `extension/`.
|
||||
- **On-Demand Connection**: The service worker connects only while the user intends to be in a room.
|
||||
- **Host Control & Co-Hosts**: Hosts can switch a room into `host-only` mode and grant controller rights to trusted peers.
|
||||
- **Episode Auto-Sync**: Title/episode changes can open a lobby so peers resume together once everyone is ready.
|
||||
- **Smart Matching & Title Privacy**: Matching video tabs are highlighted, while tab/media title sharing can be reduced or disabled.
|
||||
- **Audio Processing**: Optional local compressor settings live in the dedicated audio options page.
|
||||
- **Status Diagnostics**: The Status tab exposes connection state, ping, video debug data, action history, and copyable logs.
|
||||
- **Dynamic i18n**: 15 languages are supported: `en`, `de`, `fr`, `es`, `it`, `nl`, `pl`, `pt`, `pt-BR`, `tr`, `ru`, `ja`, `ko`, `zh`, and `uk`.
|
||||
|
||||
## Tab Overview
|
||||
1. **Room**: Manage connections, view active peers, and share invitation links.
|
||||
2. **Sync**: Control video playback (Play/Pause/Force Sync) and view recent activity.
|
||||
3. **Settings**: Customize your Username, toggle domain-based Noise Filtering, and switch the App Language.
|
||||
4. **Dev**: Monitor connection status and view real-time video element metadata for debugging.
|
||||
|
||||
1. **Room**: Select official/custom server, create or join rooms, view peers, share invite links, and manage Host Control when supported by the relay.
|
||||
2. **Sync**: Select the video tab, send play/pause/seek/force-sync actions, and view episode lobby state.
|
||||
3. **Settings**: Configure username, title sharing, noise filtering, auto episode sync, notifications, language, and audio options.
|
||||
4. **Status**: Inspect connection state, latency, video debug info, history, and logs for bug reports.
|
||||
5. **Dev**: Hidden developer-only controls shown for the `KoalaDev` username.
|
||||
|
||||
## Privacy & Permissions
|
||||
KoalaSync requires `<all_urls>` permission to detect and interact with video elements (`<video>`) on websites.
|
||||
- **No Browsing History**: We do not track or store your browsing history.
|
||||
- **State Management**: Sensitive data (Room Passwords) is stored locally using `chrome.storage`.
|
||||
- **Zero Telemetry**: No analytics or external tracking scripts.
|
||||
- **Zero Runtime Dependencies**: The extension is built with pure Vanilla JS and contains no external libraries or tracking scripts, ensuring performance and privacy.
|
||||
|
||||
KoalaSync requires `<all_urls>` host permission so it can detect and control `<video>` elements on arbitrary sites.
|
||||
|
||||
- No browsing history is collected or uploaded.
|
||||
- Room credentials and user settings are stored locally with `chrome.storage`.
|
||||
- No analytics, external scripts, external fonts, or tracking libraries are used by the extension.
|
||||
- Audio processing is local to the selected tab.
|
||||
- Title privacy controls decide whether tab/media titles are shared with room peers.
|
||||
|
||||
## Installation
|
||||
1. **Prepare Extension**: From the repository root, run:
|
||||
```bash
|
||||
npm run build:extension
|
||||
```
|
||||
2. Open Chrome and go to `chrome://extensions/`.
|
||||
3. Enable **Developer mode** (top right).
|
||||
4. Click **Load unpacked** and select the `dist/chrome` folder.
|
||||
|
||||
From the repository root:
|
||||
```bash
|
||||
npm install
|
||||
npm run build:extension
|
||||
```
|
||||
|
||||
Then load the generated bundle:
|
||||
|
||||
- Chrome/Chromium: open `chrome://extensions/`, enable Developer Mode, and load `dist/chrome`.
|
||||
- Firefox: open `about:debugging`, choose **This Firefox**, and load `dist/firefox/manifest.json`.
|
||||
|
||||
## Development
|
||||
If you modify `shared/constants.js`, you must synchronize the changes by running the build script from the root:
|
||||
|
||||
Run the build whenever shared protocol files or extension packaging inputs change:
|
||||
```bash
|
||||
npm run build:extension
|
||||
```
|
||||
This ensures that the `extension/shared` folder is updated with the latest protocol constants.
|
||||
|
||||
The build copies `shared/constants.js`, `shared/blacklist.js`, `shared/names.js`, and `shared/README.md` into `extension/shared/`, injects synchronous constants into `content.js`, generates browser manifests, and creates zip artifacts in `dist/`.
|
||||
|
||||
Useful focused checks from the repository root:
|
||||
```bash
|
||||
node -c extension/background.js
|
||||
node -c extension/content.js
|
||||
node -c extension/popup.js
|
||||
node scripts/test-episode-utils.mjs
|
||||
node scripts/test-title-privacy.mjs
|
||||
node scripts/test-audio-settings.mjs
|
||||
node scripts/test-locales.cjs
|
||||
```
|
||||
|
||||
For the full suite, run:
|
||||
```bash
|
||||
npm run verify
|
||||
```
|
||||
|
||||
## Do Not Break
|
||||
|
||||
- Keep `content.js` synchronous and IIFE-based; it cannot import ES modules directly.
|
||||
- Keep the injection markers used by `scripts/build-extension.cjs`.
|
||||
- Keep protocol names in `shared/constants.js` as the source of truth.
|
||||
- Keep extension runtime dependencies at zero unless the project explicitly decides to introduce a bundler.
|
||||
- Keep all extension assets self-hosted.
|
||||
|
||||
## Module Structure
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `background.js` | Service worker: message routing, tab listeners, startup |
|
||||
| `content.js` | Video detection, audio processing, episode transition (IIFE) |
|
||||
| `popup.js` | Popup UI: join/create, tabs, status, settings |
|
||||
| `bridge.js` | Landing page bridge (injected into sync.koalastuff.net) |
|
||||
| `episode-utils.js` | Shared `extractEpisodeId()` / `sameEpisode()` — used by background.js, injected into content.js at build time |
|
||||
| `i18n.js` | Translation loader |
|
||||
| `shared/` | Constants, blacklist, name generator |
|
||||
| `background.js` | Service worker: WebSocket protocol, room state, host control, tab/content routing, reconnects |
|
||||
| `content.js` | Video detection/control, audio processing, episode transition, host-only guest behavior |
|
||||
| `popup.js` | Popup UI: room join/create, tabs, settings, peer list, status, diagnostics |
|
||||
| `popup.html` | Popup markup, tabs, onboarding, status/debug surfaces |
|
||||
| `bridge.js` | Invitation bridge injected into `sync.koalastuff.net` |
|
||||
| `episode-utils.js` | Shared episode-title parser imported by background and injected into content at build time |
|
||||
| `title-privacy.js` | Tab/media title privacy modes and sanitization helpers |
|
||||
| `audio-options.html` / `audio-options.js` / `audio-options.css` | Dedicated local audio-processing settings page |
|
||||
| `page-api-seek-overrides.js` | Page-level seek bridge for site-specific player APIs |
|
||||
| `modules/tab-manager.js` | Tab lifecycle helper used by the background service worker |
|
||||
| `i18n.js` | Dynamic locale loader and DOM translation helper |
|
||||
| `locales/` | Runtime popup translations |
|
||||
| `_locales/` | Browser-store manifest translations |
|
||||
| `shared/` | Generated mirror of root shared constants, blacklist, names, and README |
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "YouTube、Netflix、Emby、Jellyfin、その他HTML5サイトでの動画再生を友達とリアルタイムで同期します。"
|
||||
"message": "プライベートなウォッチパーティーを作成し、YouTube、Netflix、Jellyfin、EmbyやほぼすべてのHTML5サイトで友達と動画を同期再生できます。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 사이트에서 친구들과 영상을 동기화하세요."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "与朋友实时同步YouTube、Netflix、Emby、Jellyfin和任何HTML5网站上的视频播放。"
|
||||
"message": "创建私人观影派对,与好友在 YouTube、Netflix、Jellyfin、Emby 及几乎任何 HTML5 视频网站同步观看。"
|
||||
}
|
||||
}
|
||||
|
||||
+54
-17
@@ -1,13 +1,46 @@
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--card: #1e293b;
|
||||
--panel: #172033;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #818cf8;
|
||||
--text: #f8fafc;
|
||||
--text-muted: #94a3b8;
|
||||
--border: #334155;
|
||||
/* KoalaSync nature system: forest neutrals, eucalyptus brand, warm supporting accents. */
|
||||
--bg-base: oklch(0.23 0.028 155);
|
||||
--surface: oklch(0.30 0.035 150);
|
||||
--surface-deep: oklch(0.205 0.025 155);
|
||||
--surface-alt: oklch(0.265 0.032 150);
|
||||
--border-soft: oklch(0.88 0.025 145 / 0.09);
|
||||
--border-strong: oklch(0.39 0.038 145);
|
||||
--text-primary: oklch(0.96 0.01 90);
|
||||
--text-secondary: oklch(0.78 0.02 90);
|
||||
--accent-green: oklch(0.73 0.13 155);
|
||||
--accent-green-hover: oklch(0.79 0.12 155);
|
||||
--accent-terracotta: oklch(0.62 0.14 45);
|
||||
--success-green: oklch(0.72 0.14 135);
|
||||
--warning-amber: oklch(0.76 0.14 80);
|
||||
--danger: oklch(0.55 0.15 25);
|
||||
--text-on-green: oklch(0.14 0.02 90);
|
||||
|
||||
--bg: var(--bg-base);
|
||||
--card: var(--surface);
|
||||
--panel: var(--surface-alt);
|
||||
--accent: var(--accent-green);
|
||||
--accent-hover: var(--accent-green-hover);
|
||||
--text: var(--text-primary);
|
||||
--text-muted: var(--text-secondary);
|
||||
--border: var(--border-strong);
|
||||
--radius: 8px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html.theme-light {
|
||||
--bg-base: oklch(0.965 0.012 105);
|
||||
--surface: oklch(0.995 0.006 100);
|
||||
--surface-deep: oklch(0.935 0.018 110);
|
||||
--surface-alt: oklch(0.91 0.022 115);
|
||||
--border-soft: oklch(0.28 0.035 140 / 0.10);
|
||||
--border-strong: oklch(0.73 0.035 125);
|
||||
--text-primary: oklch(0.21 0.03 140);
|
||||
--text-secondary: oklch(0.43 0.03 135);
|
||||
--accent-green: oklch(0.52 0.13 155);
|
||||
--accent-green-hover: oklch(0.45 0.12 155);
|
||||
--text-on-green: oklch(0.98 0.005 100);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -55,7 +88,7 @@ body {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
h1,
|
||||
@@ -83,6 +116,10 @@ h2 {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
html.theme-light .panel {
|
||||
box-shadow: 0 12px 30px oklch(0.22 0.035 140 / 0.09);
|
||||
}
|
||||
|
||||
.muted-panel {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -101,7 +138,7 @@ h2 {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.preset-group {
|
||||
@@ -125,12 +162,12 @@ h2 {
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.preset-card:hover {
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
border-color: color-mix(in oklch, var(--accent), transparent 60%);
|
||||
}
|
||||
|
||||
.preset-card:has(input:checked) {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.35);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklch, var(--accent), transparent 65%);
|
||||
}
|
||||
|
||||
.preset-card input {
|
||||
@@ -141,8 +178,8 @@ h2 {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(15, 23, 42, 0.58);
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: var(--surface-deep);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
@@ -158,7 +195,7 @@ h2 {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
@@ -204,7 +241,7 @@ input[type="number"]:focus {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background-color: #334155;
|
||||
background-color: var(--border-strong);
|
||||
transition: .3s;
|
||||
border-radius: 22px;
|
||||
}
|
||||
@@ -216,7 +253,7 @@ input[type="number"]:focus {
|
||||
width: 16px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: #94a3b8;
|
||||
background-color: var(--text-secondary);
|
||||
transition: .3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>KoalaSync Audio Settings</title>
|
||||
<script src="theme-init.js"></script>
|
||||
<link rel="stylesheet" href="audio-options.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+1235
-176
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -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(() => {});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
(function exposeChatFormat(root) {
|
||||
function escapeChatHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
root.KoalaSyncChatFormat = Object.freeze({ escapeChatHtml, formatChatText, tokenizeChatText });
|
||||
})(globalThis);
|
||||
@@ -0,0 +1,22 @@
|
||||
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('<img src=x onerror=alert(1)> <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' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
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 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 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'
|
||||
];
|
||||
|
||||
describe('chat overlay contract', () => {
|
||||
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('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('textarea.value === submittedValue');
|
||||
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'");
|
||||
});
|
||||
|
||||
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(/[—–]/);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,624 @@
|
||||
(function initKoalaSyncChatOverlay() {
|
||||
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 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 layout = {
|
||||
mode: 'right',
|
||||
x: 24,
|
||||
y: 72,
|
||||
width: DEFAULT_WIDTH,
|
||||
height: DEFAULT_HEIGHT,
|
||||
customWidth: DEFAULT_WIDTH,
|
||||
customHeight: DEFAULT_HEIGHT,
|
||||
detachedInitialized: false
|
||||
};
|
||||
let chatPosition = 'right';
|
||||
let chatSize = 'standard';
|
||||
let chatStartMode = 'bubble';
|
||||
let themeMode = 'system';
|
||||
let themePalette = 'eucalyptus';
|
||||
|
||||
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); font-size: 22px;
|
||||
}
|
||||
.launcher:hover:not([aria-disabled="true"]) { border-color: var(--accent); transform: translateY(-1px); }
|
||||
.launcher[aria-disabled="true"] { cursor: not-allowed; opacity: .58; }
|
||||
.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; }
|
||||
.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); }
|
||||
.composer { padding: 10px; border-top: 1px solid var(--border-soft); background: var(--card); }
|
||||
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; }
|
||||
.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; } }
|
||||
`;
|
||||
|
||||
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 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 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/500');
|
||||
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(textareaLabel, textarea, composerRow, status);
|
||||
panel.append(header, messages, composer);
|
||||
app.append(launcherHint, launcher, panel);
|
||||
shadow.append(style, app);
|
||||
document.documentElement.append(host);
|
||||
|
||||
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 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 || '');
|
||||
}
|
||||
|
||||
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 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;
|
||||
chrome.storage.local.set({ [storageKey]: layout }).catch(() => {});
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function applyLayout() {
|
||||
applyingLayout = true;
|
||||
const size = preferredSize();
|
||||
panel.style.right = 'auto';
|
||||
panel.style.left = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
panel.style.resize = 'none';
|
||||
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') {
|
||||
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';
|
||||
launcher.style.left = `${layout.x}px`;
|
||||
launcher.style.right = 'auto';
|
||||
launcher.style.top = `${layout.y}px`;
|
||||
} else {
|
||||
const viewport = viewportBounds();
|
||||
const gutter = Math.min(16, Math.max(0, Math.floor((viewport.width - 1) / 2)));
|
||||
const panelTop = viewport.height < MIN_HEIGHT + 80 ? Math.min(8, Math.max(0, viewport.height - 1)) : 64;
|
||||
panel.style.top = `${panelTop}px`;
|
||||
panel.style.width = `${Math.max(1, Math.min(size.width, viewport.width - gutter * 2))}px`;
|
||||
panel.style.height = `${Math.max(1, Math.min(size.height, viewport.height - panelTop - Math.min(16, Math.max(0, viewport.height - panelTop - 1))))}px`;
|
||||
panel.style[layout.mode] = `${gutter}px`;
|
||||
launcher.style.top = `${Math.max(0, Math.min(viewport.height - 48, viewport.height / 2 - 24))}px`;
|
||||
launcher.style.left = layout.mode === 'left' ? `${gutter}px` : 'auto';
|
||||
launcher.style.right = layout.mode === 'right' ? `${gutter}px` : 'auto';
|
||||
}
|
||||
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) chrome.storage.local.set({ chatPosition: mode }).catch(() => {});
|
||||
}
|
||||
|
||||
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) chrome.storage.local.set({ chatSize }).catch(() => {});
|
||||
}
|
||||
|
||||
function setOpened(next) {
|
||||
opened = !!next && !!context?.enabled;
|
||||
panel.classList.toggle('open', opened);
|
||||
launcher.style.display = opened ? 'none' : '';
|
||||
if (opened) {
|
||||
textarea.focus();
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
messages.replaceChildren(empty);
|
||||
}
|
||||
|
||||
function resetComposer() {
|
||||
sendGeneration++;
|
||||
sending = false;
|
||||
textarea.value = '';
|
||||
count.textContent = '0/500';
|
||||
count.style.color = '';
|
||||
status.textContent = '';
|
||||
sendButton.disabled = false;
|
||||
}
|
||||
|
||||
launcher.addEventListener('click', () => setOpened(true));
|
||||
closeButton.addEventListener('click', () => setOpened(false));
|
||||
leftButton.addEventListener('click', () => setMode('left'));
|
||||
detachedButton.addEventListener('click', () => setMode('detached'));
|
||||
rightButton.addEventListener('click', () => setMode('right'));
|
||||
|
||||
textarea.addEventListener('input', () => {
|
||||
const length = [...textarea.value].length;
|
||||
count.textContent = `${length}/500`;
|
||||
count.style.color = length > 500 ? 'var(--danger)' : '';
|
||||
status.textContent = length > 500 ? (strings().tooLong || '') : '';
|
||||
});
|
||||
textarea.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (!sending) composer.requestSubmit();
|
||||
}
|
||||
});
|
||||
composer.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
if (sending || !context?.enabled) return;
|
||||
const submittedValue = textarea.value;
|
||||
const text = textarea.value.trim();
|
||||
if (!text) return;
|
||||
if ([...text].length > 500) {
|
||||
status.textContent = strings().tooLong || '';
|
||||
return;
|
||||
}
|
||||
const generation = ++sendGeneration;
|
||||
sending = true;
|
||||
sendButton.disabled = true;
|
||||
status.textContent = '';
|
||||
const response = await messageRuntime({ type: 'CHAT_SEND', text });
|
||||
if (destroyed || generation !== sendGeneration) return;
|
||||
sending = false;
|
||||
sendButton.disabled = false;
|
||||
if (response?.status === 'ok') {
|
||||
if (textarea.value === submittedValue) {
|
||||
textarea.value = '';
|
||||
count.textContent = '0/500';
|
||||
}
|
||||
} else {
|
||||
status.textContent = response?.status === 'too_long' ? (strings().tooLong || '') : (strings().sendFailed || '');
|
||||
}
|
||||
});
|
||||
|
||||
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';
|
||||
chrome.storage.local.set({ chatSize }).catch(() => {});
|
||||
}
|
||||
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() {
|
||||
if (layout.mode === 'detached') {
|
||||
clampDetached();
|
||||
applyLayout();
|
||||
saveLayout();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSystemTheme() {
|
||||
if (themeMode === 'system') applyTheme();
|
||||
}
|
||||
|
||||
function handleStorage(changes, area) {
|
||||
if (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.locale) refresh();
|
||||
}
|
||||
|
||||
function handleRuntime(message) {
|
||||
if (message?.type === 'CHAT_MESSAGE') appendMessage(message.message);
|
||||
if (message?.type === 'CHAT_CONTEXT_UPDATE' || message?.type === 'CONNECTION_STATUS') refresh();
|
||||
if (message?.type === 'CHAT_RESET') {
|
||||
clearMessages();
|
||||
resetComposer();
|
||||
refresh();
|
||||
}
|
||||
if (message?.type === 'CHAT_DESTROY') destroy();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
refreshGeneration++;
|
||||
sendGeneration++;
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
resizeObserver.disconnect();
|
||||
document.removeEventListener('fullscreenchange', moveIntoFullscreen);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
systemTheme.removeEventListener('change', handleSystemTheme);
|
||||
chrome.storage.onChanged.removeListener(handleStorage);
|
||||
chrome.runtime.onMessage.removeListener(handleRuntime);
|
||||
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'], data => {
|
||||
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';
|
||||
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 };
|
||||
})();
|
||||
@@ -0,0 +1,58 @@
|
||||
export const MAX_ROOM_ID_LENGTH = 64;
|
||||
export const CHAT_SEND_LIMIT = 9;
|
||||
export const CHAT_SEND_WINDOW_MS = 10000;
|
||||
|
||||
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 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();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CHAT_SEND_LIMIT,
|
||||
createChatSendLimiter,
|
||||
createLatestTaskQueue,
|
||||
MAX_ROOM_ID_LENGTH,
|
||||
normalizeRoomId
|
||||
} 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('keeps client chat bursts below the relay disconnect threshold', () => {
|
||||
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('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']);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+84
-34
@@ -6,16 +6,18 @@
|
||||
(function() {
|
||||
// Injection Guard: Check if already injected AND context is valid
|
||||
try {
|
||||
if (window.koalaSyncInjected && chrome.runtime.id) {
|
||||
return;
|
||||
}
|
||||
if (window.koalaSyncInjected && chrome.runtime.id) {
|
||||
window.koalaSyncTargetActive = true;
|
||||
return;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Context invalidated, proceed with re-injection
|
||||
}
|
||||
window.koalaSyncInjected = true;
|
||||
window.koalaSyncInjected = true;
|
||||
window.koalaSyncTargetActive = true;
|
||||
|
||||
// --- SHARED_EVENTS_INJECT_START ---
|
||||
// This block is automatically updated by /scripts/build-extension.js
|
||||
// This block is automatically updated by /scripts/build-extension.cjs
|
||||
const EVENTS = {
|
||||
PLAY: "play",
|
||||
PAUSE: "pause",
|
||||
@@ -86,9 +88,34 @@
|
||||
return Array.isArray(urls) && urls.some(url => hostMatchesUrl(host, url));
|
||||
}
|
||||
|
||||
function isDisneyPlusHost() {
|
||||
return matchesPlayerUrls(['disneyplus.com']);
|
||||
}
|
||||
function isDisneyPlusHost() {
|
||||
return matchesPlayerUrls(['disneyplus.com']);
|
||||
}
|
||||
|
||||
function isGoogleDrivePlayerFrame() {
|
||||
if (window.location.hostname.toLowerCase() !== 'youtube.googleapis.com') return false;
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const origin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin');
|
||||
return origin === 'https://drive.google.com';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isContentTargetActive() {
|
||||
return window.koalaSyncTargetActive === true;
|
||||
}
|
||||
|
||||
if (isGoogleDrivePlayerFrame()) {
|
||||
let lastDriveFrameVisible = window.innerWidth > 0 && window.innerHeight > 0;
|
||||
window.addEventListener('resize', () => {
|
||||
const visible = window.innerWidth > 0 && window.innerHeight > 0;
|
||||
if (visible === lastDriveFrameVisible || !isContentTargetActive()) return;
|
||||
lastDriveFrameVisible = visible;
|
||||
chrome.runtime.sendMessage({ type: 'DRIVE_FRAME_VISIBILITY', visible }).catch(() => {});
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
function getDisneyPlusTimeline() {
|
||||
if (!isDisneyPlusHost()) return null;
|
||||
@@ -424,13 +451,13 @@
|
||||
const host = hcmEl('div', 'all:initial');
|
||||
const root = host.attachShadow({ mode: 'open' });
|
||||
|
||||
const wrap = hcmEl('div', 'position:fixed;z-index:2147483647;left:50%;bottom:32px;transform:translateX(-50%);background:#1f2937;color:#f9fafb;font:14px/1.4 system-ui,sans-serif;padding:16px 18px;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.45);max-width:360px;border:1px solid #374151');
|
||||
const wrap = hcmEl('div', 'position:fixed;z-index:2147483647;left:50%;bottom:32px;transform:translateX(-50%);background:#212a17;color:#f4f2ea;font:14px/1.4 system-ui,sans-serif;padding:16px 18px;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.45);max-width:360px;border:1px solid #2f3625');
|
||||
wrap.setAttribute('role', 'dialog');
|
||||
const title = hcmEl('div', 'font-weight:600;margin-bottom:6px', hcmStrings.title);
|
||||
const body = hcmEl('div', 'margin-bottom:12px;color:#d1d5db', hcmStrings.body);
|
||||
const body = hcmEl('div', 'margin-bottom:12px;color:#bcb7a9', hcmStrings.body);
|
||||
const btnRow = hcmEl('div', 'display:flex;gap:8px;justify-content:flex-end');
|
||||
const soloBtn = hcmEl('button', 'background:#374151;color:#f9fafb;border:0;padding:8px 12px;border-radius:8px;cursor:pointer', hcmStrings.solo);
|
||||
const stayBtn = hcmEl('button', 'background:#10b981;color:#062a20;border:0;padding:8px 12px;border-radius:8px;cursor:pointer;font-weight:600', hcmStrings.stay);
|
||||
const soloBtn = hcmEl('button', 'background:#2f3625;color:#f4f2ea;border:0;padding:8px 12px;border-radius:8px;cursor:pointer', hcmStrings.solo);
|
||||
const stayBtn = hcmEl('button', 'background:#56ae6c;color:#0a1a0d;border:0;padding:8px 12px;border-radius:8px;cursor:pointer;font-weight:600', hcmStrings.stay);
|
||||
btnRow.append(soloBtn, stayBtn);
|
||||
wrap.append(title, body, btnRow);
|
||||
root.appendChild(wrap);
|
||||
@@ -494,7 +521,7 @@
|
||||
}
|
||||
const host = hcmEl('div', 'all:initial');
|
||||
const root = host.attachShadow({ mode: 'open' });
|
||||
const b = hcmEl('div', 'position:fixed;z-index:2147483646;right:16px;bottom:16px;background:#b45309;color:#fff;font:13px/1.3 system-ui,sans-serif;padding:8px 12px;border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.4);cursor:pointer;display:flex;align-items:center;gap:8px');
|
||||
const b = hcmEl('div', 'position:fixed;z-index:2147483646;right:16px;bottom:16px;background:#c96736;color:#fff;font:13px/1.3 system-ui,sans-serif;padding:8px 12px;border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.4);cursor:pointer;display:flex;align-items:center;gap:8px');
|
||||
b.append(hcmEl('span', null, '● ' + hcmStrings.badge), hcmEl('span', 'text-decoration:underline', hcmStrings.resync));
|
||||
b.addEventListener('click', hcmExitDesync);
|
||||
root.appendChild(b);
|
||||
@@ -717,7 +744,7 @@
|
||||
// Handles: S01E01, S1E1, S01 - E01, Season 1 Episode 1, "Folge 5", "Episode 5", "Ep. 5", "#5"
|
||||
// Returns null if no episode pattern found.
|
||||
// --- SHARED_EPISODE_UTILS_INJECT_START ---
|
||||
// This block is automatically replaced by /scripts/build-extension.js
|
||||
// This block is automatically replaced by /scripts/build-extension.cjs
|
||||
function extractEpisodeId(title) {
|
||||
if (!title || typeof title !== 'string') return null;
|
||||
const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i);
|
||||
@@ -758,7 +785,8 @@
|
||||
// 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)
|
||||
&& !sameEpisode(currentTitle, lastKnownMediaTitle)
|
||||
&& extractEpisodeId(currentTitle) !== null
|
||||
&& video
|
||||
&& current !== null && current < 5
|
||||
&& video.readyState >= 1) {
|
||||
@@ -798,7 +826,11 @@
|
||||
|
||||
const current = video ? getSyncCurrentTime(video) : null;
|
||||
if (video && currentTitle && sameEpisode(currentTitle, expectedTitle)
|
||||
&& current !== null && current < 5 && video.readyState >= 1) {
|
||||
&& 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');
|
||||
@@ -846,11 +878,17 @@
|
||||
|
||||
function getPlayerActionFixes() {
|
||||
return [
|
||||
{
|
||||
name: 'youtube-player-buttons',
|
||||
urls: ['youtube.com'],
|
||||
playPauseButtonSelector: '.ytp-play-button'
|
||||
},
|
||||
{
|
||||
name: 'youtube-player-buttons',
|
||||
urls: ['youtube.com'],
|
||||
playPauseButtonSelector: '.ytp-play-button'
|
||||
},
|
||||
{
|
||||
name: 'google-drive-player-buttons',
|
||||
urls: ['youtube.googleapis.com'],
|
||||
matches() { return isGoogleDrivePlayerFrame(); },
|
||||
playPauseButtonSelector: '.ytp-play-button'
|
||||
},
|
||||
{
|
||||
name: 'twitch-player-buttons',
|
||||
urls: ['twitch.tv'],
|
||||
@@ -859,9 +897,11 @@
|
||||
];
|
||||
}
|
||||
|
||||
function getActivePlayerActionFix() {
|
||||
return getPlayerActionFixes().find(fix => matchesPlayerUrls(fix.urls)) || null;
|
||||
}
|
||||
function getActivePlayerActionFix() {
|
||||
return getPlayerActionFixes().find(fix =>
|
||||
typeof fix.matches === 'function' ? fix.matches() : matchesPlayerUrls(fix.urls)
|
||||
) || null;
|
||||
}
|
||||
|
||||
function tryPlayerActionFix(fix, action, video, data) {
|
||||
if (!fix) return false;
|
||||
@@ -946,8 +986,14 @@
|
||||
}
|
||||
|
||||
// Listen for commands from background.js
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (!message) return;
|
||||
if (message.type === 'KOALASYNC_DEACTIVATE') {
|
||||
window.koalaSyncTargetActive = false;
|
||||
sendResponse({ status: 'ok' });
|
||||
return true;
|
||||
}
|
||||
if (!isContentTargetActive()) return;
|
||||
if (message.action === 'get_current_time') {
|
||||
const video = findVideo();
|
||||
sendResponse({ currentTime: video ? getSyncCurrentTime(video) : null });
|
||||
@@ -1131,9 +1177,10 @@
|
||||
if (message.type === 'GET_VIDEO_STATE') {
|
||||
const video = findVideo();
|
||||
|
||||
const platform = (() => {
|
||||
const h = window.location.hostname.toLowerCase();
|
||||
if (h === 'youtube.com' || h.endsWith('.youtube.com')) return 'YouTube';
|
||||
const platform = (() => {
|
||||
const h = window.location.hostname.toLowerCase();
|
||||
if (isGoogleDrivePlayerFrame()) return 'Google Drive';
|
||||
if (h === 'youtube.com' || h.endsWith('.youtube.com')) return 'YouTube';
|
||||
if (h === 'twitch.tv' || h.endsWith('.twitch.tv')) return 'Twitch';
|
||||
if (h === 'netflix.com' || h.endsWith('.netflix.com')) return 'Netflix';
|
||||
if (h === 'primevideo.com' || h.endsWith('.primevideo.com') || /(^|\.)amazon\.(com\.[a-z]{2}|co\.[a-z]{2}|[a-z]{2,})$/.test(h)) return 'Prime Video';
|
||||
@@ -1266,8 +1313,9 @@
|
||||
// Build the relay payload from the *current* media state and send it to
|
||||
// background.js. Re-reads the video each call so deferred (coalesced) emits
|
||||
// carry an up-to-date position. Safe with no/invalid video — it no-ops.
|
||||
function sendContentEvent(action) {
|
||||
const video = findVideo();
|
||||
function sendContentEvent(action) {
|
||||
if (!isContentTargetActive()) return;
|
||||
const video = findVideo();
|
||||
if (!video) return;
|
||||
|
||||
const current = getSyncCurrentTime(video);
|
||||
@@ -1303,8 +1351,9 @@
|
||||
sendContentEvent(finalAction);
|
||||
}
|
||||
|
||||
function reportEvent(action) {
|
||||
if (seekDebounceTimer && (action === EVENTS.PLAY || action === EVENTS.PAUSE)) {
|
||||
function reportEvent(action) {
|
||||
if (!isContentTargetActive()) return;
|
||||
if (seekDebounceTimer && (action === EVENTS.PLAY || action === EVENTS.PAUSE)) {
|
||||
clearTimeout(seekDebounceTimer);
|
||||
seekDebounceTimer = null;
|
||||
const v = findVideo();
|
||||
@@ -1547,8 +1596,9 @@
|
||||
let proactiveHeartbeatTimeout = null;
|
||||
let heartbeatErrorCount = 0;
|
||||
|
||||
function sendHeartbeat() {
|
||||
const video = findVideo();
|
||||
function sendHeartbeat() {
|
||||
if (!isContentTargetActive()) return;
|
||||
const video = findVideo();
|
||||
if (!video) return;
|
||||
|
||||
const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* KoalaSync Episode Title Utilities
|
||||
* Single source of truth — synced to content.js by build-extension.js.
|
||||
* Single source of truth — synced to content.js by build-extension.cjs.
|
||||
* Keep in sync with the injection block in content.js!
|
||||
*/
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
export const HOST_ACCESS_REQUIRED_STATUS = 'host_permission_required';
|
||||
|
||||
export function normalizeTabId(value) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : null;
|
||||
}
|
||||
if (typeof value !== 'string') return null;
|
||||
|
||||
const normalized = value.trim();
|
||||
if (!/^[1-9]\d*$/.test(normalized)) return null;
|
||||
const tabId = Number(normalized);
|
||||
return Number.isSafeInteger(tabId) ? tabId : null;
|
||||
}
|
||||
|
||||
function browserSupportsPortMatchPatterns(chromeApi) {
|
||||
// runtime.getBrowserInfo is a Firefox-only WebExtension API. Firefox does
|
||||
// not accept ports in match patterns, while Chromium does.
|
||||
return typeof chromeApi?.runtime?.getBrowserInfo !== 'function';
|
||||
}
|
||||
|
||||
export function describeTabUrl(rawUrl, { includePort = true } = {}) {
|
||||
if (typeof rawUrl !== 'string' || !rawUrl) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
||||
const permissionHost = includePort ? url.host : url.hostname;
|
||||
return {
|
||||
url: rawUrl,
|
||||
host: url.host,
|
||||
originPattern: `${url.protocol}//${permissionHost}/*`
|
||||
};
|
||||
}
|
||||
if (url.protocol === 'file:') {
|
||||
return {
|
||||
url: rawUrl,
|
||||
host: 'local file',
|
||||
originPattern: 'file:///*'
|
||||
};
|
||||
}
|
||||
} catch (_e) {
|
||||
// Invalid and browser-internal URLs cannot receive host access.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function inspectTabHostAccess(chromeApi, tabId) {
|
||||
const tab = await chromeApi.tabs.get(tabId);
|
||||
// executeScript targets the committed document. pendingUrl may already point
|
||||
// at another origin while tab.url is still the document being injected into.
|
||||
const descriptor = describeTabUrl(tab?.url || tab?.pendingUrl || '', {
|
||||
includePort: browserSupportsPortMatchPatterns(chromeApi)
|
||||
});
|
||||
if (!descriptor) {
|
||||
return {
|
||||
tab,
|
||||
url: tab?.url || '',
|
||||
host: null,
|
||||
originPattern: null,
|
||||
granted: null
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof chromeApi.permissions?.contains !== 'function') {
|
||||
return { tab, ...descriptor, granted: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const granted = await callBooleanPermissionMethod(
|
||||
chromeApi,
|
||||
'contains',
|
||||
{ origins: [descriptor.originPattern] },
|
||||
{ timeoutMs: 1000 }
|
||||
);
|
||||
return {
|
||||
tab,
|
||||
...descriptor,
|
||||
granted: granted === null ? null : granted === true
|
||||
};
|
||||
} catch (_e) {
|
||||
// Permission inspection is advisory. The actual script injection below
|
||||
// remains the source of truth, including temporary activeTab access.
|
||||
return { tab, ...descriptor, granted: null };
|
||||
}
|
||||
}
|
||||
|
||||
function callBooleanPermissionMethod(chromeApi, methodName, request, { timeoutMs = null } = {}) {
|
||||
const method = chromeApi.permissions?.[methodName];
|
||||
if (typeof method !== 'function') return Promise.resolve(null);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timeout = null;
|
||||
const clearSettlementTimeout = () => {
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
};
|
||||
const finish = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearSettlementTimeout();
|
||||
resolve(value === true ? true : value === false ? false : null);
|
||||
};
|
||||
const fail = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearSettlementTimeout();
|
||||
reject(error instanceof Error ? error : new Error(String(error || 'Permission request failed')));
|
||||
};
|
||||
const callback = (value) => {
|
||||
const lastError = chromeApi.runtime?.lastError;
|
||||
if (lastError) {
|
||||
fail(new Error(lastError.message || String(lastError)));
|
||||
return;
|
||||
}
|
||||
finish(value);
|
||||
};
|
||||
|
||||
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
||||
timeout = setTimeout(() => finish(null), timeoutMs);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = method.call(chromeApi.permissions, request, callback);
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.then(finish, fail);
|
||||
} else if (typeof result === 'boolean') {
|
||||
finish(result);
|
||||
}
|
||||
// Callback-based Chromium/Firefox APIs return undefined and settle
|
||||
// through callback. Promise-based implementations settle above.
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function requestOriginPermission(chromeApi, originPattern) {
|
||||
if (typeof originPattern !== 'string' || !originPattern) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return callBooleanPermissionMethod(
|
||||
chromeApi,
|
||||
'request',
|
||||
{ origins: [originPattern] },
|
||||
{ timeoutMs: 60000 }
|
||||
).catch(() => false);
|
||||
}
|
||||
|
||||
export function containsOriginPermission(chromeApi, originPattern) {
|
||||
if (typeof originPattern !== 'string' || !originPattern) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return callBooleanPermissionMethod(
|
||||
chromeApi,
|
||||
'contains',
|
||||
{ origins: [originPattern] },
|
||||
{ timeoutMs: 1000 }
|
||||
).catch(() => null);
|
||||
}
|
||||
|
||||
export function isHostAccessError(error) {
|
||||
const message = typeof error?.message === 'string' ? error.message : String(error || '');
|
||||
return /host permission|permission to access (?:this|the|respective) host|cannot access contents of/i.test(message);
|
||||
}
|
||||
|
||||
function hostAccessRequestDetails(tabId, originPattern) {
|
||||
const request = { tabId };
|
||||
if (typeof originPattern === 'string' && originPattern) {
|
||||
request.pattern = originPattern;
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
export async function addTabHostAccessRequest(chromeApi, tabId, originPattern = null) {
|
||||
if (typeof chromeApi.permissions?.addHostAccessRequest !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await chromeApi.permissions.addHostAccessRequest(
|
||||
hostAccessRequestDetails(tabId, originPattern)
|
||||
);
|
||||
return true;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeTabHostAccessRequest(chromeApi, tabId, originPattern = null) {
|
||||
if (typeof chromeApi.permissions?.removeHostAccessRequest !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await chromeApi.permissions.removeHostAccessRequest(
|
||||
hostAccessRequestDetails(tabId, originPattern)
|
||||
);
|
||||
return true;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ let currentLanguage = null;
|
||||
*/
|
||||
export async function loadLocale(langCode) {
|
||||
const resolvedLang = SUPPORTED_LANGUAGES.includes(langCode) ? langCode : DEFAULT_LANGUAGE;
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = resolvedLang;
|
||||
}
|
||||
|
||||
if (currentLanguage === resolvedLang && Object.keys(activeDictionary).length > 0) {
|
||||
return;
|
||||
@@ -90,6 +93,8 @@ export function getMessage(key, placeholders = null) {
|
||||
* Performs dynamic DOM replacements for elements carrying data-i18n attributes.
|
||||
*/
|
||||
export function translateDOM() {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
// 1. Text Content
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path d="M3 2.5h4v11H3zm6 0h4v11H9z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 109 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path d="M4 2.6v10.8L13 8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 100 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path d="M13.4 5.5A5.8 5.8 0 0 0 3 4.2L1.7 2.9v4.4h4.4L4.4 5.6a4 4 0 0 1 7.2.8zM2.6 10.5A5.8 5.8 0 0 0 13 11.8l1.3 1.3V8.7H9.9l1.7 1.7a4 4 0 0 1-7.2-.8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 226 B |
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Fernsteuerung",
|
||||
"BTN_COPY_INVITE": "📋 Einladungslink",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Einladungslink kopieren",
|
||||
"BTN_PLAY": "▶ Abspielen",
|
||||
"BTN_PLAY": "Abspielen",
|
||||
"BTN_PLAY_TOOLTIP": "Sende einen Play-Befehl an alle",
|
||||
"BTN_PAUSE": "⏸ Pause",
|
||||
"BTN_PAUSE": "Pause",
|
||||
"BTN_PAUSE_TOOLTIP": "Sende einen Pause-Befehl an alle",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC": "Sync",
|
||||
"BTN_SYNC_TOOLTIP": "Erzwinge die Synchronisation aller Teilnehmer",
|
||||
"OPTION_JUMP_TO_OTHERS": "Zu anderen springen",
|
||||
"OPTION_JUMP_TO_ME": "Zu mir springen",
|
||||
@@ -148,6 +148,8 @@
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identität noch nicht geladen. Warte kurz und versuche es erneut.",
|
||||
"ERR_NO_PEERS_TIME": "Keine anderen Teilnehmer mit bekannter Zeit. Wechsle zu 'Zu mir springen'.",
|
||||
"ERR_NO_VIDEO_TAB": "Verbindung zum Video-Tab fehlgeschlagen.",
|
||||
"SITE_ACCESS_REQUIRED": "Dein Browser blockiert KoalaSync auf {host}. Klicke unten auf „Zugriff erlauben“. Falls kein Dialog erscheint, erlaube KoalaSync in den Erweiterungs- oder Websiteberechtigungen für diese Seite.",
|
||||
"BTN_RETRY_ACCESS": "Zugriff erlauben",
|
||||
"ERR_SELECT_VIDEO": "Bitte wähle zuerst ein Video aus!",
|
||||
"TOAST_INVITE_COPIED": "Einladungslink kopiert!",
|
||||
"TOAST_COPY_FAILED": "Kopieren in Zwischenablage fehlgeschlagen",
|
||||
@@ -209,6 +211,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Wendet Audioeffekte wie Kompression auf die Videowiedergabe an",
|
||||
"AUDIO_OPEN_SETTINGS": "Öffnen",
|
||||
"NEW_FEATURE_AUDIO": "Neu: Audio-Verarbeitung — probiere den Kompressor aus!",
|
||||
"NEW_FEATURE_THEME": "Neu: Themes — wechsle zum klassischen Look oder minimalem Graphit.",
|
||||
"AUDIO_BACK": "← Zurück",
|
||||
"AUDIO_PAGE_TITLE": "Audio-Einstellungen",
|
||||
"AUDIO_MASTER_TOGGLE": "Audio-Verarbeitung",
|
||||
@@ -238,5 +241,38 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Tab-Titel senden",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Sendet den Titel des ausgewählten Browser-Tabs an den Raum.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Medientitel",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Lege fest, wie Medientitel an den Raum gesendet werden."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Lege fest, wie Medientitel an den Raum gesendet werden.",
|
||||
"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_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",
|
||||
"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 500 Zeichen.",
|
||||
"CHAT_SEND_FAILED": "Nachricht konnte nicht gesendet werden.",
|
||||
"CHAT_EMPTY": "Noch keine Nachrichten. Der Chat ist nur live."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Remote Control",
|
||||
"BTN_COPY_INVITE": "📋 Invite Link",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copy Invite Link",
|
||||
"BTN_PLAY": "▶ Play",
|
||||
"BTN_PLAY": "Play",
|
||||
"BTN_PLAY_TOOLTIP": "Send a Play command to everyone",
|
||||
"BTN_PAUSE": "⏸ Pause",
|
||||
"BTN_PAUSE": "Pause",
|
||||
"BTN_PAUSE_TOOLTIP": "Send a Pause command to everyone",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC": "Sync",
|
||||
"BTN_SYNC_TOOLTIP": "Force all users to sync up",
|
||||
"OPTION_JUMP_TO_OTHERS": "Jump to Others",
|
||||
"OPTION_JUMP_TO_ME": "Jump to Me",
|
||||
@@ -148,6 +148,8 @@
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identity not yet loaded. Wait a moment and try again.",
|
||||
"ERR_NO_PEERS_TIME": "No other peers with a known time. Switch to 'Jump to Me'.",
|
||||
"ERR_NO_VIDEO_TAB": "Could not connect to video tab.",
|
||||
"SITE_ACCESS_REQUIRED": "Your browser is blocking KoalaSync on {host}. Click \"Allow access\" below. If no dialog appears, allow KoalaSync on this site in your extension or site permissions.",
|
||||
"BTN_RETRY_ACCESS": "Allow access",
|
||||
"ERR_SELECT_VIDEO": "Please select a video first!",
|
||||
"TOAST_INVITE_COPIED": "Invite link copied!",
|
||||
"TOAST_COPY_FAILED": "Failed to copy to clipboard",
|
||||
@@ -209,6 +211,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Apply audio effects like compression to video playback",
|
||||
"AUDIO_OPEN_SETTINGS": "Open",
|
||||
"NEW_FEATURE_AUDIO": "New: Audio Processing — try the compressor!",
|
||||
"NEW_FEATURE_THEME": "New: Themes — switch to the classic look or minimal graphite.",
|
||||
"AUDIO_BACK": "← Back",
|
||||
"AUDIO_PAGE_TITLE": "Audio Settings",
|
||||
"AUDIO_MASTER_TOGGLE": "Audio Processing",
|
||||
@@ -238,5 +241,38 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Send tab title",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Send the selected browser tab title to the room.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Media title",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Choose how media titles are sent to the room."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Choose how media titles are sent to the room.",
|
||||
"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_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",
|
||||
"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 500 characters.",
|
||||
"CHAT_SEND_FAILED": "Message could not be sent.",
|
||||
"CHAT_EMPTY": "No messages yet. Chat is live only."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Control remoto",
|
||||
"BTN_COPY_INVITE": "📋 Enlace de invitación",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copiar enlace de invitación",
|
||||
"BTN_PLAY": "▶ Reproducir",
|
||||
"BTN_PLAY": "Reproducir",
|
||||
"BTN_PLAY_TOOLTIP": "Enviar un comando de reproducción a todos",
|
||||
"BTN_PAUSE": "⏸ Pausar",
|
||||
"BTN_PAUSE": "Pausar",
|
||||
"BTN_PAUSE_TOOLTIP": "Enviar un comando de pausa a todos",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC": "Sync",
|
||||
"BTN_SYNC_TOOLTIP": "Forzar la sincronización de todos los usuarios",
|
||||
"OPTION_JUMP_TO_OTHERS": "Ir a la posición de otros",
|
||||
"OPTION_JUMP_TO_ME": "Traerlos a mi posición",
|
||||
@@ -148,6 +148,8 @@
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identidad no cargada aún. Espera un momento e inténtalo de nuevo.",
|
||||
"ERR_NO_PEERS_TIME": "No hay otros participantes con posición conocida. Cambia a 'Traerlos a mi posición'.",
|
||||
"ERR_NO_VIDEO_TAB": "No se pudo conectar a la pestaña de video.",
|
||||
"SITE_ACCESS_REQUIRED": "Tu navegador está bloqueando KoalaSync en {host}. Haz clic en «Permitir acceso». Si no aparece ningún diálogo, permite KoalaSync en este sitio desde los permisos de extensiones o del sitio.",
|
||||
"BTN_RETRY_ACCESS": "Permitir acceso",
|
||||
"ERR_SELECT_VIDEO": "¡Selecciona un video primero!",
|
||||
"TOAST_INVITE_COPIED": "¡Enlace de invitación copiado!",
|
||||
"TOAST_COPY_FAILED": "Error al copiar al portapapeles",
|
||||
@@ -209,6 +211,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Aplica efectos de audio como compresión a la reproducción de video",
|
||||
"AUDIO_OPEN_SETTINGS": "Abrir",
|
||||
"NEW_FEATURE_AUDIO": "Nuevo: Procesamiento de audio — ¡prueba el compresor!",
|
||||
"NEW_FEATURE_THEME": "Nuevo: Temas — cambia al aspecto clásico o al grafito minimalista.",
|
||||
"AUDIO_BACK": "← Volver",
|
||||
"AUDIO_PAGE_TITLE": "Configuración de audio",
|
||||
"AUDIO_MASTER_TOGGLE": "Procesamiento de audio",
|
||||
@@ -238,5 +241,38 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Enviar título de pestaña",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Envía a la sala el título de la pestaña del navegador seleccionada.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Título multimedia",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Elige cómo se envían los títulos multimedia a la sala."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Elige cómo se envían los títulos multimedia a la sala.",
|
||||
"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_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",
|
||||
"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 500 caracteres.",
|
||||
"CHAT_SEND_FAILED": "No se pudo enviar el mensaje.",
|
||||
"CHAT_EMPTY": "Aún no hay mensajes. El chat solo es en directo."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Contrôle à distance",
|
||||
"BTN_COPY_INVITE": "📋 Lien d'invitation",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copier le lien d'invitation",
|
||||
"BTN_PLAY": "▶ Lecture",
|
||||
"BTN_PLAY": "Lecture",
|
||||
"BTN_PLAY_TOOLTIP": "Envoyer une commande Lecture à tout le monde",
|
||||
"BTN_PAUSE": "⏸ Pause",
|
||||
"BTN_PAUSE": "Pause",
|
||||
"BTN_PAUSE_TOOLTIP": "Envoyer une commande Pause à tout le monde",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC": "Sync",
|
||||
"BTN_SYNC_TOOLTIP": "Forcer tous les utilisateurs à se synchroniser",
|
||||
"OPTION_JUMP_TO_OTHERS": "Rejoindre les autres",
|
||||
"OPTION_JUMP_TO_ME": "Les amener à moi",
|
||||
@@ -148,6 +148,8 @@
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identifiant non encore chargé. Veuillez patienter et réessayer.",
|
||||
"ERR_NO_PEERS_TIME": "Aucun autre membre avec une position connue. Basculez sur 'Les amener à moi'.",
|
||||
"ERR_NO_VIDEO_TAB": "Impossible de se connecter à l'onglet vidéo.",
|
||||
"SITE_ACCESS_REQUIRED": "Votre navigateur bloque KoalaSync sur {host}. Cliquez sur « Autoriser l’accès ». Si aucune boîte de dialogue n’apparaît, autorisez KoalaSync sur ce site dans les permissions des extensions ou du site.",
|
||||
"BTN_RETRY_ACCESS": "Autoriser l’accès",
|
||||
"ERR_SELECT_VIDEO": "Veuillez d'abord sélectionner une vidéo !",
|
||||
"TOAST_INVITE_COPIED": "Lien d'invitation copié !",
|
||||
"TOAST_COPY_FAILED": "Échec de la copie dans le presse-papiers",
|
||||
@@ -209,6 +211,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Applique des effets audio comme la compression à la lecture vidéo",
|
||||
"AUDIO_OPEN_SETTINGS": "Ouvrir",
|
||||
"NEW_FEATURE_AUDIO": "Nouveau : Traitement audio — essayez le compresseur !",
|
||||
"NEW_FEATURE_THEME": "Nouveau : Thèmes — passez au look classique ou au graphite minimal.",
|
||||
"AUDIO_BACK": "← Retour",
|
||||
"AUDIO_PAGE_TITLE": "Paramètres audio",
|
||||
"AUDIO_MASTER_TOGGLE": "Traitement audio",
|
||||
@@ -238,5 +241,38 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Envoyer le titre de l’onglet",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Envoie au salon le titre de l’onglet de navigateur sélectionné.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Titre du média",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Choisissez comment les titres des médias sont envoyés au salon."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Choisissez comment les titres des médias sont envoyés au salon.",
|
||||
"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_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",
|
||||
"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": "500 caractères maximum.",
|
||||
"CHAT_SEND_FAILED": "Le message n'a pas pu être envoyé.",
|
||||
"CHAT_EMPTY": "Aucun message. Le chat est uniquement en direct."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Telecomando",
|
||||
"BTN_COPY_INVITE": "📋 Link di Invito",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copia il link di invito",
|
||||
"BTN_PLAY": "▶ Riproduci",
|
||||
"BTN_PLAY": "Riproduci",
|
||||
"BTN_PLAY_TOOLTIP": "Avvia la riproduzione per tutti",
|
||||
"BTN_PAUSE": "⏸ Pausa",
|
||||
"BTN_PAUSE": "Pausa",
|
||||
"BTN_PAUSE_TOOLTIP": "Metti in pausa per tutti",
|
||||
"BTN_SYNC": "⚡ SINCRO",
|
||||
"BTN_SYNC": "Sincro",
|
||||
"BTN_SYNC_TOOLTIP": "Forza la sincronizzazione per tutti",
|
||||
"OPTION_JUMP_TO_OTHERS": "Vai alla posizione degli altri",
|
||||
"OPTION_JUMP_TO_ME": "Portali alla mia posizione",
|
||||
@@ -148,6 +148,8 @@
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identità non ancora caricata. Attendi un momento.",
|
||||
"ERR_NO_PEERS_TIME": "Nessun altro partecipante con posizione nota. Passa a 'Portali alla mia posizione'.",
|
||||
"ERR_NO_VIDEO_TAB": "Impossibile connettersi alla scheda del video.",
|
||||
"SITE_ACCESS_REQUIRED": "Il browser sta bloccando KoalaSync su {host}. Fai clic su «Consenti accesso». Se non appare alcuna finestra, consenti KoalaSync su questo sito nelle autorizzazioni dell’estensione o del sito.",
|
||||
"BTN_RETRY_ACCESS": "Consenti accesso",
|
||||
"ERR_SELECT_VIDEO": "Seleziona prima un video!",
|
||||
"TOAST_INVITE_COPIED": "Link di invito copiato!",
|
||||
"TOAST_COPY_FAILED": "Impossibile copiare negli appunti",
|
||||
@@ -209,6 +211,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Applica effetti sonori (come il compressore) ai video",
|
||||
"AUDIO_OPEN_SETTINGS": "Apri",
|
||||
"NEW_FEATURE_AUDIO": "Novità: Elaborazione Audio — prova il compressore!",
|
||||
"NEW_FEATURE_THEME": "Nuovo: Temi — passa all'aspetto classico o al grafite minimale.",
|
||||
"AUDIO_BACK": "← Indietro",
|
||||
"AUDIO_PAGE_TITLE": "Impostazioni Audio",
|
||||
"AUDIO_MASTER_TOGGLE": "Elaborazione Audio",
|
||||
@@ -238,5 +241,38 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Invia titolo scheda",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Invia alla stanza il titolo della scheda del browser selezionata.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Titolo multimediale",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Scegli come inviare alla stanza i titoli multimediali."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Scegli come inviare alla stanza i titoli multimediali.",
|
||||
"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_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 mobile",
|
||||
"OPTION_CHAT_START_OPEN": "Overlay aperto",
|
||||
"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 500 caratteri.",
|
||||
"CHAT_SEND_FAILED": "Impossibile inviare il messaggio.",
|
||||
"CHAT_EMPTY": "Nessun messaggio. La chat è solo in diretta."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "リモートコントロール",
|
||||
"BTN_COPY_INVITE": "📋 招待リンク",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "招待リンクをコピー",
|
||||
"BTN_PLAY": "▶ 再生",
|
||||
"BTN_PLAY": "再生",
|
||||
"BTN_PLAY_TOOLTIP": "全員に再生コマンドを送信",
|
||||
"BTN_PAUSE": "⏸ 一時停止",
|
||||
"BTN_PAUSE": "一時停止",
|
||||
"BTN_PAUSE_TOOLTIP": "全員に一時停止コマンドを送信",
|
||||
"BTN_SYNC": "⚡ 同期",
|
||||
"BTN_SYNC": "同期",
|
||||
"BTN_SYNC_TOOLTIP": "すべてのユーザーを強制的に同期",
|
||||
"OPTION_JUMP_TO_OTHERS": "他の人に合わせる",
|
||||
"OPTION_JUMP_TO_ME": "自分に合わせる",
|
||||
@@ -148,6 +148,8 @@
|
||||
"ERR_IDENTITY_NOT_LOADED": "IDがまだロードされていません。しばらく待ってから再試行してください。",
|
||||
"ERR_NO_PEERS_TIME": "既知の時間を持つ他のメンバーがいません。「自分に合わせる」に切り替えてください。",
|
||||
"ERR_NO_VIDEO_TAB": "ビデオタブに接続できませんでした。",
|
||||
"SITE_ACCESS_REQUIRED": "ブラウザーが {host} で KoalaSync をブロックしています。下の「アクセスを許可」をクリックしてください。ダイアログが表示されない場合は、拡張機能またはサイトの権限でこのサイトの KoalaSync を許可してください。",
|
||||
"BTN_RETRY_ACCESS": "アクセスを許可",
|
||||
"ERR_SELECT_VIDEO": "最初にビデオを選択してください!",
|
||||
"TOAST_INVITE_COPIED": "招待リンクをコピーしました!",
|
||||
"TOAST_COPY_FAILED": "クリップボードへのコピーに失敗しました",
|
||||
@@ -209,6 +211,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "動画再生に圧縮などのオーディオエフェクトを適用します",
|
||||
"AUDIO_OPEN_SETTINGS": "開く",
|
||||
"NEW_FEATURE_AUDIO": "新機能: オーディオ処理 — コンプレッサーを試してみよう!",
|
||||
"NEW_FEATURE_THEME": "新機能:テーマ — クラシックな外観やミニマルなグラファイトに切り替え。",
|
||||
"AUDIO_BACK": "← 戻る",
|
||||
"AUDIO_PAGE_TITLE": "オーディオ設定",
|
||||
"AUDIO_MASTER_TOGGLE": "オーディオ処理",
|
||||
@@ -238,5 +241,38 @@
|
||||
"LABEL_SEND_TAB_TITLE": "タブタイトルを送信",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "選択中のブラウザータブのタイトルをルームに送信します。",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "メディアタイトル",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "メディアタイトルをルームに送信する方法を選択します。"
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "メディアタイトルをルームに送信する方法を選択します。",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "プロフィールと外観",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "ユーザー名、テーマ、言語設定を変更します。",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "再生と同期",
|
||||
"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": "開いたオーバーレイ",
|
||||
"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": "最大500文字です。",
|
||||
"CHAT_SEND_FAILED": "メッセージを送信できませんでした。",
|
||||
"CHAT_EMPTY": "まだメッセージはありません。チャットはライブのみです。"
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "원격 제어",
|
||||
"BTN_COPY_INVITE": "📋 초대 링크",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "초대 링크 복사",
|
||||
"BTN_PLAY": "▶ 재생",
|
||||
"BTN_PLAY": "재생",
|
||||
"BTN_PLAY_TOOLTIP": "모든 사람에게 재생 명령 전송",
|
||||
"BTN_PAUSE": "⏸ 일시정지",
|
||||
"BTN_PAUSE": "일시정지",
|
||||
"BTN_PAUSE_TOOLTIP": "모든 사람에게 일시정지 명령 전송",
|
||||
"BTN_SYNC": "⚡ 동기화",
|
||||
"BTN_SYNC": "동기화",
|
||||
"BTN_SYNC_TOOLTIP": "모든 사용자의 강제 동기화",
|
||||
"OPTION_JUMP_TO_OTHERS": "다른 사람에게 이동",
|
||||
"OPTION_JUMP_TO_ME": "나에게 이동",
|
||||
@@ -148,6 +148,8 @@
|
||||
"ERR_IDENTITY_NOT_LOADED": "ID가 아직 로드되지 않았습니다. 잠시 후 다시 시도해 주세요.",
|
||||
"ERR_NO_PEERS_TIME": "시간을 알고 있는 다른 피어가 없습니다. '나에게 이동'으로 전환하세요.",
|
||||
"ERR_NO_VIDEO_TAB": "비디오 탭에 연결할 수 없습니다.",
|
||||
"SITE_ACCESS_REQUIRED": "브라우저가 {host}에서 KoalaSync를 차단하고 있습니다. 아래의 ‘액세스 허용’을 클릭하세요. 대화상자가 표시되지 않으면 확장 프로그램 또는 사이트 권한에서 이 사이트의 KoalaSync를 허용하세요.",
|
||||
"BTN_RETRY_ACCESS": "액세스 허용",
|
||||
"ERR_SELECT_VIDEO": "먼저 비디오를 선택하세요!",
|
||||
"TOAST_INVITE_COPIED": "초대 링크가 복사되었습니다!",
|
||||
"TOAST_COPY_FAILED": "클립보드 복사에 실패했습니다",
|
||||
@@ -209,6 +211,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "비디오 재생에 압축과 같은 오디오 효과를 적용합니다",
|
||||
"AUDIO_OPEN_SETTINGS": "열기",
|
||||
"NEW_FEATURE_AUDIO": "신규: 오디오 처리 — 컴프레서를 사용해보세요!",
|
||||
"NEW_FEATURE_THEME": "신규: 테마 — 클래식 룩이나 미니멀 그래파이트로 전환하세요.",
|
||||
"AUDIO_BACK": "← 뒤로",
|
||||
"AUDIO_PAGE_TITLE": "오디오 설정",
|
||||
"AUDIO_MASTER_TOGGLE": "오디오 처리",
|
||||
@@ -238,5 +241,38 @@
|
||||
"LABEL_SEND_TAB_TITLE": "탭 제목 보내기",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "선택한 브라우저 탭 제목을 방에 보냅니다.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "미디어 제목",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "미디어 제목을 방에 보내는 방식을 선택합니다."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "미디어 제목을 방에 보내는 방식을 선택합니다.",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "프로필 및 테마",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "사용자 이름, 테마 및 언어 설정을 변경합니다.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "재생 및 동기화",
|
||||
"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": "열린 오버레이",
|
||||
"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": "최대 500자입니다.",
|
||||
"CHAT_SEND_FAILED": "메시지를 보낼 수 없습니다.",
|
||||
"CHAT_EMPTY": "아직 메시지가 없습니다. 채팅은 실시간 전용입니다."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Afstandsbediening",
|
||||
"BTN_COPY_INVITE": "📋 Uitnodigingslink",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Uitnodigingslink kopiëren",
|
||||
"BTN_PLAY": "▶ Afspelen",
|
||||
"BTN_PLAY": "Afspelen",
|
||||
"BTN_PLAY_TOOLTIP": "Stuur een afspeelopdracht naar iedereen",
|
||||
"BTN_PAUSE": "⏸ Pauzeren",
|
||||
"BTN_PAUSE": "Pauzeren",
|
||||
"BTN_PAUSE_TOOLTIP": "Stuur een pauzeeropdracht naar iedereen",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC": "Sync",
|
||||
"BTN_SYNC_TOOLTIP": "Dwing alle gebruikers om te synchroniseren",
|
||||
"OPTION_JUMP_TO_OTHERS": "Spring naar anderen",
|
||||
"OPTION_JUMP_TO_ME": "Spring naar mij",
|
||||
@@ -148,6 +148,8 @@
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identiteit nog niet geladen. Wacht een moment en probeer het opnieuw.",
|
||||
"ERR_NO_PEERS_TIME": "Geen andere deelnemers met een bekende tijd. Schakel over naar 'Spring naar mij'.",
|
||||
"ERR_NO_VIDEO_TAB": "Kon geen verbinding maken met videotabblad.",
|
||||
"SITE_ACCESS_REQUIRED": "Je browser blokkeert KoalaSync op {host}. Klik hieronder op ‘Toegang toestaan’. Als er geen dialoog verschijnt, sta KoalaSync voor deze site toe via de extensie- of siterechten.",
|
||||
"BTN_RETRY_ACCESS": "Toegang toestaan",
|
||||
"ERR_SELECT_VIDEO": "Selecteer eerst een video!",
|
||||
"TOAST_INVITE_COPIED": "Uitnodigingslink gekopieerd!",
|
||||
"TOAST_COPY_FAILED": "Kopiëren naar klembord mislukt",
|
||||
@@ -209,6 +211,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Past audio-effecten zoals compressie toe op video-afspelen",
|
||||
"AUDIO_OPEN_SETTINGS": "Openen",
|
||||
"NEW_FEATURE_AUDIO": "Nieuw: Audioverwerking — probeer de compressor!",
|
||||
"NEW_FEATURE_THEME": "Nieuw: Thema's — schakel naar de klassieke look of minimaal grafiet.",
|
||||
"AUDIO_BACK": "← Terug",
|
||||
"AUDIO_PAGE_TITLE": "Audio-instellingen",
|
||||
"AUDIO_MASTER_TOGGLE": "Audioverwerking",
|
||||
@@ -238,5 +241,38 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Tabtitel verzenden",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Stuurt de titel van het geselecteerde browsertabblad naar de kamer.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Mediatitel",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Kies hoe mediatitels naar de kamer worden gestuurd."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Kies hoe mediatitels naar de kamer worden gestuurd.",
|
||||
"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_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",
|
||||
"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 500 tekens.",
|
||||
"CHAT_SEND_FAILED": "Bericht kon niet worden verzonden.",
|
||||
"CHAT_EMPTY": "Nog geen berichten. Chat is alleen live."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Zdalne sterowanie",
|
||||
"BTN_COPY_INVITE": "📋 Link do zaproszenia",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Kopiuj link zaproszenia",
|
||||
"BTN_PLAY": "▶ Odtwórz",
|
||||
"BTN_PLAY": "Odtwórz",
|
||||
"BTN_PLAY_TOOLTIP": "Wyślij polecenie odtwarzania do wszystkich",
|
||||
"BTN_PAUSE": "⏸ Pauza",
|
||||
"BTN_PAUSE": "Pauza",
|
||||
"BTN_PAUSE_TOOLTIP": "Wyślij polecenie pauzy do wszystkich",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC": "Sync",
|
||||
"BTN_SYNC_TOOLTIP": "Wymuś synchronizację wszystkich użytkowników",
|
||||
"OPTION_JUMP_TO_OTHERS": "Skocz do innych",
|
||||
"OPTION_JUMP_TO_ME": "Skocz do mnie",
|
||||
@@ -148,6 +148,8 @@
|
||||
"ERR_IDENTITY_NOT_LOADED": "Tożsamość nie została jeszcze załadowana. Odczekaj chwilę i spróbuj ponownie.",
|
||||
"ERR_NO_PEERS_TIME": "Brak innych uczestników ze znanym czasem. Przełącz na 'Skocz do mnie'.",
|
||||
"ERR_NO_VIDEO_TAB": "Nie można połączyć się z kartą wideo.",
|
||||
"SITE_ACCESS_REQUIRED": "Przeglądarka blokuje KoalaSync na {host}. Kliknij poniżej „Zezwól na dostęp”. Jeśli nie pojawi się okno, zezwól KoalaSync na tej stronie w uprawnieniach rozszerzenia lub witryny.",
|
||||
"BTN_RETRY_ACCESS": "Zezwól na dostęp",
|
||||
"ERR_SELECT_VIDEO": "Najpierw wybierz wideo!",
|
||||
"TOAST_INVITE_COPIED": "Link zaproszenia skopiowany!",
|
||||
"TOAST_COPY_FAILED": "Nie udało się skopiować do schowka",
|
||||
@@ -209,6 +211,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Stosuje efekty dźwiękowe, takie jak kompresja, do odtwarzania wideo",
|
||||
"AUDIO_OPEN_SETTINGS": "Otwórz",
|
||||
"NEW_FEATURE_AUDIO": "Nowość: Przetwarzanie dźwięku — wypróbuj kompresor!",
|
||||
"NEW_FEATURE_THEME": "Nowość: Motywy — przełącz na klasyczny wygląd lub minimalny grafit.",
|
||||
"AUDIO_BACK": "← Wstecz",
|
||||
"AUDIO_PAGE_TITLE": "Ustawienia dźwięku",
|
||||
"AUDIO_MASTER_TOGGLE": "Przetwarzanie dźwięku",
|
||||
@@ -238,5 +241,38 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Wysyłaj tytuł karty",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Wysyła do pokoju tytuł wybranej karty przeglądarki.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Tytuł multimediów",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Wybierz, jak tytuły multimediów są wysyłane do pokoju."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Wybierz, jak tytuły multimediów są wysyłane do pokoju.",
|
||||
"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_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",
|
||||
"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 500 znaków.",
|
||||
"CHAT_SEND_FAILED": "Nie udało się wysłać wiadomości.",
|
||||
"CHAT_EMPTY": "Brak wiadomości. Czat działa tylko na żywo."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Controle remoto",
|
||||
"BTN_COPY_INVITE": "📋 Link de convite",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copiar link de convite",
|
||||
"BTN_PLAY": "▶ Reproduzir",
|
||||
"BTN_PLAY": "Reproduzir",
|
||||
"BTN_PLAY_TOOLTIP": "Iniciar a reprodução para todos",
|
||||
"BTN_PAUSE": "⏸ Pausar",
|
||||
"BTN_PAUSE": "Pausar",
|
||||
"BTN_PAUSE_TOOLTIP": "Pausar o vídeo para todos",
|
||||
"BTN_SYNC": "⚡ SINCRO",
|
||||
"BTN_SYNC": "Sincro",
|
||||
"BTN_SYNC_TOOLTIP": "Forçar todos os usuários a sincronizar",
|
||||
"OPTION_JUMP_TO_OTHERS": "Ir para a posição dos outros",
|
||||
"OPTION_JUMP_TO_ME": "Trazer para a minha posição",
|
||||
@@ -148,6 +148,8 @@
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identidade não carregada ainda. Aguarde um momento.",
|
||||
"ERR_NO_PEERS_TIME": "Nenhum participante com posição conhecida. Mude para 'Trazer para a minha posição'.",
|
||||
"ERR_NO_VIDEO_TAB": "Não foi possível conectar à aba do vídeo.",
|
||||
"SITE_ACCESS_REQUIRED": "Seu navegador está bloqueando o KoalaSync em {host}. Clique em «Permitir acesso» abaixo. Se nenhuma caixa de diálogo aparecer, permita o KoalaSync neste site nas permissões da extensão ou do site.",
|
||||
"BTN_RETRY_ACCESS": "Permitir acesso",
|
||||
"ERR_SELECT_VIDEO": "Selecione um vídeo primeiro!",
|
||||
"TOAST_INVITE_COPIED": "Link de convite copiado!",
|
||||
"TOAST_COPY_FAILED": "Falha ao copiar para a área de transferência",
|
||||
@@ -209,6 +211,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Aplica efeitos de áudio (como compressão) aos vídeos",
|
||||
"AUDIO_OPEN_SETTINGS": "Abrir",
|
||||
"NEW_FEATURE_AUDIO": "Novo: Processamento de áudio — experimente o compressor!",
|
||||
"NEW_FEATURE_THEME": "Novo: Temas — mude para o visual clássico ou grafite minimalista.",
|
||||
"AUDIO_BACK": "← Voltar",
|
||||
"AUDIO_PAGE_TITLE": "Configurações de áudio",
|
||||
"AUDIO_MASTER_TOGGLE": "Processamento de áudio",
|
||||
@@ -238,5 +241,38 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Enviar título da aba",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Envia para a sala o título da aba selecionada do navegador.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Título da mídia",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Escolha como os títulos de mídia são enviados para a sala."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Escolha como os títulos de mídia são enviados para a sala.",
|
||||
"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_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",
|
||||
"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 500 caracteres.",
|
||||
"CHAT_SEND_FAILED": "Não foi possível enviar a mensagem.",
|
||||
"CHAT_EMPTY": "Ainda não há mensagens. O chat é somente ao vivo."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Controlo Remoto",
|
||||
"BTN_COPY_INVITE": "📋 Link de Convite",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Copiar Link de Convite",
|
||||
"BTN_PLAY": "▶ Reproduzir",
|
||||
"BTN_PLAY": "Reproduzir",
|
||||
"BTN_PLAY_TOOLTIP": "Iniciar a reprodução para todos",
|
||||
"BTN_PAUSE": "⏸ Pausa",
|
||||
"BTN_PAUSE": "Pausa",
|
||||
"BTN_PAUSE_TOOLTIP": "Pausar o vídeo para todos",
|
||||
"BTN_SYNC": "⚡ SINCRO",
|
||||
"BTN_SYNC": "Sincro",
|
||||
"BTN_SYNC_TOOLTIP": "Forçar sincronização de todos os utilizadores",
|
||||
"OPTION_JUMP_TO_OTHERS": "Ir para a posição dos outros",
|
||||
"OPTION_JUMP_TO_ME": "Trazer para a minha posição",
|
||||
@@ -148,6 +148,8 @@
|
||||
"ERR_IDENTITY_NOT_LOADED": "Identidade ainda não carregada. Aguarde um momento.",
|
||||
"ERR_NO_PEERS_TIME": "Nenhum participante com hora conhecida. Mude para 'Trazer para a minha posição'.",
|
||||
"ERR_NO_VIDEO_TAB": "Não foi possível aceder ao separador do vídeo.",
|
||||
"SITE_ACCESS_REQUIRED": "O seu navegador está a bloquear o KoalaSync em {host}. Clique em «Permitir acesso» abaixo. Se não aparecer uma caixa de diálogo, permita o KoalaSync neste site nas permissões da extensão ou do site.",
|
||||
"BTN_RETRY_ACCESS": "Permitir acesso",
|
||||
"ERR_SELECT_VIDEO": "Selecione primeiro um vídeo!",
|
||||
"TOAST_INVITE_COPIED": "Link de convite copiado!",
|
||||
"TOAST_COPY_FAILED": "Falha ao copiar para a área de transferência",
|
||||
@@ -209,6 +211,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Aplica efeitos de áudio (como compressão) aos vídeos",
|
||||
"AUDIO_OPEN_SETTINGS": "Abrir",
|
||||
"NEW_FEATURE_AUDIO": "Novo: Processamento de áudio — experimente o compressor!",
|
||||
"NEW_FEATURE_THEME": "Novo: Temas — mude para o visual clássico ou grafite minimalista.",
|
||||
"AUDIO_BACK": "← Voltar",
|
||||
"AUDIO_PAGE_TITLE": "Configurações de Áudio",
|
||||
"AUDIO_MASTER_TOGGLE": "Processamento de Áudio",
|
||||
@@ -238,5 +241,38 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Enviar título do separador",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Envia para a sala o título do separador do navegador selecionado.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Título multimédia",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Escolhe como os títulos multimédia são enviados para a sala."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Escolhe como os títulos multimédia são enviados para a sala.",
|
||||
"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_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 ponto a ponto. 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",
|
||||
"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 500 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."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Дистанционное управление",
|
||||
"BTN_COPY_INVITE": "📋 Ссылка",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Копировать ссылку-приглашение",
|
||||
"BTN_PLAY": "▶ Запуск",
|
||||
"BTN_PLAY": "Запуск",
|
||||
"BTN_PLAY_TOOLTIP": "Отправить команду воспроизведения всем",
|
||||
"BTN_PAUSE": "⏸ Пауза",
|
||||
"BTN_PAUSE": "Пауза",
|
||||
"BTN_PAUSE_TOOLTIP": "Отправить команду паузы всем",
|
||||
"BTN_SYNC": "⚡ СИНХРО",
|
||||
"BTN_SYNC": "Синхро",
|
||||
"BTN_SYNC_TOOLTIP": "Принудительно синхронизировать всех участников",
|
||||
"OPTION_JUMP_TO_OTHERS": "Перейти к другим",
|
||||
"OPTION_JUMP_TO_ME": "Синхронизировать по мне",
|
||||
@@ -148,6 +148,8 @@
|
||||
"ERR_IDENTITY_NOT_LOADED": "Идентификатор еще не загружен. Подождите немного и повторите попытку.",
|
||||
"ERR_NO_PEERS_TIME": "Нет других участников с известной позицией плеера. Выберите 'Синхронизировать по мне'.",
|
||||
"ERR_NO_VIDEO_TAB": "Не удалось подключиться к вкладке с видео.",
|
||||
"SITE_ACCESS_REQUIRED": "Браузер блокирует KoalaSync на {host}. Нажмите «Разрешить доступ» ниже. Если диалог не появится, разрешите KoalaSync для этого сайта в настройках расширения или разрешениях сайта.",
|
||||
"BTN_RETRY_ACCESS": "Разрешить доступ",
|
||||
"ERR_SELECT_VIDEO": "Пожалуйста, сначала выберите вкладку с видео!",
|
||||
"TOAST_INVITE_COPIED": "Ссылка скопирована в буфер обмена!",
|
||||
"TOAST_COPY_FAILED": "Не удалось скопировать ссылку в буфер обмена",
|
||||
@@ -209,6 +211,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Применяет аудиоэффекты, такие как сжатие, к воспроизведению видео",
|
||||
"AUDIO_OPEN_SETTINGS": "Открыть",
|
||||
"NEW_FEATURE_AUDIO": "Новое: Обработка звука — попробуйте компрессор!",
|
||||
"NEW_FEATURE_THEME": "Новое: Темы — переключитесь на классический вид или минимальный графит.",
|
||||
"AUDIO_BACK": "← Назад",
|
||||
"AUDIO_PAGE_TITLE": "Настройки звука",
|
||||
"AUDIO_MASTER_TOGGLE": "Обработка звука",
|
||||
@@ -238,5 +241,38 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Отправлять название вкладки",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Отправляет в комнату название выбранной вкладки браузера.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Название медиа",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Выберите, как названия медиа отправляются в комнату."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Выберите, как названия медиа отправляются в комнату.",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Профиль и Оформление",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Измените имя пользователя, тему и настройки языка.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Воспроизведение и Синхронизация",
|
||||
"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": "Открытое окно",
|
||||
"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": "Не более 500 символов.",
|
||||
"CHAT_SEND_FAILED": "Не удалось отправить сообщение.",
|
||||
"CHAT_EMPTY": "Сообщений пока нет. Чат работает только в реальном времени."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Uzaktan Kumanda",
|
||||
"BTN_COPY_INVITE": "📋 Davet Linki",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Davet Linkini Kopyala",
|
||||
"BTN_PLAY": "▶ Oynat",
|
||||
"BTN_PLAY": "Oynat",
|
||||
"BTN_PLAY_TOOLTIP": "Herkese Oynat komutu gönder",
|
||||
"BTN_PAUSE": "⏸ Duraklat",
|
||||
"BTN_PAUSE": "Duraklat",
|
||||
"BTN_PAUSE_TOOLTIP": "Herkese Duraklat komutu gönder",
|
||||
"BTN_SYNC": "⚡ SYNC",
|
||||
"BTN_SYNC": "Sync",
|
||||
"BTN_SYNC_TOOLTIP": "Tüm kullanıcıları senkronize etmeye zorla",
|
||||
"OPTION_JUMP_TO_OTHERS": "Diğerlerine Atla",
|
||||
"OPTION_JUMP_TO_ME": "Bana Atla",
|
||||
@@ -148,6 +148,8 @@
|
||||
"ERR_IDENTITY_NOT_LOADED": "Kimlik henüz yüklenmedi. Biraz bekleyin ve tekrar deneyin.",
|
||||
"ERR_NO_PEERS_TIME": "Bilinen bir zamanı olan başka bağlantı yok. 'Bana Atla' seçeneğine geçin.",
|
||||
"ERR_NO_VIDEO_TAB": "Video sekmesine bağlanılamadı.",
|
||||
"SITE_ACCESS_REQUIRED": "Tarayıcınız {host} üzerinde KoalaSync'i engelliyor. Aşağıdaki “Erişime izin ver” düğmesine tıklayın. İletişim kutusu görünmezse uzantı veya site izinlerinden bu site için KoalaSync'e izin verin.",
|
||||
"BTN_RETRY_ACCESS": "Erişime izin ver",
|
||||
"ERR_SELECT_VIDEO": "Lütfen önce bir video seçin!",
|
||||
"TOAST_INVITE_COPIED": "Davet bağlantısı kopyalandı!",
|
||||
"TOAST_COPY_FAILED": "Panoya kopyalanamadı",
|
||||
@@ -209,6 +211,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Video oynatımına sıkıştırma gibi ses efektleri uygular",
|
||||
"AUDIO_OPEN_SETTINGS": "Aç",
|
||||
"NEW_FEATURE_AUDIO": "Yeni: Ses işleme — kompresörü deneyin!",
|
||||
"NEW_FEATURE_THEME": "Yeni: Temalar — klasik görünüme veya minimal grafite geçin.",
|
||||
"AUDIO_BACK": "← Geri",
|
||||
"AUDIO_PAGE_TITLE": "Ses ayarları",
|
||||
"AUDIO_MASTER_TOGGLE": "Ses işleme",
|
||||
@@ -238,5 +241,38 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Sekme başlığını gönder",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Seçili tarayıcı sekmesinin başlığını odaya gönderir.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Medya başlığı",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Medya başlıklarının odaya nasıl gönderileceğini seçin."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Medya başlıklarının odaya nasıl gönderileceğini seçin.",
|
||||
"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_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",
|
||||
"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 500 karakter.",
|
||||
"CHAT_SEND_FAILED": "Mesaj gönderilemedi.",
|
||||
"CHAT_EMPTY": "Henüz mesaj yok. Sohbet yalnızca canlıdır."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "Пульт дистанційного керування",
|
||||
"BTN_COPY_INVITE": "📋 Посилання на запрошення",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "Копіювати посилання для запрошення",
|
||||
"BTN_PLAY": "▶ Відтворити",
|
||||
"BTN_PLAY": "Відтворити",
|
||||
"BTN_PLAY_TOOLTIP": "Надішліть усім команду Play",
|
||||
"BTN_PAUSE": "⏸ Пауза",
|
||||
"BTN_PAUSE": "Пауза",
|
||||
"BTN_PAUSE_TOOLTIP": "Надішліть команду паузи всім",
|
||||
"BTN_SYNC": "⚡ СИНХР",
|
||||
"BTN_SYNC": "Синхр",
|
||||
"BTN_SYNC_TOOLTIP": "Змусити всіх користувачів синхронізуватися",
|
||||
"OPTION_JUMP_TO_OTHERS": "Перейти до інших",
|
||||
"OPTION_JUMP_TO_ME": "Перейти до мене",
|
||||
@@ -148,6 +148,8 @@
|
||||
"ERR_IDENTITY_NOT_LOADED": "Посвідчення особи ще не завантажено. Зачекайте хвилинку та повторіть спробу.",
|
||||
"ERR_NO_PEERS_TIME": "Немає інших аналогів із відомим часом. Переключіться на «Перейти до мене».",
|
||||
"ERR_NO_VIDEO_TAB": "Не вдалося підключитися до вкладки відео.",
|
||||
"SITE_ACCESS_REQUIRED": "Браузер блокує KoalaSync на {host}. Натисніть «Дозволити доступ» нижче. Якщо діалог не з’явиться, дозвольте KoalaSync для цього сайту в налаштуваннях розширення або дозволах сайту.",
|
||||
"BTN_RETRY_ACCESS": "Дозволити доступ",
|
||||
"ERR_SELECT_VIDEO": "Спочатку виберіть відео!",
|
||||
"TOAST_INVITE_COPIED": "Посилання на запрошення скопійовано!",
|
||||
"TOAST_COPY_FAILED": "Не вдалося скопіювати в буфер обміну",
|
||||
@@ -209,6 +211,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Застосуйте звукові ефекти, такі як стиснення, до відтворення відео",
|
||||
"AUDIO_OPEN_SETTINGS": "Відкрити",
|
||||
"NEW_FEATURE_AUDIO": "Новинка: обробка звуку — спробуйте компресор!",
|
||||
"NEW_FEATURE_THEME": "Нове: Теми — перемкніться на класичний вигляд або мінімальний графіт.",
|
||||
"AUDIO_BACK": "← Назад",
|
||||
"AUDIO_PAGE_TITLE": "Параметри звуку",
|
||||
"AUDIO_MASTER_TOGGLE": "Обробка аудіо",
|
||||
@@ -238,5 +241,38 @@
|
||||
"LABEL_SEND_TAB_TITLE": "Надсилати назву вкладки",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Надсилає до кімнати назву вибраної вкладки браузера.",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "Назва медіа",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Виберіть, як назви медіа надсилати до кімнати."
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Виберіть, як назви медіа надсилати до кімнати.",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Профіль та Вигляд",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Змініть ім'я користувача, тему та мовні налаштування.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Відтворення та Синхронізація",
|
||||
"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": "Відкрите вікно",
|
||||
"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": "Не більше 500 символів.",
|
||||
"CHAT_SEND_FAILED": "Не вдалося надіслати повідомлення.",
|
||||
"CHAT_EMPTY": "Повідомлень ще немає. Чат працює лише наживо."
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
"LABEL_REMOTE_CONTROL": "遥控",
|
||||
"BTN_COPY_INVITE": "📋 邀请链接",
|
||||
"BTN_COPY_INVITE_TOOLTIP": "复制邀请链接",
|
||||
"BTN_PLAY": "▶ 播放",
|
||||
"BTN_PLAY": "播放",
|
||||
"BTN_PLAY_TOOLTIP": "向所有人发送播放命令",
|
||||
"BTN_PAUSE": "⏸ 暂停",
|
||||
"BTN_PAUSE": "暂停",
|
||||
"BTN_PAUSE_TOOLTIP": "向所有人发送暂停命令",
|
||||
"BTN_SYNC": "⚡ 同步",
|
||||
"BTN_SYNC": "同步",
|
||||
"BTN_SYNC_TOOLTIP": "强制所有用户同步",
|
||||
"OPTION_JUMP_TO_OTHERS": "跳转到其他",
|
||||
"OPTION_JUMP_TO_ME": "跳到我这里",
|
||||
@@ -148,6 +148,8 @@
|
||||
"ERR_IDENTITY_NOT_LOADED": "身份尚未加载。稍等片刻,然后重试。",
|
||||
"ERR_NO_PEERS_TIME": "没有其他已知时间的同行。切换到“跳到我这里”。",
|
||||
"ERR_NO_VIDEO_TAB": "无法连接到视频选项卡。",
|
||||
"SITE_ACCESS_REQUIRED": "浏览器正在阻止 KoalaSync 访问 {host}。请点击下方的“允许访问”。如果未显示对话框,请在扩展程序或网站权限中允许 KoalaSync 访问此网站。",
|
||||
"BTN_RETRY_ACCESS": "允许访问",
|
||||
"ERR_SELECT_VIDEO": "请先选择视频!",
|
||||
"TOAST_INVITE_COPIED": "邀请链接已复制!",
|
||||
"TOAST_COPY_FAILED": "无法复制到剪贴板",
|
||||
@@ -209,6 +211,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "将压缩等音频效果应用于视频播放",
|
||||
"AUDIO_OPEN_SETTINGS": "打开",
|
||||
"NEW_FEATURE_AUDIO": "新功能:音频处理 - 尝试压缩器!",
|
||||
"NEW_FEATURE_THEME": "新功能:主题 — 切换到经典外观或极简石墨风格。",
|
||||
"AUDIO_BACK": "← 返回",
|
||||
"AUDIO_PAGE_TITLE": "音频设置",
|
||||
"AUDIO_MASTER_TOGGLE": "音频处理",
|
||||
@@ -238,5 +241,38 @@
|
||||
"LABEL_SEND_TAB_TITLE": "发送标签页标题",
|
||||
"LABEL_SEND_TAB_TITLE_TOOLTIP": "将所选浏览器标签页的标题发送到房间。",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY": "媒体标题",
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "选择媒体标题发送到房间的方式。"
|
||||
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "选择媒体标题发送到房间的方式。",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "个人资料与外观",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "更改您的用户名、主题和语言首选项。",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "播放与同步",
|
||||
"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": "打开浮层",
|
||||
"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": "最多500个字符。",
|
||||
"CHAT_SEND_FAILED": "无法发送消息。",
|
||||
"CHAT_EMPTY": "暂无消息。聊天仅实时显示。"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"default_locale": "en",
|
||||
"name": "KoalaSync",
|
||||
"version": "2.5.2",
|
||||
"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.0",
|
||||
"description": "__MSG_appDesc__",
|
||||
"permissions": [
|
||||
"storage",
|
||||
"tabs",
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
const GOOGLE_DRIVE_HOST = 'drive.google.com';
|
||||
const GOOGLE_DRIVE_PLAYER_HOST = 'youtube.googleapis.com';
|
||||
export const GOOGLE_DRIVE_PLAYER_ORIGIN_PATTERN = 'https://youtube.googleapis.com/*';
|
||||
export const GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED = 'google_drive_player_access_required';
|
||||
export const GOOGLE_DRIVE_PLAYER_AMBIGUOUS = 'google_drive_player_ambiguous';
|
||||
|
||||
export function isGoogleDriveUrl(value) {
|
||||
try {
|
||||
return new URL(value).hostname.toLowerCase() === GOOGLE_DRIVE_HOST;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function inspectMediaFrame() {
|
||||
const videos = Array.from(document.querySelectorAll('video'));
|
||||
const drivePlayerIframes = Array.from(document.querySelectorAll('iframe')).filter((frame) => {
|
||||
try {
|
||||
const url = new URL(frame.src);
|
||||
if (url.hostname.toLowerCase() !== 'youtube.googleapis.com') return false;
|
||||
const origin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin');
|
||||
return origin === 'https://drive.google.com';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
let bestVideoScore = 0;
|
||||
|
||||
for (const video of videos) {
|
||||
const width = video.videoWidth || video.offsetWidth || 0;
|
||||
const height = video.videoHeight || video.offsetHeight || 0;
|
||||
const duration = Number.isFinite(video.duration) ? video.duration : 0;
|
||||
bestVideoScore = Math.max(bestVideoScore, (width * height) + (duration * 100));
|
||||
}
|
||||
|
||||
return {
|
||||
href: window.location.href,
|
||||
videoCount: videos.length,
|
||||
videoScore: bestVideoScore,
|
||||
frameArea: Math.max(0, window.innerWidth) * Math.max(0, window.innerHeight),
|
||||
drivePlayerIframeCount: drivePlayerIframes.length,
|
||||
parentFrameVisible: window.__koalaParentFrameVisibility?.visible ?? null,
|
||||
parentFrameArea: window.__koalaParentFrameVisibility?.area ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function installParentFrameVisibilityProbe(token) {
|
||||
const handler = (event) => {
|
||||
if (event.source !== window.parent
|
||||
|| event.data?.type !== 'KOALASYNC_FRAME_VISIBILITY'
|
||||
|| event.data?.token !== token) {
|
||||
return;
|
||||
}
|
||||
window.__koalaParentFrameVisibility = {
|
||||
visible: event.data.visible === true,
|
||||
area: Number.isFinite(event.data.area) ? event.data.area : 0
|
||||
};
|
||||
window.removeEventListener('message', handler);
|
||||
};
|
||||
window.addEventListener('message', handler);
|
||||
}
|
||||
|
||||
export async function dispatchParentFrameVisibilityProbe(token) {
|
||||
let count = 0;
|
||||
for (const frame of document.querySelectorAll('iframe')) {
|
||||
try {
|
||||
const url = new URL(frame.src);
|
||||
const origin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin');
|
||||
if (url.hostname.toLowerCase() !== 'youtube.googleapis.com'
|
||||
|| origin !== 'https://drive.google.com') {
|
||||
continue;
|
||||
}
|
||||
const rect = frame.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(frame);
|
||||
const area = Math.max(0, rect.width) * Math.max(0, rect.height);
|
||||
const intersectsViewport = rect.bottom > 0
|
||||
&& rect.right > 0
|
||||
&& rect.top < window.innerHeight
|
||||
&& rect.left < window.innerWidth;
|
||||
const browserReportsVisible = typeof frame.checkVisibility === 'function'
|
||||
? frame.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
|
||||
: true;
|
||||
const visible = area > 0
|
||||
&& intersectsViewport
|
||||
&& browserReportsVisible
|
||||
&& style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& Number(style.opacity) !== 0;
|
||||
frame.contentWindow?.postMessage({
|
||||
type: 'KOALASYNC_FRAME_VISIBILITY',
|
||||
token,
|
||||
visible,
|
||||
area
|
||||
}, 'https://youtube.googleapis.com');
|
||||
count++;
|
||||
} catch {
|
||||
// Ignore unrelated or not-yet-initialized frames.
|
||||
}
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return count;
|
||||
}
|
||||
|
||||
function isGoogleDrivePlayerFrameUrl(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.hostname.toLowerCase() !== GOOGLE_DRIVE_PLAYER_HOST || url.pathname !== '/embed/') {
|
||||
return false;
|
||||
}
|
||||
const origin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin');
|
||||
return origin === 'https://drive.google.com';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createGoogleDrivePlayerAccessError() {
|
||||
const error = new Error('Google Drive player frame access is required');
|
||||
error.code = GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED;
|
||||
error.originPattern = GOOGLE_DRIVE_PLAYER_ORIGIN_PATTERN;
|
||||
return error;
|
||||
}
|
||||
|
||||
function createGoogleDrivePlayerAmbiguousError() {
|
||||
const error = new Error('Google Drive player frame could not be identified safely');
|
||||
error.code = GOOGLE_DRIVE_PLAYER_AMBIGUOUS;
|
||||
return error;
|
||||
}
|
||||
|
||||
export function selectGoogleDrivePlayerFrame(injectionResults) {
|
||||
let candidates = (Array.isArray(injectionResults) ? injectionResults : [])
|
||||
.filter(entry => Number.isInteger(entry?.frameId) && entry?.result)
|
||||
.filter(entry => isGoogleDrivePlayerFrameUrl(entry.result.href));
|
||||
|
||||
if (candidates.length > 1) {
|
||||
const confirmedVisible = candidates.filter(entry => entry.result.parentFrameVisible === true);
|
||||
if (confirmedVisible.length > 0) {
|
||||
candidates = confirmedVisible;
|
||||
} else {
|
||||
const notConfirmedHidden = candidates.filter(entry =>
|
||||
entry.result.parentFrameVisible !== false
|
||||
);
|
||||
if (notConfirmedHidden.length !== 1) return null;
|
||||
candidates = notConfirmedHidden;
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((left, right) => {
|
||||
const visibilityRank = result => result.parentFrameVisible === true
|
||||
? 2
|
||||
: result.parentFrameVisible === false
|
||||
? 0
|
||||
: 1;
|
||||
const leftVisible = visibilityRank(left.result);
|
||||
const rightVisible = visibilityRank(right.result);
|
||||
if (leftVisible !== rightVisible) return rightVisible - leftVisible;
|
||||
const leftParentArea = Number.isFinite(left.result.parentFrameArea)
|
||||
? left.result.parentFrameArea
|
||||
: left.result.frameArea;
|
||||
const rightParentArea = Number.isFinite(right.result.parentFrameArea)
|
||||
? right.result.parentFrameArea
|
||||
: right.result.frameArea;
|
||||
if (leftParentArea !== rightParentArea) return rightParentArea - leftParentArea;
|
||||
const leftHasVideo = left.result.videoCount > 0 ? 1 : 0;
|
||||
const rightHasVideo = right.result.videoCount > 0 ? 1 : 0;
|
||||
if (leftHasVideo !== rightHasVideo) return rightHasVideo - leftHasVideo;
|
||||
if (left.result.videoScore !== right.result.videoScore) {
|
||||
return right.result.videoScore - left.result.videoScore;
|
||||
}
|
||||
return right.result.frameArea - left.result.frameArea;
|
||||
});
|
||||
|
||||
return candidates[0] || null;
|
||||
}
|
||||
|
||||
export async function resolveMediaScriptTarget(chromeApi, tabId, tabUrl, {
|
||||
attempts = 8,
|
||||
retryDelayMs = 200
|
||||
} = {}) {
|
||||
const topFrameTarget = { tabId };
|
||||
if (!isGoogleDriveUrl(tabUrl)) return topFrameTarget;
|
||||
|
||||
let fallbackFrame = null;
|
||||
let drivePlayerIframeDetected = false;
|
||||
let ambiguousPlayerFramesDetected = false;
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
let results;
|
||||
try {
|
||||
results = await chromeApi.scripting.executeScript({
|
||||
target: { tabId, allFrames: true },
|
||||
func: inspectMediaFrame
|
||||
});
|
||||
} catch {
|
||||
let topFrameResult = null;
|
||||
try {
|
||||
const topResults = await chromeApi.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: inspectMediaFrame
|
||||
});
|
||||
topFrameResult = Array.isArray(topResults) ? topResults[0]?.result : null;
|
||||
} catch {
|
||||
// The generic injection path below remains the final source of truth.
|
||||
}
|
||||
if (topFrameResult?.drivePlayerIframeCount > 0) {
|
||||
throw createGoogleDrivePlayerAccessError();
|
||||
}
|
||||
return topFrameTarget;
|
||||
}
|
||||
|
||||
const topFrame = results.find(entry => entry?.frameId === 0);
|
||||
if (topFrame?.result?.drivePlayerIframeCount > 0) {
|
||||
drivePlayerIframeDetected = true;
|
||||
}
|
||||
const playerFrameCount = results.filter(entry =>
|
||||
isGoogleDrivePlayerFrameUrl(entry?.result?.href)
|
||||
).length;
|
||||
if (playerFrameCount > 1) {
|
||||
const token = `${tabId}:${attempt}:${Date.now()}:${Math.random()}`;
|
||||
try {
|
||||
await chromeApi.scripting.executeScript({
|
||||
target: { tabId, allFrames: true },
|
||||
func: installParentFrameVisibilityProbe,
|
||||
args: [token]
|
||||
});
|
||||
await chromeApi.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: dispatchParentFrameVisibilityProbe,
|
||||
args: [token]
|
||||
});
|
||||
results = await chromeApi.scripting.executeScript({
|
||||
target: { tabId, allFrames: true },
|
||||
func: inspectMediaFrame
|
||||
});
|
||||
} catch {
|
||||
// Ambiguous frames are retried below; never guess a stale player.
|
||||
}
|
||||
}
|
||||
const selectedFrame = selectGoogleDrivePlayerFrame(results);
|
||||
if (!selectedFrame && playerFrameCount > 1) {
|
||||
ambiguousPlayerFramesDetected = true;
|
||||
}
|
||||
if (selectedFrame?.result?.videoCount > 0) {
|
||||
return { tabId, frameIds: [selectedFrame.frameId] };
|
||||
}
|
||||
if (selectedFrame) fallbackFrame = selectedFrame;
|
||||
|
||||
if (attempt < attempts - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
|
||||
}
|
||||
}
|
||||
|
||||
if (!fallbackFrame && ambiguousPlayerFramesDetected) {
|
||||
throw createGoogleDrivePlayerAmbiguousError();
|
||||
}
|
||||
if (!fallbackFrame && drivePlayerIframeDetected) {
|
||||
throw createGoogleDrivePlayerAccessError();
|
||||
}
|
||||
|
||||
return fallbackFrame
|
||||
? { tabId, frameIds: [fallbackFrame.frameId] }
|
||||
: topFrameTarget;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED,
|
||||
GOOGLE_DRIVE_PLAYER_AMBIGUOUS,
|
||||
isGoogleDriveUrl,
|
||||
resolveMediaScriptTarget,
|
||||
selectGoogleDrivePlayerFrame
|
||||
} from './media-frame-target.js';
|
||||
|
||||
const drivePlayerUrl = 'https://youtube.googleapis.com/embed/?enablejsapi=1&origin=https%3A%2F%2Fdrive.google.com';
|
||||
|
||||
describe('Google Drive media-frame targeting', () => {
|
||||
it('recognizes Drive tabs without treating other Google pages as Drive', () => {
|
||||
expect(isGoogleDriveUrl('https://drive.google.com/drive/u/1/search?q=mp4')).toBe(true);
|
||||
expect(isGoogleDriveUrl('https://docs.google.com/document/d/1/edit')).toBe(false);
|
||||
});
|
||||
|
||||
it('selects the visible frame containing the real video', () => {
|
||||
const selected = selectGoogleDrivePlayerFrame([
|
||||
{ frameId: 0, result: { href: 'https://drive.google.com/drive/u/1/search?q=mp4', videoCount: 0, videoScore: 0, frameArea: 900000 } },
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1900000, frameArea: 900000, parentFrameVisible: true } },
|
||||
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 0, parentFrameVisible: false } }
|
||||
]);
|
||||
|
||||
expect(selected.frameId).toBe(4);
|
||||
});
|
||||
|
||||
it('never lets a hidden loaded player outrank the visible Drive player', () => {
|
||||
const selected = selectGoogleDrivePlayerFrame([
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000, parentFrameVisible: true, parentFrameArea: 900000 } },
|
||||
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1281600, frameArea: 900000, parentFrameVisible: false, parentFrameArea: 900000 } }
|
||||
]);
|
||||
|
||||
expect(selected.frameId).toBe(4);
|
||||
});
|
||||
|
||||
it('refuses to guess when parent visibility is unavailable', () => {
|
||||
const selected = selectGoogleDrivePlayerFrame([
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000 } },
|
||||
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1281600, frameArea: 900000 } }
|
||||
]);
|
||||
|
||||
expect(selected).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores unrelated video frames inside a Drive tab', () => {
|
||||
const selected = selectGoogleDrivePlayerFrame([
|
||||
{ frameId: 3, result: { href: 'https://example.com/embed', videoCount: 1, videoScore: 3000000, frameArea: 1200000 } },
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000 } }
|
||||
]);
|
||||
|
||||
expect(selected.frameId).toBe(4);
|
||||
});
|
||||
|
||||
it('falls back to the visible Drive player frame while its video is loading', () => {
|
||||
const selected = selectGoogleDrivePlayerFrame([
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 900000, parentFrameVisible: true } },
|
||||
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 0, parentFrameVisible: false } }
|
||||
]);
|
||||
|
||||
expect(selected.frameId).toBe(4);
|
||||
});
|
||||
|
||||
it('reports ambiguity instead of selecting a stale frame after retries', async () => {
|
||||
const ambiguousResults = [
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000 } },
|
||||
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1281600, frameArea: 900000 } }
|
||||
];
|
||||
const executeScript = vi.fn().mockResolvedValue(ambiguousResults);
|
||||
|
||||
await expect(resolveMediaScriptTarget(
|
||||
{ scripting: { executeScript } },
|
||||
42,
|
||||
'https://drive.google.com/drive/u/1/search?q=mp4',
|
||||
{ attempts: 1, retryDelayMs: 0 }
|
||||
)).rejects.toMatchObject({ code: GOOGLE_DRIVE_PLAYER_AMBIGUOUS });
|
||||
});
|
||||
|
||||
it('probes all frames and returns only the frame with the Drive video', async () => {
|
||||
const executeScript = vi.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{ frameId: 0, result: { href: 'https://drive.google.com/drive/u/1/search?q=mp4', videoCount: 0, videoScore: 0, frameArea: 900000 } },
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 900000 } }
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ frameId: 0, result: { href: 'https://drive.google.com/drive/u/1/search?q=mp4', videoCount: 0, videoScore: 0, frameArea: 900000 } },
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1900000, frameArea: 900000 } }
|
||||
]);
|
||||
|
||||
await expect(resolveMediaScriptTarget(
|
||||
{ scripting: { executeScript } },
|
||||
42,
|
||||
'https://drive.google.com/drive/u/1/search?q=mp4',
|
||||
{ retryDelayMs: 0 }
|
||||
)).resolves.toEqual({ tabId: 42, frameIds: [4] });
|
||||
|
||||
expect(executeScript).toHaveBeenCalledWith({
|
||||
target: { tabId: 42, allFrames: true },
|
||||
func: expect.any(Function)
|
||||
});
|
||||
});
|
||||
|
||||
it('reports missing access when Drive exposes a player iframe but the frame cannot be inspected', async () => {
|
||||
const executeScript = vi.fn().mockResolvedValue([
|
||||
{
|
||||
frameId: 0,
|
||||
result: {
|
||||
href: 'https://drive.google.com/drive/u/1/search?q=mp4',
|
||||
videoCount: 0,
|
||||
videoScore: 0,
|
||||
frameArea: 900000,
|
||||
drivePlayerIframeCount: 1
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
await expect(resolveMediaScriptTarget(
|
||||
{ scripting: { executeScript } },
|
||||
42,
|
||||
'https://drive.google.com/drive/u/1/search?q=mp4',
|
||||
{ attempts: 1, retryDelayMs: 0 }
|
||||
)).rejects.toMatchObject({ code: GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED });
|
||||
});
|
||||
|
||||
it('reports missing access when the all-frame probe itself is rejected', async () => {
|
||||
const executeScript = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('Cannot access contents of the frame'))
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
frameId: 0,
|
||||
result: {
|
||||
href: 'https://drive.google.com/drive/u/1/search?q=mp4',
|
||||
videoCount: 0,
|
||||
videoScore: 0,
|
||||
frameArea: 900000,
|
||||
drivePlayerIframeCount: 1
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
await expect(resolveMediaScriptTarget(
|
||||
{ scripting: { executeScript } },
|
||||
42,
|
||||
'https://drive.google.com/drive/u/1/search?q=mp4',
|
||||
{ attempts: 1, retryDelayMs: 0 }
|
||||
)).rejects.toMatchObject({ code: GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED });
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,8 @@
|
||||
export function initTabManager({
|
||||
getCurrentTabId,
|
||||
setCurrentTabId,
|
||||
setCurrentTabTitle,
|
||||
setLastContentHeartbeatAt,
|
||||
setRoomIdleSince,
|
||||
getCurrentRoom,
|
||||
getPeerId,
|
||||
getStorageInitialized,
|
||||
updateBadgeStatus,
|
||||
addLog,
|
||||
getSettings,
|
||||
emit,
|
||||
applyAudioSettingsToTab,
|
||||
injectContentScript,
|
||||
reactivateCurrentTarget,
|
||||
ensureState,
|
||||
EVENTS
|
||||
sendToCurrentContent
|
||||
}) {
|
||||
chrome.storage.onChanged.addListener(async (changes, area) => {
|
||||
if (area !== 'local' || !changes.audioSettings) return;
|
||||
@@ -22,71 +10,20 @@ export function initTabManager({
|
||||
const tabId = getCurrentTabId();
|
||||
if (!tabId) return;
|
||||
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
sendToCurrentContent({
|
||||
action: 'APPLY_AUDIO_SETTINGS',
|
||||
settings: changes.audioSettings.newValue
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
chrome.tabs.onRemoved.addListener(async (tabId) => {
|
||||
await ensureState();
|
||||
if (tabId === getCurrentTabId()) {
|
||||
const wasInRoom = !!getCurrentRoom();
|
||||
setCurrentTabId(null);
|
||||
setCurrentTabTitle(null);
|
||||
setLastContentHeartbeatAt(null);
|
||||
const now = Date.now();
|
||||
setRoomIdleSince(now);
|
||||
chrome.storage.session.set({
|
||||
currentTabId: null,
|
||||
currentTabTitle: null,
|
||||
roomIdleSince: now,
|
||||
lastContentHeartbeatAt: null
|
||||
});
|
||||
updateBadgeStatus();
|
||||
addLog('Target tab closed.', 'warn');
|
||||
|
||||
if (wasInRoom) {
|
||||
const roomAtClose = getCurrentRoom();
|
||||
getSettings().then(settings => {
|
||||
if (getCurrentRoom() !== roomAtClose) return;
|
||||
|
||||
emit(EVENTS.PEER_STATUS, {
|
||||
peerId: getPeerId(),
|
||||
playbackState: 'paused',
|
||||
currentTime: null,
|
||||
mediaTitle: null,
|
||||
username: settings.username,
|
||||
tabTitle: null
|
||||
});
|
||||
|
||||
const room = getCurrentRoom();
|
||||
if (room && Array.isArray(room.peers)) {
|
||||
const me = room.peers.find(p => (p.peerId || p) === getPeerId());
|
||||
if (me && typeof me === 'object') {
|
||||
me.playbackState = 'paused';
|
||||
me.currentTime = null;
|
||||
me.mediaTitle = null;
|
||||
me.tabTitle = null;
|
||||
me.lastHeartbeat = Date.now();
|
||||
if (getStorageInitialized()) {
|
||||
chrome.storage.session.set({ currentRoom: room });
|
||||
}
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: room.peers }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => {
|
||||
await ensureState();
|
||||
const curTabId = getCurrentTabId();
|
||||
if (curTabId && tabId === parseInt(curTabId) && changeInfo.status === 'complete') {
|
||||
injectContentScript(tabId)
|
||||
.then(() => applyAudioSettingsToTab(tabId))
|
||||
.catch(() => {});
|
||||
const currentTabId = Number(getCurrentTabId());
|
||||
if (Number.isInteger(currentTabId)
|
||||
&& currentTabId > 0
|
||||
&& tabId === currentTabId
|
||||
&& changeInfo.status === 'complete') {
|
||||
reactivateCurrentTarget(tabId).catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+1262
-197
File diff suppressed because it is too large
Load Diff
+620
-165
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
function hostnameMatchesDomain(hostname, domain) {
|
||||
const normalizedHostname = String(hostname || '').toLowerCase();
|
||||
const normalizedDomain = String(domain || '').toLowerCase();
|
||||
return normalizedDomain
|
||||
&& (normalizedHostname === normalizedDomain
|
||||
|| normalizedHostname.endsWith(`.${normalizedDomain}`));
|
||||
}
|
||||
|
||||
export function isTabUrlFiltered(url, blacklistDomains, allowlistDomains = []) {
|
||||
const urlString = String(url || '');
|
||||
|
||||
try {
|
||||
const hostname = new URL(urlString).hostname.toLowerCase();
|
||||
if (allowlistDomains.some(domain => hostnameMatchesDomain(hostname, domain))) {
|
||||
return false;
|
||||
}
|
||||
return blacklistDomains.some(domain => hostnameMatchesDomain(hostname, domain));
|
||||
} catch {
|
||||
const normalizedUrl = urlString.toLowerCase();
|
||||
return blacklistDomains.some(domain => normalizedUrl.includes(String(domain).toLowerCase()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isTabUrlFiltered } from './tab-filter.js';
|
||||
|
||||
const blacklist = ['google.com', 'mail.google.com'];
|
||||
const allowlist = ['drive.google.com'];
|
||||
|
||||
describe('tab URL filtering', () => {
|
||||
it('keeps Google Drive selectable despite the google.com parent-domain rule', () => {
|
||||
expect(isTabUrlFiltered(
|
||||
'https://drive.google.com/drive/u/1/search?q=mp4',
|
||||
blacklist,
|
||||
allowlist
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('continues to filter other Google subdomains', () => {
|
||||
expect(isTabUrlFiltered('https://mail.google.com/mail/u/0/', blacklist, allowlist)).toBe(true);
|
||||
expect(isTabUrlFiltered('https://www.google.com/search?q=video', blacklist, allowlist)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
(() => {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: light)');
|
||||
let mode = 'system';
|
||||
let palette = 'eucalyptus';
|
||||
|
||||
const normalize = value => ['system', 'dark', 'light'].includes(value) ? value : 'system';
|
||||
const normalizePalette = value => ['eucalyptus', 'cyber', 'graphite'].includes(value) ? value : 'eucalyptus';
|
||||
|
||||
const apply = value => {
|
||||
mode = normalize(value);
|
||||
const light = mode === 'light' || (mode === 'system' && systemTheme.matches);
|
||||
document.documentElement.classList.toggle('theme-light', light);
|
||||
document.documentElement.dataset.theme = light ? 'light' : 'dark';
|
||||
document.documentElement.style.colorScheme = light ? 'light' : 'dark';
|
||||
};
|
||||
|
||||
const applyPalette = value => {
|
||||
palette = normalizePalette(value);
|
||||
document.documentElement.dataset.palette = palette;
|
||||
};
|
||||
|
||||
apply('system');
|
||||
applyPalette('eucalyptus');
|
||||
window.koalaTheme = {
|
||||
getMode: () => mode,
|
||||
setMode: apply,
|
||||
getPalette: () => palette,
|
||||
setPalette: applyPalette
|
||||
};
|
||||
|
||||
systemTheme.addEventListener('change', () => {
|
||||
if (mode === 'system') apply('system');
|
||||
});
|
||||
|
||||
if (!globalThis.chrome?.storage?.local) return;
|
||||
chrome.storage.local.get(['themeMode', 'themePalette'], data => {
|
||||
apply(data.themeMode);
|
||||
applyPalette(data.themePalette);
|
||||
});
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area !== 'local') return;
|
||||
if (changes.themeMode) apply(changes.themeMode.newValue);
|
||||
if (changes.themePalette) applyPalette(changes.themePalette.newValue);
|
||||
});
|
||||
})();
|
||||
@@ -19,7 +19,7 @@ export function normalizeSendTabTitle(sendTabTitle, legacyMode = TITLE_PRIVACY_M
|
||||
|
||||
export function normalizeTabTitle(title) {
|
||||
if (typeof title !== 'string') return null;
|
||||
const normalized = title.replace(/^\s*(?:\(\d{1,2}\)|\[\d{1,2}\])\s+/, '').trim();
|
||||
const normalized = title.replace(/^\s*(?:\(\d+\+?\)|\[\d+\+?\])\s+/, '').trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
|
||||
Generated
+2334
-835
File diff suppressed because it is too large
Load Diff
+22
-8
@@ -1,25 +1,39 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "2.5.2",
|
||||
"version": "3.0.0",
|
||||
"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",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"subset-flags": "node website/tools/subset-flag-font.mjs",
|
||||
"test": "npm run verify",
|
||||
"test:unit": "vitest run",
|
||||
"verify": "node scripts/verify-release.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"archiver": "^7.0.1",
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"addons-linter": "^10.9.0",
|
||||
"archiver": "^8.0.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.4.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"sharp": "^0.34.5",
|
||||
"svgo": "^4.0.1",
|
||||
"vitest": "^4.1.9"
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-plugin-no-unsanitized": "^4.1.5",
|
||||
"fs-extra": "^11.4.0",
|
||||
"htmlnano": "^3.4.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
+78
-18
@@ -1,33 +1,93 @@
|
||||
# Development Scripts
|
||||
|
||||
This directory contains utility scripts for the KoalaSync development workflow.
|
||||
This directory contains build, synchronization, and verification scripts for the KoalaSync workspace. Run all commands from the repository root unless a script says otherwise.
|
||||
|
||||
## Main Commands
|
||||
|
||||
```bash
|
||||
npm run build:extension
|
||||
npm run verify
|
||||
npm run lint
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
- `npm run build:extension` runs `scripts/build-extension.cjs`.
|
||||
- `npm run verify` runs the full release-safety suite in `scripts/verify-release.mjs`.
|
||||
- `npm run lint` runs ESLint across the repository.
|
||||
- `npm run test:unit` runs Vitest tests.
|
||||
|
||||
## build-extension.cjs
|
||||
|
||||
The primary build tool for KoalaSync. This Node.js script automates two critical tasks:
|
||||
The primary extension build tool performs these steps:
|
||||
|
||||
1. **Protocol Synchronization**: Copies the "Single Source of Truth" constants (`shared/constants.js`) and the domain blacklist (`shared/blacklist.js`) from the root `/shared` directory into the `extension/shared/` directory.
|
||||
2. **Content Script Injection**: Injects protocol constants directly into `content.js` using marker-based replacement. This is necessary because `content.js` executes synchronously and cannot use ES module imports.
|
||||
3. **Artifact Generation**: Compiles the extension into browser-specific bundles for Chrome and Firefox, located in the `dist/` directory.
|
||||
|
||||
### Usage
|
||||
|
||||
From the **repository root**, run:
|
||||
1. Recreates `dist/`.
|
||||
2. Copies `shared/constants.js`, `shared/blacklist.js`, `shared/names.js`, and `shared/README.md` into `extension/shared/`.
|
||||
3. Injects synchronous shared values into `content.js`.
|
||||
4. Injects browser-specific uninstall URL constants into `background.js`.
|
||||
5. Injects the build timestamp into `popup.html`.
|
||||
6. Generates browser-specific manifests for Chrome and Firefox.
|
||||
7. Creates `dist/koalasync-chrome.zip` and `dist/koalasync-firefox.zip`.
|
||||
|
||||
Usage:
|
||||
```bash
|
||||
node scripts/build-extension.cjs
|
||||
# or
|
||||
npm run build:extension
|
||||
```
|
||||
|
||||
### Why this script exists
|
||||
KoalaSync uses **Vanilla JS** in the extension to maintain zero runtime dependencies and maximum privacy. Since we don't use a bundler (like Webpack or Vite) inside the extension, this script serves as our lightweight "pre-build" step to ensure that the protocol constants remain synchronized between the extension and the relay server.
|
||||
## Injection Markers
|
||||
|
||||
### Content Injection Markers
|
||||
The build script uses marker comments/placeholders. Missing markers are a hard build failure so release artifacts cannot silently contain stale protocol data.
|
||||
|
||||
The build script uses marker comments in `content.js` to locate and replace constant blocks:
|
||||
| Target | Marker / Placeholder | Injected Value | Source |
|
||||
|:---|:---|:---|:---|
|
||||
| `content.js` | `SHARED_EVENTS_INJECT_START` / `END` | Full `EVENTS` object | `shared/constants.js` |
|
||||
| `content.js` | `SHARED_HEARTBEAT_INJECT_START` / `END` | `HEARTBEAT_INTERVAL` | `shared/constants.js` |
|
||||
| `content.js` | `SHARED_EPISODE_UTILS_INJECT_START` / `END` | `extractEpisodeId()` and `sameEpisode()` | `extension/episode-utils.js` |
|
||||
| `background.js` | `UNINSTALL_URL_INJECT_START` / `END` | Uninstall URL and browser type | `scripts/build-extension.cjs` |
|
||||
| `popup.html` | `__BUILD_TIMESTAMP__` | UTC build timestamp | Build time |
|
||||
|
||||
| Marker Pair | Injected Value | Source |
|
||||
|:---|:---|:---|
|
||||
| `SHARED_EVENTS_INJECT_START` / `END` | The full `EVENTS` object | `shared/constants.js` |
|
||||
| `SHARED_HEARTBEAT_INJECT_START` / `END` | `HEARTBEAT_INTERVAL` value | `shared/constants.js` |
|
||||
Do not remove or rename these markers without updating the build script and tests.
|
||||
|
||||
> **⚠️ Do NOT remove or modify these marker comments in `content.js`.** They are required for the build script to function. If the markers are missing, the build will fail with a clear error message.
|
||||
## Verification Suite
|
||||
|
||||
`scripts/verify-release.mjs` is the best single command before release, PR review, or handoff:
|
||||
|
||||
```bash
|
||||
npm run verify
|
||||
```
|
||||
|
||||
It currently runs:
|
||||
|
||||
- Vitest unit tests.
|
||||
- Server ops, route, WebSocket, and rate-limiter checks.
|
||||
- Episode parser, title privacy, audio settings, popup cooldown, names, and content-video-finder checks.
|
||||
- JavaScript syntax checks for server and extension entry points.
|
||||
- Extension and website locale coverage checks.
|
||||
- ESLint.
|
||||
- Production `npm audit` checks for root and server dependencies.
|
||||
- Extension build and website build.
|
||||
|
||||
## Focused Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|:---|:---|
|
||||
| `test-server-ops.mjs` | Health payload and admin metrics helpers |
|
||||
| `test-server-routes.mjs` | HTTP health routes, caching, and admin metrics access |
|
||||
| `test-server-ws.mjs` | Socket.IO relay integration, including host-control behavior |
|
||||
| `test-rate-limiter.mjs` | Rate-limiter map and cooldown behavior |
|
||||
| `test-episode-utils.mjs` | Episode-title extraction and comparison |
|
||||
| `test-title-privacy.mjs` | Tab/media title privacy sanitization |
|
||||
| `test-audio-settings.mjs` | Audio settings defaults and normalization |
|
||||
| `test-popup-refresh-cooldown.mjs` | Popup refresh throttling behavior |
|
||||
| `test-names.mjs` | Generated username format and coverage |
|
||||
| `test-content-video-finder.cjs` | Content-script video selection helpers |
|
||||
| `test-locales.cjs` | Extension runtime and browser-store locale coverage |
|
||||
| `test-website-locales.mjs` | Website locale coverage |
|
||||
|
||||
## Do Not Break
|
||||
|
||||
- Keep scripts runnable from the repository root.
|
||||
- Keep build output under `dist/` and generated website output under `website/www/`.
|
||||
- Keep shared protocol sync automated; do not add manual copy steps.
|
||||
- Treat warnings in verification scripts as release blockers unless the script explicitly documents them as informational.
|
||||
|
||||
+85
-38
@@ -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,11 +83,18 @@ 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'));
|
||||
|
||||
function replaceRequiredBlock(content, pattern, replacement, description) {
|
||||
if (!pattern.test(content)) {
|
||||
throw new Error(`CRITICAL: ${description} markers not found. Aborting build to prevent stale artifacts.`);
|
||||
}
|
||||
return content.replace(pattern, replacement);
|
||||
}
|
||||
|
||||
// Helper to copy files, ignoring manifest.json and manifest.base.json
|
||||
// Also injects shared constants into content.js
|
||||
function copyExtensionFiles(targetDir, browserName) {
|
||||
@@ -79,11 +138,7 @@ function copyExtensionFiles(targetDir, browserName) {
|
||||
const ePattern = new RegExp(`${eStart}[\\s\\S]+?${eEnd}`);
|
||||
const eRep = `${eStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n const EVENTS = ${eventsObject};\n ${eEnd}`;
|
||||
|
||||
if (ePattern.test(content)) {
|
||||
content = content.replace(ePattern, eRep);
|
||||
} else {
|
||||
console.warn('⚠️ WARNING: Event markers not found in content.js');
|
||||
}
|
||||
content = replaceRequiredBlock(content, ePattern, eRep, 'Event injection');
|
||||
|
||||
// 2. Inject Heartbeat
|
||||
const hStart = '// --- SHARED_HEARTBEAT_INJECT_START ---';
|
||||
@@ -91,30 +146,23 @@ function copyExtensionFiles(targetDir, browserName) {
|
||||
const hPattern = new RegExp(`${hStart}[\\s\\S]+?${hEnd}`);
|
||||
const hRep = `${hStart}\n const HEARTBEAT_INTERVAL_VAL = ${heartbeatVal};\n ${hEnd}`;
|
||||
|
||||
if (hPattern.test(content)) {
|
||||
content = content.replace(hPattern, hRep);
|
||||
} else {
|
||||
console.warn('⚠️ WARNING: Heartbeat markers not found in content.js');
|
||||
}
|
||||
content = replaceRequiredBlock(content, hPattern, hRep, 'Heartbeat injection');
|
||||
|
||||
// 3. Inject Episode Utils
|
||||
const euStart = '// --- SHARED_EPISODE_UTILS_INJECT_START ---';
|
||||
const euEnd = '// --- SHARED_EPISODE_UTILS_INJECT_END ---';
|
||||
const euPattern = new RegExp(euStart.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\s\\S]+?' + euEnd.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
const euPath = path.join(rootDir, 'extension', 'episode-utils.js');
|
||||
if (fs.existsSync(euPath)) {
|
||||
const euContent = fs.readFileSync(euPath, 'utf8');
|
||||
const stripped = euContent
|
||||
.replace(/^\/\*\*[\s\S]*?\*\/\s*/m, '')
|
||||
.replace(/export function /g, 'function ')
|
||||
.trim();
|
||||
const euRep = `${euStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n${stripped.split('\n').map(l => ' ' + l).join('\n')}\n ${euEnd}`;
|
||||
if (euPattern.test(content)) {
|
||||
content = content.replace(euPattern, euRep);
|
||||
} else {
|
||||
console.warn('⚠ WARNING: Episode utils markers not found in content.js');
|
||||
}
|
||||
if (!fs.existsSync(euPath)) {
|
||||
throw new Error(`CRITICAL: Episode utils source missing: ${euPath}. Aborting build.`);
|
||||
}
|
||||
const euContent = fs.readFileSync(euPath, 'utf8');
|
||||
const stripped = euContent
|
||||
.replace(/^\/\*\*[\s\S]*?\*\/\s*/m, '')
|
||||
.replace(/export function /g, 'function ')
|
||||
.trim();
|
||||
const euRep = `${euStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n${stripped.split('\n').map(l => ' ' + l).join('\n')}\n ${euEnd}`;
|
||||
content = replaceRequiredBlock(content, euPattern, euRep, 'Episode utils injection');
|
||||
|
||||
fs.writeFileSync(destPath, content);
|
||||
console.log('✓ Injected shared constants into content.js');
|
||||
@@ -132,17 +180,16 @@ function copyExtensionFiles(targetDir, browserName) {
|
||||
uRep += ` const BROWSER_TYPE = "${browserName}";\n`;
|
||||
uRep += ` ${uEnd}`;
|
||||
|
||||
if (uPattern.test(content)) {
|
||||
content = content.replace(uPattern, uRep);
|
||||
} else {
|
||||
console.warn('⚠️ WARNING: Uninstall URL markers not found in background.js');
|
||||
}
|
||||
content = replaceRequiredBlock(content, uPattern, uRep, 'Uninstall URL injection');
|
||||
|
||||
fs.writeFileSync(destPath, content);
|
||||
console.log(`✓ Injected uninstall URL constants for ${browserName} into background.js`);
|
||||
} else if (item === 'popup.html') {
|
||||
let content = fs.readFileSync(srcPath, 'utf8');
|
||||
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
|
||||
if (!content.includes('__BUILD_TIMESTAMP__')) {
|
||||
throw new Error('CRITICAL: Build timestamp placeholder not found in popup.html. Aborting build.');
|
||||
}
|
||||
content = content.replace(/__BUILD_TIMESTAMP__/g, timestamp);
|
||||
fs.writeFileSync(destPath, content);
|
||||
console.log(`✓ Injected build timestamp into popup.html: ${timestamp}`);
|
||||
@@ -154,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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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', 'chatPosition', 'chatSize', 'chatStartMode']) {
|
||||
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 key of ['chatEnabled', 'chatPosition', 'chatSize', 'chatStartMode']) {
|
||||
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']) {
|
||||
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(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');
|
||||
|
||||
console.log('chat settings tests passed');
|
||||
@@ -98,14 +98,15 @@ function loadTimelineFns(hostname, document = makeDocument(), pageApiTime = null
|
||||
].join('\n'))({ location: { hostname } }, document);
|
||||
}
|
||||
|
||||
function loadPlayerFixFns(hostname) {
|
||||
function loadPlayerFixFns(hostname, href = `https://${hostname}/`) {
|
||||
return Function('window', [
|
||||
extractFunction('hostMatchesUrl', source),
|
||||
extractFunction('matchesPlayerUrls', source),
|
||||
extractFunction('isGoogleDrivePlayerFrame', source),
|
||||
extractFunction('getPlayerActionFixes', source),
|
||||
extractFunction('getActivePlayerActionFix', source),
|
||||
'return { getPlayerActionFixes, getActivePlayerActionFix };'
|
||||
].join('\n'))({ location: { hostname } });
|
||||
'return { isGoogleDrivePlayerFrame, getPlayerActionFixes, getActivePlayerActionFix };'
|
||||
].join('\n'))({ location: { hostname, href } });
|
||||
}
|
||||
|
||||
const disneyFns = loadTimelineFns('www.disneyplus.com', makeDocument(), {
|
||||
@@ -147,6 +148,28 @@ const twitchFixFns = loadPlayerFixFns('player.twitch.tv');
|
||||
assert.equal(twitchFixFns.getActivePlayerActionFix().name, 'twitch-player-buttons');
|
||||
assert.deepEqual(twitchFixFns.getActivePlayerActionFix().urls, ['twitch.tv']);
|
||||
|
||||
const googleDriveFixFns = loadPlayerFixFns(
|
||||
'youtube.googleapis.com',
|
||||
'https://youtube.googleapis.com/embed/?origin=https%3A%2F%2Fdrive.google.com'
|
||||
);
|
||||
assert.equal(googleDriveFixFns.getActivePlayerActionFix().name, 'google-drive-player-buttons');
|
||||
assert.deepEqual(googleDriveFixFns.getActivePlayerActionFix().urls, ['youtube.googleapis.com']);
|
||||
const unrelatedGoogleEmbedFixFns = loadPlayerFixFns(
|
||||
'youtube.googleapis.com',
|
||||
'https://youtube.googleapis.com/embed/?origin=https%3A%2F%2Fexample.com'
|
||||
);
|
||||
assert.equal(unrelatedGoogleEmbedFixFns.getActivePlayerActionFix(), null);
|
||||
const googleDrivePlayerFns = Function('window', [
|
||||
extractFunction('isGoogleDrivePlayerFrame', source),
|
||||
'return { isGoogleDrivePlayerFrame };'
|
||||
].join('\n'))({
|
||||
location: {
|
||||
hostname: 'youtube.googleapis.com',
|
||||
href: 'https://youtube.googleapis.com/embed/?origin=https%3A%2F%2Fdrive.google.com'
|
||||
}
|
||||
});
|
||||
assert.equal(googleDrivePlayerFns.isGoogleDrivePlayerFrame(), true);
|
||||
|
||||
const genericFixFns = loadPlayerFixFns('example.com');
|
||||
assert.equal(genericFixFns.getActivePlayerActionFix(), null);
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { cwd } from 'node:process';
|
||||
import {
|
||||
HOST_ACCESS_REQUIRED_STATUS,
|
||||
addTabHostAccessRequest,
|
||||
containsOriginPermission,
|
||||
describeTabUrl,
|
||||
inspectTabHostAccess,
|
||||
isHostAccessError,
|
||||
normalizeTabId,
|
||||
removeTabHostAccessRequest,
|
||||
requestOriginPermission
|
||||
} from '../extension/host-access.js';
|
||||
|
||||
assert.equal(HOST_ACCESS_REQUIRED_STATUS, 'host_permission_required');
|
||||
assert.equal(normalizeTabId(null), null);
|
||||
assert.equal(normalizeTabId(undefined), null);
|
||||
assert.equal(normalizeTabId(''), null);
|
||||
assert.equal(normalizeTabId(0), null);
|
||||
assert.equal(normalizeTabId('42'), 42);
|
||||
assert.equal(normalizeTabId(true), null);
|
||||
assert.equal(normalizeTabId([42]), null);
|
||||
assert.equal(normalizeTabId('42.5'), null);
|
||||
assert.equal(normalizeTabId(' 42 '), 42);
|
||||
assert.equal(normalizeTabId(Number.MAX_SAFE_INTEGER + 1), null);
|
||||
assert.deepEqual(describeTabUrl('https://emby.example:8443/web/index.html'), {
|
||||
url: 'https://emby.example:8443/web/index.html',
|
||||
host: 'emby.example:8443',
|
||||
originPattern: 'https://emby.example:8443/*'
|
||||
});
|
||||
assert.deepEqual(describeTabUrl('http://localhost:8096/web/'), {
|
||||
url: 'http://localhost:8096/web/',
|
||||
host: 'localhost:8096',
|
||||
originPattern: 'http://localhost:8096/*'
|
||||
});
|
||||
assert.deepEqual(describeTabUrl('http://localhost:8096/web/', { includePort: false }), {
|
||||
url: 'http://localhost:8096/web/',
|
||||
host: 'localhost:8096',
|
||||
originPattern: 'http://localhost/*'
|
||||
});
|
||||
assert.equal(describeTabUrl('chrome://extensions/'), null);
|
||||
assert.equal(describeTabUrl('not a url'), null);
|
||||
|
||||
let containsRequest = null;
|
||||
const deniedChrome = {
|
||||
tabs: {
|
||||
get: async tabId => ({ id: tabId, url: 'https://video.example/watch' })
|
||||
},
|
||||
permissions: {
|
||||
contains: async request => {
|
||||
containsRequest = request;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
const access = await inspectTabHostAccess(deniedChrome, 42);
|
||||
assert.equal(access.granted, false);
|
||||
assert.equal(access.host, 'video.example');
|
||||
assert.deepEqual(containsRequest, { origins: ['https://video.example/*'] });
|
||||
|
||||
let firefoxContainsRequest = null;
|
||||
const firefoxChrome = {
|
||||
runtime: { getBrowserInfo: async () => ({ name: 'Firefox' }) },
|
||||
tabs: {
|
||||
get: async tabId => ({
|
||||
id: tabId,
|
||||
url: 'http://localhost:8096/web/',
|
||||
pendingUrl: 'https://different.example/loading'
|
||||
})
|
||||
},
|
||||
permissions: {
|
||||
contains: async request => {
|
||||
firefoxContainsRequest = request;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
const firefoxAccess = await inspectTabHostAccess(firefoxChrome, 42);
|
||||
assert.equal(firefoxAccess.host, 'localhost:8096');
|
||||
assert.equal(firefoxAccess.originPattern, 'http://localhost/*');
|
||||
assert.deepEqual(firefoxContainsRequest, { origins: ['http://localhost/*'] });
|
||||
|
||||
const unknownPermissionChrome = {
|
||||
runtime: {},
|
||||
tabs: {
|
||||
get: async tabId => ({ id: tabId, url: 'https://video.example/watch' })
|
||||
},
|
||||
permissions: {
|
||||
contains: (_request, callback) => { callback(undefined); }
|
||||
}
|
||||
};
|
||||
assert.equal((await inspectTabHostAccess(unknownPermissionChrome, 42)).granted, null);
|
||||
|
||||
let requestedTabId = null;
|
||||
const requestChrome = {
|
||||
permissions: {
|
||||
addHostAccessRequest: async request => { requestedTabId = request; }
|
||||
}
|
||||
};
|
||||
assert.equal(await addTabHostAccessRequest(requestChrome, 42, 'https://video.example/*'), true);
|
||||
assert.deepEqual(requestedTabId, { tabId: 42, pattern: 'https://video.example/*' });
|
||||
assert.equal(await addTabHostAccessRequest({ permissions: {} }, 42), false);
|
||||
|
||||
let removedTabId = null;
|
||||
const removeRequestChrome = {
|
||||
permissions: {
|
||||
removeHostAccessRequest: async request => { removedTabId = request; }
|
||||
}
|
||||
};
|
||||
assert.equal(await removeTabHostAccessRequest(removeRequestChrome, 42, 'https://video.example/*'), true);
|
||||
assert.deepEqual(removedTabId, { tabId: 42, pattern: 'https://video.example/*' });
|
||||
assert.equal(await removeTabHostAccessRequest({ permissions: {} }, 42), false);
|
||||
|
||||
assert.equal(isHostAccessError(new Error('Missing host permission for the tab')), true);
|
||||
assert.equal(isHostAccessError(new Error('No tab with id: 42')), false);
|
||||
const callbackPermissionChrome = {
|
||||
runtime: {},
|
||||
permissions: {
|
||||
request: (_request, callback) => { callback(true); }
|
||||
}
|
||||
};
|
||||
assert.equal(await requestOriginPermission(callbackPermissionChrome, 'https://video.example/*'), true);
|
||||
assert.equal(await requestOriginPermission({ permissions: {} }, 'https://video.example/*'), null);
|
||||
let containedOriginRequest = null;
|
||||
assert.equal(await containsOriginPermission({
|
||||
permissions: {
|
||||
contains: async request => {
|
||||
containedOriginRequest = request;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}, 'https://youtube.googleapis.com/*'), true);
|
||||
assert.deepEqual(containedOriginRequest, { origins: ['https://youtube.googleapis.com/*'] });
|
||||
assert.equal(await containsOriginPermission({ permissions: {} }, 'https://youtube.googleapis.com/*'), null);
|
||||
|
||||
const background = fs.readFileSync(path.join(cwd(), 'extension', 'background.js'), 'utf8');
|
||||
const popup = fs.readFileSync(path.join(cwd(), 'extension', 'popup.js'), 'utf8');
|
||||
const popupHtml = fs.readFileSync(path.join(cwd(), 'extension', 'popup.html'), 'utf8');
|
||||
const tabManager = fs.readFileSync(path.join(cwd(), 'extension', 'modules', 'tab-manager.js'), 'utf8');
|
||||
|
||||
assert.match(background, /await activateTargetTab\(message\.tabId, message\.tabTitle\)/,
|
||||
'SET_TARGET_TAB must await successful activation before acknowledging it');
|
||||
assert.match(background, /addTabHostAccessRequest\(chrome, tabId, access\.originPattern\)/,
|
||||
'failed injection must register Chrome host-access request');
|
||||
assert.match(background, /retryPendingTarget\(\)/,
|
||||
'pending target must resume after the user grants access');
|
||||
assert.match(background, /activationGeneration !== targetActivationGeneration/,
|
||||
'stale concurrent tab activations must not overwrite the newest selection');
|
||||
assert.match(background, /pendingTargetRequestId/,
|
||||
'pending access recovery must use an identity token');
|
||||
assert.match(background, /addedOrigins\.includes\(pending\.originPattern\)/,
|
||||
'unrelated permission grants must not activate a pending target');
|
||||
assert.match(background, /isCurrentTargetIdentity\(tabId, targetGeneration\)/,
|
||||
'stale content-routing retries must not reactivate an old target');
|
||||
assert.match(background, /message\.expectedTabId/,
|
||||
'popup playback events must be rejected after their target changes');
|
||||
assert.match(background, /completeForceSyncBeforeTargetChange\(selectedTabId\)/,
|
||||
'a target switch must finish an in-flight force sync on the old target');
|
||||
assert.match(background, /FORCE_SYNC_ACK'[\s\S]*ignored_unselected_tab/,
|
||||
'stale content scripts must not acknowledge force sync for a new target');
|
||||
const activateTargetBody = background.slice(
|
||||
background.indexOf('async function activateTargetTab'),
|
||||
background.indexOf('async function retryPendingTarget')
|
||||
);
|
||||
assert.ok(
|
||||
activateTargetBody.indexOf('await injectContentScript') < activateTargetBody.indexOf('currentTabId = selectedTabId'),
|
||||
'a tab must not become current until its content script injection succeeds'
|
||||
);
|
||||
assert.match(background, /removeTabHostAccessRequest\([\s\S]*pendingTabId/,
|
||||
'clearing a pending target must also clear Chrome toolbar access requests');
|
||||
assert.match(popup, /response\?\.status === 'host_permission_required'/,
|
||||
'popup must render the structured host-access failure');
|
||||
assert.match(popup, /requestOriginPermission\(chrome, requestedOriginPattern\)/,
|
||||
'retry button must request withheld host access directly');
|
||||
assert.match(popup, /expectedCurrentTabId: tabId/,
|
||||
'manual reinjection must be tied to the selected target identity');
|
||||
assert.match(popup, /expectedTabId: tabId/,
|
||||
'force sync must be tied to the tab whose time was sampled');
|
||||
assert.doesNotMatch(tabManager, /injectContentScript/,
|
||||
'tab reload recovery must use the guarded background activation path');
|
||||
assert.equal(
|
||||
(background.match(/tabs\.onRemoved\.addListener/g) || []).length
|
||||
+ (tabManager.match(/tabs\.onRemoved\.addListener/g) || []).length,
|
||||
1,
|
||||
'target-tab closure must have exactly one state owner'
|
||||
);
|
||||
assert.match(popupHtml, /id="siteAccessNotice"/,
|
||||
'popup must contain a persistent site-access notice');
|
||||
|
||||
console.log('host access recovery tests passed');
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -18,7 +18,12 @@ assert.equal(normalizeSendTabTitle(true, TITLE_PRIVACY_MODES.HIDDEN), true);
|
||||
assert.equal(normalizeSendTabTitle(false, TITLE_PRIVACY_MODES.FULL), false);
|
||||
assert.equal(normalizeTabTitle('(12) Testvideo - YouTube'), 'Testvideo - YouTube');
|
||||
assert.equal(normalizeTabTitle('[7] Testvideo - YouTube'), 'Testvideo - YouTube');
|
||||
assert.equal(normalizeTabTitle('(500) Days of Summer'), '(500) Days of Summer');
|
||||
assert.equal(normalizeTabTitle('(99+) Testvideo - YouTube'), 'Testvideo - YouTube');
|
||||
assert.equal(normalizeTabTitle('(999+) Testvideo - YouTube'), 'Testvideo - YouTube');
|
||||
assert.equal(normalizeTabTitle('[999+] Testvideo - YouTube'), 'Testvideo - YouTube');
|
||||
assert.equal(normalizeTabTitle('(500) Days of Summer'), 'Days of Summer');
|
||||
assert.equal(normalizeTabTitle('(101) Days of Summer'), 'Days of Summer');
|
||||
assert.equal(normalizeTabTitle('[101] Days of Summer'), 'Days of Summer');
|
||||
assert.equal(normalizeTabTitle(' '), null);
|
||||
|
||||
assert.equal(sanitizeTabTitle('Private Tab', true), 'Private Tab');
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const template = fs.readFileSync(path.join(repoRoot, 'website', 'template.html'), 'utf8');
|
||||
const app = fs.readFileSync(path.join(repoRoot, 'website', 'app.js'), 'utf8');
|
||||
const langInit = fs.readFileSync(path.join(repoRoot, 'website', 'lang-init.js'), 'utf8');
|
||||
const demoCss = fs.readFileSync(path.join(repoRoot, 'website', 'styles', 'demo.css'), 'utf8');
|
||||
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 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 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);
|
||||
|
||||
if (mockupStart === -1 || mockupEnd === -1) {
|
||||
throw new Error('Could not locate the extension mockup in website/template.html');
|
||||
}
|
||||
|
||||
const mockup = template.slice(mockupStart, mockupEnd);
|
||||
const themeSensitiveControls = [
|
||||
['video selector', /<select[^>]+id="demo-video-select"[^>]+>/],
|
||||
['sync target', /<div[^>]+class="mock-input"[^>]+title="Choose sync target"[^>]*>/],
|
||||
['WebSocket status', /<span style="[^"]*flex:1;[^"]*">\s*\{\{MOCK_27\}\}/]
|
||||
];
|
||||
|
||||
for (const [label, pattern] of themeSensitiveControls) {
|
||||
const match = mockup.match(pattern);
|
||||
if (!match) throw new Error(`Could not locate ${label} in the extension mockup`);
|
||||
if (!/color:\s*var\(--text\)/.test(match[0])) {
|
||||
throw new Error(`${label} must use var(--text) so it remains readable in both themes`);
|
||||
}
|
||||
}
|
||||
|
||||
const landingStylesheets = [...template.matchAll(/<link\b[^>]*\brel="stylesheet"[^>]*\bhref="\{\{ASSET_PATH\}\}landing[^">]*"[^>]*>/g)];
|
||||
if (landingStylesheets.length !== 1) {
|
||||
throw new Error(`Landing must load exactly one render-blocking stylesheet; found ${landingStylesheets.length}`);
|
||||
}
|
||||
|
||||
const llmsLinks = [...template.matchAll(/<link\b[^>]*\brel="alternate"[^>]*\btype="text\/markdown"[^>]*\bhref="https:\/\/sync\.koalastuff\.net\/llms\.txt"[^>]*>/g)];
|
||||
if (llmsLinks.length !== 1) {
|
||||
throw new Error(`Landing head must link exactly one canonical llms.txt document; found ${llmsLinks.length}`);
|
||||
}
|
||||
|
||||
const requiredLlmsSections = [
|
||||
'## Quick fit assessment',
|
||||
'## How it works',
|
||||
'## Privacy and security',
|
||||
'## Supported environments',
|
||||
'## Install KoalaSync',
|
||||
'## Technical information'
|
||||
];
|
||||
for (const section of requiredLlmsSections) {
|
||||
if (!llmsText.includes(section)) throw new Error(`llms.txt is missing ${section}`);
|
||||
}
|
||||
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(`Current website release: ${websiteVersion}`)) {
|
||||
throw new Error(`llms.txt release must match website/version.json (${websiteVersion})`);
|
||||
}
|
||||
const requiredSupportSocialMetadata = [
|
||||
'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}`);
|
||||
}
|
||||
}
|
||||
if (!websiteBuild.includes("['log', '-1', '--format=%cs', '--', ...sourceFiles]")
|
||||
|| !websiteBuild.includes("lastmod(['website/site-access-help.html'])")
|
||||
|| !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');
|
||||
}
|
||||
const documentedRelayUrls = [...llmsText.matchAll(/`(wss:\/\/[^`\s]+)`/g)].map(([, value]) => new URL(value));
|
||||
const hasCanonicalPublicRelay = documentedRelayUrls.some((url) => (
|
||||
url.protocol === 'wss:' &&
|
||||
url.hostname === 'syncserver.koalastuff.net' &&
|
||||
url.port === '' &&
|
||||
url.pathname === '/' &&
|
||||
url.search === '' &&
|
||||
url.hash === ''
|
||||
));
|
||||
if (!hasCanonicalPublicRelay || !llmsText.includes('internally on port 3000')) {
|
||||
throw new Error('llms.txt must distinguish the public TLS relay URL from the internal self-hosting port');
|
||||
}
|
||||
|
||||
const selfHostingStart = template.indexOf('<div id="pane-docker"');
|
||||
const selfHostingEnd = template.indexOf('</section>', selfHostingStart);
|
||||
if (selfHostingStart === -1 || selfHostingEnd === -1) {
|
||||
throw new Error('Could not locate the self-hosting examples in website/template.html');
|
||||
}
|
||||
const selfHostingExamples = template.slice(selfHostingStart, selfHostingEnd);
|
||||
const requiredSelfHostingValues = [
|
||||
'SERVER_SALT:',
|
||||
'REPLACE_WITH_OPENSSL_RAND_BASE64_32_OUTPUT',
|
||||
'ADMIN_METRICS_TOKEN:',
|
||||
'DEBUG_LOGGING:',
|
||||
'127.0.0.1:3000:3000'
|
||||
];
|
||||
for (const value of requiredSelfHostingValues) {
|
||||
if (!selfHostingExamples.includes(value)) {
|
||||
throw new Error(`Website self-hosting compose example is missing ${value}`);
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
if (/\bmedia=|data-(?:landing-)?deferred/.test(landingStylesheet)) {
|
||||
throw new Error('Landing stylesheet must not be activated after first paint');
|
||||
}
|
||||
|
||||
const runtimeCssActivation = [
|
||||
['app.js', app],
|
||||
['lang-init.js', langInit]
|
||||
];
|
||||
for (const [file, source] of runtimeCssActivation) {
|
||||
if (/createElement\(['"]link['"]\)|requestIdleCallback|data-landing-deferred|data-deferred-css|\.media\s*=\s*['"]all['"]/.test(source)) {
|
||||
throw new Error(`${file} must not dynamically load or activate structural CSS`);
|
||||
}
|
||||
}
|
||||
|
||||
// Film birds were removed entirely (commit 16cb138); guard against remnants
|
||||
if (/film-hero-bird|film-bird-wing|film-birds/.test(template) || /filmBirdGlide|filmWingFlap/.test(demoCss)) {
|
||||
throw new Error('Film birds were removed from the demo mockups; no bird markup or animations may remain');
|
||||
}
|
||||
|
||||
const gettingStartedLightSurfaces = [
|
||||
'step-illustration-1',
|
||||
'step-illustration-2',
|
||||
'step-illustration-3',
|
||||
'illus-popup-card',
|
||||
'illus-player-card',
|
||||
'popup-select-mock'
|
||||
];
|
||||
for (const className of gettingStartedLightSurfaces) {
|
||||
const pattern = new RegExp(`html\\.theme-light[^{]*\\.${className}[^}]*\\{`);
|
||||
if (!pattern.test(landingPrimaryCss)) {
|
||||
throw new Error(`Getting Started .${className} must define an explicit light-theme surface`);
|
||||
}
|
||||
}
|
||||
|
||||
const stepNumberLightRule = landingPrimaryCss.match(/html\.theme-light \.step-num\s*\{([^}]*)\}/)?.[1] || '';
|
||||
if (!/color:\s*oklch\(/.test(stepNumberLightRule)
|
||||
|| !/background:\s*none/.test(stepNumberLightRule)
|
||||
|| !/-webkit-text-fill-color:\s*currentColor/.test(stepNumberLightRule)) {
|
||||
throw new Error('Getting Started step numbers must use solid readable text in light mode');
|
||||
}
|
||||
|
||||
if (!/class="footer-disclaimer"/.test(joinPage) || /footer-disclaimer[^>]*opacity:/.test(joinPage)) {
|
||||
throw new Error('Join-page disclaimer must use the contrast-safe footer-disclaimer colors without opacity');
|
||||
}
|
||||
|
||||
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 (!/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`);
|
||||
}
|
||||
}
|
||||
|
||||
if (/<h3\b/.test(alternativesIndex)
|
||||
|| (alternativesIndex.match(/<h2\b/g) || []).length !== 6
|
||||
|| (alternativesIndex.match(/<h2[^>]+color:\s*var\(--text\)/g) || []).length !== 6) {
|
||||
throw new Error('Alternatives overview cards must use h2 headings directly below the page h1');
|
||||
}
|
||||
if (!/html\.theme-light \.guide-card\s*\{[^}]*background-color:\s*var\(--card-surface\)\s*!important/s.test(alternativesCss)) {
|
||||
throw new Error('Alternatives cards must define a readable light-theme surface');
|
||||
}
|
||||
|
||||
const reducedMotionRule = demoCss.match(/@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.step-illustration-3 \.film-mtn-scroll,\s*\.step-illustration-3 \.film-mid-scroll,\s*\.step-illustration-3 \.film-fore-scroll\s*\{([^}]*)\}/)?.[1] || '';
|
||||
if (!/animation:\s*none\s*!important/.test(reducedMotionRule)) {
|
||||
throw new Error('Getting Started film layers must stop when reduced motion is requested');
|
||||
}
|
||||
|
||||
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');
|
||||
console.log('Getting Started step numbers stay readable in light mode');
|
||||
console.log('Accessibility regressions stay fixed across legal, alternatives, and reduced-motion views');
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* KoalaSync Locale Translation Script
|
||||
*
|
||||
* This script is stored in `scripts/translate-locales-tool.cjs`
|
||||
* so future LLM agents can easily reuse and adapt it to translate new keys.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/translate-locales-tool.cjs
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('https');
|
||||
|
||||
const localesDir = path.join(__dirname, '../website/locales');
|
||||
const enFile = path.join(localesDir, 'en.json');
|
||||
const enLocale = JSON.parse(fs.readFileSync(enFile, 'utf8'));
|
||||
|
||||
// Keys starting with these prefixes need to be translated.
|
||||
// Update this list when translating new pages or features.
|
||||
const prefixes = ['ALT_W2G_', 'ALT_SCENER_', 'ALT_KOSMI_', 'ALT_TWOSEVEN_'];
|
||||
|
||||
const keysToTranslate = Object.keys(enLocale).filter(key => {
|
||||
return prefixes.some(prefix => key.startsWith(prefix));
|
||||
});
|
||||
|
||||
console.log(`Found ${keysToTranslate.length} keys to translate.`);
|
||||
|
||||
function translate(text, targetLang) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// We use Google's free translation endpoint which is highly robust for standard text segment translations
|
||||
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=en&tl=${targetLang}&dt=t&q=${encodeURIComponent(text)}`;
|
||||
http.get(url, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const translation = parsed[0].map(item => item[0]).join('');
|
||||
resolve(translation);
|
||||
} catch (_e) {
|
||||
reject(new Error(`Failed to parse response for target ${targetLang}: ${data}`));
|
||||
}
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
const fileToLangMap = {
|
||||
'es.json': 'es',
|
||||
'fr.json': 'fr',
|
||||
'it.json': 'it',
|
||||
'ja.json': 'ja',
|
||||
'ko.json': 'ko',
|
||||
'nl.json': 'nl',
|
||||
'pl.json': 'pl',
|
||||
'pt-BR.json': 'pt',
|
||||
'pt.json': 'pt',
|
||||
'ru.json': 'ru',
|
||||
'tr.json': 'tr',
|
||||
'uk.json': 'uk',
|
||||
'zh.json': 'zh-CN'
|
||||
};
|
||||
|
||||
async function run() {
|
||||
const files = fs.readdirSync(localesDir).filter(f => f.endsWith('.json') && f !== 'en.json' && f !== 'de.json');
|
||||
|
||||
for (const file of files) {
|
||||
const targetLang = fileToLangMap[file];
|
||||
if (!targetLang) {
|
||||
console.log(`Skipping unknown file mapping: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(localesDir, file);
|
||||
const content = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
|
||||
console.log(`\nTranslating ${file} (${targetLang})...`);
|
||||
let translatedCount = 0;
|
||||
|
||||
for (const key of keysToTranslate) {
|
||||
const englishValue = enLocale[key];
|
||||
// Translate if missing or currently identical to English value (which means it's a fallback)
|
||||
if (!content[key] || content[key] === englishValue) {
|
||||
try {
|
||||
const translatedValue = await translate(englishValue, targetLang);
|
||||
content[key] = translatedValue;
|
||||
translatedCount++;
|
||||
console.log(` [${translatedCount}/${keysToTranslate.length}] Translated key: ${key}`);
|
||||
await sleep(200); // 200ms delay to prevent rate limits
|
||||
} catch (err) {
|
||||
console.error(` Error translating key ${key} to ${targetLang}:`, err.message);
|
||||
// Wait longer on error
|
||||
await sleep(2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save back keeping exact same structure and format
|
||||
fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n', 'utf8');
|
||||
console.log(`Saved ${file} (${translatedCount} keys updated).`);
|
||||
}
|
||||
|
||||
console.log('\nAll translations completed successfully!');
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('Translation script failed:', err);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const localesDir = path.join(__dirname, '../website/locales');
|
||||
const files = fs.readdirSync(localesDir).filter(f => f.endsWith('.json') && f !== 'en.json');
|
||||
|
||||
const brandTranslations = [
|
||||
'dos siete', 'dos-siete', 'deux sept', 'deux-sept', 'zwei sieben', 'zwei-sieben',
|
||||
'due sette', 'due-sette', 'dwa siedem', 'dwa-siedem', 'twee zeven', 'twee-zeven',
|
||||
'iki yedi', 'iki-yedi', 'два семь', 'два-семь', '二七', '二 七', '이칠', '이 칠',
|
||||
'koala sync', 'koala-sync', 'watch 2 gether', 'watch-2-gether', 'watch 2gether',
|
||||
'watch-2gether', 'jelly fin', 'jelly-fin'
|
||||
];
|
||||
|
||||
let failed = false;
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(localesDir, file);
|
||||
const content = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
|
||||
for (const [key, value] of Object.entries(content)) {
|
||||
if (typeof value !== 'string') continue;
|
||||
|
||||
// Check brand translations (case-insensitive)
|
||||
const lowerValue = value.toLowerCase();
|
||||
for (const pattern of brandTranslations) {
|
||||
if (lowerValue.includes(pattern)) {
|
||||
console.error(`Error in ${file} [key: ${key}]: Found translated brand pattern "${pattern}" in value: "${value}"`);
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check tag validation
|
||||
const tags = ['strong', 'em', 'span', 'b', 'i', 'p', 'a'];
|
||||
for (const tag of tags) {
|
||||
const openCount = (value.match(new RegExp(`<${tag}\\b[^>]*>`, 'g')) || []).length;
|
||||
const closeCount = (value.match(new RegExp(`</${tag}\\b[^>]*>`, 'g')) || []).length;
|
||||
if (openCount !== closeCount) {
|
||||
console.error(`Error in ${file} [key: ${key}]: Unbalanced tag <${tag}> (open: ${openCount}, close: ${closeCount}) in value: "${value}"`);
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!failed) {
|
||||
console.log("All brand names and HTML tags are perfectly valid and intact across all languages!");
|
||||
} else {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -20,6 +20,8 @@ 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']],
|
||||
['server syntax rate-limiter', 'node', ['-c', 'server/rate-limiter.js']],
|
||||
@@ -28,10 +30,15 @@ const checks = [
|
||||
['background syntax', 'node', ['-c', 'extension/background.js']],
|
||||
['locale coverage', 'node', ['scripts/test-locales.cjs']],
|
||||
['website locale coverage', 'node', ['scripts/test-website-locales.mjs']],
|
||||
['website theme coverage', 'node', ['scripts/test-website-theme.mjs']],
|
||||
['lint', 'npm', ['run', 'lint']],
|
||||
['root production audit', 'npm', ['audit', '--omit=dev']],
|
||||
['server production audit', 'npm', ['audit', '--omit=dev'], { cwd: path.join(repoRoot, 'server') }],
|
||||
['extension build', 'npm', ['run', 'build:extension']],
|
||||
// Must run after the build: validates the exact artifact uploaded to AMO.
|
||||
// --warnings-as-errors is required: addons-linter exits 0 on warnings, and AMO
|
||||
// rejects on them, so without it this check would pass a rejectable build.
|
||||
['AMO validation (firefox)', 'npx', ['addons-linter', '--warnings-as-errors', 'dist/koalasync-firefox.zip']],
|
||||
['website build', 'node', ['website/build.cjs']]
|
||||
];
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user