Compare commits
182 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 869c64171b | |||
| 6ccaf45f8e | |||
| 1b7be23b29 | |||
| 9ec0e9dd4e | |||
| 9ac8c28442 | |||
| 076157fef1 | |||
| 236da46f5d | |||
| 2fbcbc4f83 | |||
| 8fe1c6dfbc | |||
| 51da169d69 | |||
| a9f5c0b9bf | |||
| 705fbc146b | |||
| 09d35f3e48 | |||
| 32994aaaa5 | |||
| bea0f6925c | |||
| 71fa6e7315 | |||
| 54b806d270 | |||
| 066d2c9407 | |||
| ce522e2ab7 | |||
| 9cbeb661d6 | |||
| beda924b65 | |||
| 01762b0c2f | |||
| 0af22998c8 | |||
| 52265e84eb | |||
| 57cd071d58 | |||
| 8a21fbe07f | |||
| 7781d418ba | |||
| c022f6f490 | |||
| 9fe83f89b2 | |||
| e51e8ae436 | |||
| e74abd00f7 | |||
| dab7368537 | |||
| 463f871958 | |||
| 1f5196e1e6 |
@@ -2,7 +2,7 @@
|
||||
name: 🚀 Feature Request
|
||||
about: Suggest a new feature or enhancement for KoalaSync
|
||||
title: ''
|
||||
labels: enhancement
|
||||
labels: feature
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
@@ -114,17 +114,13 @@ jobs:
|
||||
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 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:
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.0-blue?logo=github" alt="GitHub release"></a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v2.6.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.0 Release!</b> — See what's changed</a></p>
|
||||
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.6.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`).
|
||||
|
||||
@@ -131,8 +136,10 @@ gh attestation verify dist/koalasync-chrome.zip \
|
||||
- **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)**: Our community standards and expectations.
|
||||
- **[HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md)**: Step-by-step walkthrough of the complete user flow.
|
||||
- **[ARCHITECTURE.md](docs/ARCHITECTURE.md)**: Deep-dive into the two-phase sync and heartbeat logic.
|
||||
- **[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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4,10 +4,64 @@ All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## Unreleased
|
||||
## [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 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.
|
||||
|
||||
---
|
||||
|
||||
## [v2.5.2] — 2026-07-02
|
||||
|
||||
### Added
|
||||
- **Extension: Privacy title controls** - Advanced users can now disable sending browser tab titles separately from media titles. Media titles can still be sent in full, reduced to detected episode identifiers such as `S01E04`, or hidden entirely. Defaults remain full titles for backwards compatibility.
|
||||
- **Relay: Cleaner restart handling** — Connected clients are now disconnected explicitly during relay shutdown so reconnects recover more predictably.
|
||||
- **Relay: Stronger abuse protection** — Rapid room-leave spam is now rate-limited.
|
||||
- **Extension: Hidden remote seek diagnostics** — KoalaDev can use the hidden Dev tab to simulate remote seeks and inspect precise native/page-API timing while debugging playback integrations.
|
||||
|
||||
### Changed
|
||||
- **Extension: Shared page-API seek bridge** — Netflix and Disney+ now use a common page-level seek bridge so private player APIs can be invoked from the page context while the default HTML5 path stays unchanged.
|
||||
- **Build: Release build timestamp** — Extension builds now inject a build timestamp into the hidden Dev tab for easier local package verification.
|
||||
|
||||
### Fixed
|
||||
- **Extension: Disney+ precise sync** — Disney+ now reads time and seeks through the real page media-player API, and the temporary DOM timeline/button scraping fallback has been removed.
|
||||
- **Extension: Netflix seek reliability** — Netflix seeking keeps using the page player API with a safer session lookup path.
|
||||
- **Extension: Tab-title counter cleanup** — Leading browser notification counters such as `(14)` or `[7]` are removed from shared tab titles and matching logic without changing the existing privacy controls.
|
||||
- **Extension: Tab navigation reinjection** — Reinjecting the content script after selected-tab navigation now uses the same page-API-aware injection path.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
# WebSocket Protocol Reference
|
||||
|
||||
This document describes the relay behavior implemented by `server/index.js` and
|
||||
the event names defined in `shared/constants.js`.
|
||||
|
||||
## Transport
|
||||
|
||||
- The relay uses Socket.IO v4 events over WebSocket.
|
||||
- Long-polling is disabled (`transports: ['websocket']`, `allowUpgrades: false`).
|
||||
- Messages are Socket.IO event packets whose payload is an event name plus an
|
||||
object payload.
|
||||
- The relay caps incoming Socket.IO message size at 4 KB.
|
||||
|
||||
## Connection Handshake
|
||||
|
||||
The Socket.IO handshake must include:
|
||||
|
||||
- `token`: must match `OFFICIAL_SERVER_TOKEN`.
|
||||
- `version`: optional app version. If present, it must be a valid semver-like
|
||||
string and not older than `MIN_VERSION` (default `1.0.0`).
|
||||
|
||||
If the token is invalid, the relay emits `error` and disconnects the socket.
|
||||
If `version` is invalid or too old, the relay emits `error` and disconnects the
|
||||
socket.
|
||||
|
||||
After the socket is connected, `join_room` must include `protocolVersion`.
|
||||
It must equal `PROTOCOL_VERSION` exactly. A mismatch emits `error` and rejects
|
||||
the join attempt; it does not currently disconnect the socket.
|
||||
|
||||
## Room Join
|
||||
|
||||
### `join_room` (client -> server)
|
||||
|
||||
Payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"roomId": "string, sanitized to [A-Za-z0-9-], max 64",
|
||||
"peerId": "string, max 16",
|
||||
"username": "string, max 30",
|
||||
"password": "string, max 128, optional",
|
||||
"tabTitle": "string, max 100, optional",
|
||||
"mediaTitle": "string, max 100, optional",
|
||||
"protocolVersion": "string, max 16"
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- Creates the room if it does not exist and capacity allows it.
|
||||
- The first peer becomes `hostPeerId`.
|
||||
- Rooms may have an optional password hash.
|
||||
- Joining with a duplicate `peerId` disconnects the previous socket for that peer.
|
||||
- Joining the same room with the same socket and peer is ignored as a no-op.
|
||||
- Switching rooms removes the socket from the old room first.
|
||||
|
||||
On success, the joining socket receives `room_data`.
|
||||
Other room members receive `peer_status` with `status: "joined"`.
|
||||
|
||||
### `room_data` (server -> client)
|
||||
|
||||
Payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"roomId": "string",
|
||||
"peers": ["peer state objects"],
|
||||
"activeLobby": "object or null",
|
||||
"hostPeerId": "string or null",
|
||||
"controlMode": "everyone | host-only",
|
||||
"controllers": ["peerId"],
|
||||
"capabilities": ["host-control", "co-host"]
|
||||
}
|
||||
```
|
||||
|
||||
`room_data` is sent to the joining socket. It is not the general broadcast used
|
||||
for every later room update.
|
||||
|
||||
## Room Leave
|
||||
|
||||
### `leave_room` (client -> server)
|
||||
|
||||
Payload: none.
|
||||
|
||||
Behavior:
|
||||
|
||||
- Rate-limited to 10 events per socket per minute.
|
||||
- If the socket is mapped to a room, the relay removes it from that room.
|
||||
- Remaining room members receive `peer_status` with `status: "left"` when the
|
||||
peer is no longer represented by another socket.
|
||||
- Empty rooms are deleted.
|
||||
- If the host leaves and peers remain, the relay assigns the next peer as host,
|
||||
falls back to `controlMode: "everyone"`, resets controllers to the new host,
|
||||
and broadcasts `control_mode`.
|
||||
|
||||
Exceeding the `leave_room` limit is logged and the socket is disconnected.
|
||||
|
||||
## Relayed Room Events
|
||||
|
||||
The relay accepts and sanitizes these events, then emits the same event to other
|
||||
peers in the room:
|
||||
|
||||
- `play`
|
||||
- `pause`
|
||||
- `seek`
|
||||
- `peer_status`
|
||||
- `force_sync_prepare`
|
||||
- `force_sync_ack`
|
||||
- `force_sync_execute`
|
||||
- `episode_lobby`
|
||||
- `episode_ready`
|
||||
- `episode_lobby_cancel`
|
||||
|
||||
Relayed payload fields are sanitized and may include:
|
||||
|
||||
```json
|
||||
{
|
||||
"senderId": "peerId of sender",
|
||||
"seq": "number",
|
||||
"currentTime": "number 0..86400 or null",
|
||||
"targetTime": "number 0..86400",
|
||||
"playbackState": "playing | paused",
|
||||
"username": "string, max 30",
|
||||
"tabTitle": "string, max 100 or null",
|
||||
"mediaTitle": "string, max 100 or null",
|
||||
"volume": "number 0..1",
|
||||
"muted": "boolean",
|
||||
"desynced": "boolean",
|
||||
"peerId": "sender peerId",
|
||||
"status": "string, max 16",
|
||||
"expectedTitle": "string, max 100",
|
||||
"title": "string, max 100",
|
||||
"actionTimestamp": "number"
|
||||
}
|
||||
```
|
||||
|
||||
Undefined fields are removed before relay. Raw client payloads are not forwarded.
|
||||
|
||||
## Media Control
|
||||
|
||||
### `play`, `pause`, `seek`
|
||||
|
||||
These are room-moving actions. In `host-only` mode, the relay drops them unless
|
||||
the sender is a controller.
|
||||
|
||||
Common payload fields:
|
||||
|
||||
- `currentTime` for `play`/`pause`.
|
||||
- `targetTime` for `seek`.
|
||||
- `seq` and `actionTimestamp` when the extension needs stale-command or ACK
|
||||
handling.
|
||||
|
||||
The content script applies additional client-side filtering for noisy native
|
||||
player events before it sends these events.
|
||||
|
||||
## Peer Status
|
||||
|
||||
### `peer_status`
|
||||
|
||||
Used for heartbeats and peer state updates. The extension sends it every
|
||||
`HEARTBEAT_INTERVAL` while syncing is active.
|
||||
|
||||
Typical fields:
|
||||
|
||||
- `peerId`
|
||||
- `username`
|
||||
- `tabTitle`
|
||||
- `mediaTitle`
|
||||
- `playbackState`
|
||||
- `currentTime`
|
||||
- `volume`
|
||||
- `muted`
|
||||
- `desynced`
|
||||
- `status`
|
||||
|
||||
The relay stores sanitized peer state and relays the sanitized update to other
|
||||
peers.
|
||||
|
||||
## Force Sync
|
||||
|
||||
Force sync coordination is implemented primarily in the extension. The relay
|
||||
sanitizes and relays the events.
|
||||
|
||||
### `force_sync_prepare`
|
||||
|
||||
Payload includes `targetTime`. The initiator waits for ACKs or for
|
||||
`FORCE_SYNC_TIMEOUT` before sending `force_sync_execute`.
|
||||
|
||||
In `host-only` mode, only controllers may initiate it.
|
||||
|
||||
### `force_sync_ack`
|
||||
|
||||
The extension sends ACKs with peer identity and sequence data. The relay relays
|
||||
them with the same sanitized relay envelope as other room events, including
|
||||
`senderId`.
|
||||
|
||||
### `force_sync_execute`
|
||||
|
||||
Payload includes `targetTime`. In `host-only` mode, only controllers may send it.
|
||||
The relay also allows a matching initiator's execute event after that initiator
|
||||
started the prepare step, even if their controller state changed before execute.
|
||||
|
||||
## Episode Lobby
|
||||
|
||||
Episode lobby coordination is implemented primarily in the extension. The relay
|
||||
tracks enough state to include `activeLobby` in `room_data` for later joiners.
|
||||
|
||||
### `episode_lobby`
|
||||
|
||||
Payload uses `expectedTitle`. The relay creates `activeLobby` when this field is
|
||||
present and no lobby is already active.
|
||||
|
||||
In `host-only` mode, only controllers may initiate it.
|
||||
|
||||
### `episode_ready`
|
||||
|
||||
Payload may include `title`. The relay adds the sender to the active lobby's
|
||||
ready list when a lobby exists.
|
||||
|
||||
### `episode_lobby_cancel`
|
||||
|
||||
Clears the active lobby and is relayed to peers. In `host-only` mode, only
|
||||
controllers may initiate it.
|
||||
|
||||
## Host Control Mode
|
||||
|
||||
### `set_control_mode` (client -> server)
|
||||
|
||||
Payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"controlMode": "everyone | host-only"
|
||||
}
|
||||
```
|
||||
|
||||
Only the room host may change the mode. Non-host attempts are ignored and the
|
||||
sender receives the current `control_mode` snapshot.
|
||||
|
||||
Mode changes are debounced per room with `CONTROL_MODE_MIN_INTERVAL_MS` (500 ms).
|
||||
|
||||
### `set_peer_role` (client -> server)
|
||||
|
||||
Payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"peerId": "string, max 16",
|
||||
"controller": "boolean"
|
||||
}
|
||||
```
|
||||
|
||||
Only the room host may promote or demote controllers. The host cannot demote
|
||||
themself. Role changes use the same 500 ms per-room debounce as mode changes.
|
||||
|
||||
### `control_mode` (server -> client)
|
||||
|
||||
Payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"controlMode": "everyone | host-only",
|
||||
"hostPeerId": "string or null",
|
||||
"controllers": ["peerId"]
|
||||
}
|
||||
```
|
||||
|
||||
Sent when mode or controller state changes, when host migration changes room
|
||||
authority, and when unauthorized role/mode attempts need to resync the sender.
|
||||
|
||||
## Room List
|
||||
|
||||
### `get_rooms` (client -> server)
|
||||
|
||||
Payload: none.
|
||||
|
||||
No admin token is required for this Socket.IO event.
|
||||
|
||||
Limits:
|
||||
|
||||
- Counts against the per-socket event limit.
|
||||
- Also has a 10 second per-socket cooldown.
|
||||
|
||||
### `room_list` (server -> client)
|
||||
|
||||
Payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"rooms": [
|
||||
{
|
||||
"id": "room id",
|
||||
"peerCount": 2,
|
||||
"hasPassword": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Ping, Pong, and ACK
|
||||
|
||||
### `ping`
|
||||
|
||||
Payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"t": 1234567890,
|
||||
"target": "peerId, optional"
|
||||
}
|
||||
```
|
||||
|
||||
If `target` is omitted, the relay responds to the sender with `pong`.
|
||||
If `target` is another peer in the same room, the relay sends `ping` to that peer
|
||||
with `{ "t": ..., "sender": "senderPeerId" }`.
|
||||
|
||||
### `pong`
|
||||
|
||||
Payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"t": 1234567890,
|
||||
"target": "peerId, optional"
|
||||
}
|
||||
```
|
||||
|
||||
If `target` is a peer in the same room, the relay sends `pong` to that peer with
|
||||
`{ "t": ... }`.
|
||||
|
||||
### `event_ack`
|
||||
|
||||
Client payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"targetId": "peerId",
|
||||
"actionTimestamp": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
If sender and target are still in the same room, the relay emits:
|
||||
|
||||
```json
|
||||
{
|
||||
"senderId": "sender peerId",
|
||||
"actionTimestamp": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limits
|
||||
|
||||
- Connections: 10 per IP per minute; excess connections are disconnected.
|
||||
- Relayed/events: 50 per socket per 10 seconds; excess disconnects the socket.
|
||||
- `get_rooms`: 10 second cooldown per socket plus the event limit.
|
||||
- `leave_room`: 10 per socket per minute; excess disconnects the socket.
|
||||
- Invalid room passwords: tracked per IP and room. Five recent failures block
|
||||
more password attempts for that room until the failure window ages out.
|
||||
- HTTP health and admin-metrics endpoints have their own rate limits outside this
|
||||
Socket.IO protocol.
|
||||
|
||||
## Capabilities
|
||||
|
||||
`room_data.capabilities` advertises server-backed features:
|
||||
|
||||
- `host-control`
|
||||
- `co-host`
|
||||
|
||||
Clients should treat a missing or unknown capabilities list as unsupported.
|
||||
@@ -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).*
|
||||
|
||||
@@ -60,6 +60,22 @@
|
||||
- **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
|
||||
- **Category:** Compatibility / Embedded Players
|
||||
- **Background:** KoalaSync currently injects on demand into the selected tab's top frame. This works for normal top-frame players, including current Emby/Jellyfin usage, but does not cover cases where the real `<video>` lives inside a cross-origin iframe or an `about:blank`/`srcdoc` player frame.
|
||||
- **Possible approach:** Add an opt-in frame bridge where child frames announce detected videos to the top frame, and the top frame routes remote play/pause/seek commands to the active child video.
|
||||
- **Status:** Future compatibility work, not needed for current Emby behavior.
|
||||
|
||||
### Local extension E2E smoke tests
|
||||
|
||||
- **Priority:** P2
|
||||
- **Category:** Testing / Release Confidence
|
||||
- **Background:** The release verification covers unit tests, server integration, syntax, lint, audits, and builds, but it does not currently run a real browser extension flow. A small local E2E smoke suite would catch regressions in content-script injection, tab navigation reinjection, remote seek handling, and iframe player support.
|
||||
- **Possible approach:** Add a separate local-only Playwright smoke command that loads the unpacked extension, opens two controlled video pages, and verifies play/pause/seek through the actual extension path. Keep it outside `npm run verify` until it is stable enough for CI.
|
||||
- **Status:** Backlog, recommended before larger content-script or frame-bridge changes.
|
||||
|
||||
---
|
||||
|
||||
## ❌ Rejected
|
||||
@@ -70,4 +86,3 @@
|
||||
|---|---|
|
||||
| *(none yet)* | |
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
# Co-Host (Multi-Controller) — Implementation Plan
|
||||
|
||||
Branch base: `feature/host-control-mode` (builds directly on it).
|
||||
Goal: let the room owner grant **playback control to several peers** (co-hosts), not
|
||||
just one — e.g. 4 of N people in a room may drive play/pause/seek, the rest are guests.
|
||||
|
||||
This is the second server-gated feature the `capabilities` hook was designed for
|
||||
(`CAPABILITIES.CO_HOST = 'co-host'`, already stubbed in `shared/constants.js`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Roles
|
||||
|
||||
| Role | Can drive (play/pause/seek/force-sync/episode-lobby) | Can promote/demote + toggle mode |
|
||||
|------|------|------|
|
||||
| **Owner** (room creator, = today's "host") | yes (always a controller) | **yes** |
|
||||
| **Controller** (co-host) | yes (in `host-only` mode) | no |
|
||||
| **Guest** | only in `everyone` mode | no |
|
||||
|
||||
The single-host feature is just the special case `controllers = { owner }`.
|
||||
|
||||
## 2. Data model
|
||||
|
||||
### Server (`room` object)
|
||||
- `ownerPeerId` — the creator / manager. Keep `hostPeerId` as an **alias** (= ownerPeerId)
|
||||
so older clients keep working.
|
||||
- `controllers: Set<peerId>` — peers allowed to drive. **Always contains ownerPeerId.**
|
||||
- `controlMode: 'everyone' | 'host-only'` — unchanged wire values (`'host-only'` now means
|
||||
"restricted to controllers", not "single host").
|
||||
- `MAX_CONTROLLERS` cap (e.g. 10) to bound the set + payload.
|
||||
|
||||
### Shared constants
|
||||
- `CAPABILITIES.CO_HOST = 'co-host'` (un-stub it) → add to `SERVER_CAPABILITIES`.
|
||||
- New events:
|
||||
- `SET_PEER_ROLE` (client→server): `{ peerId, controller: boolean }` — owner promotes/demotes.
|
||||
- Extend `CONTROL_MODE` (server→client) payload: `{ controlMode, ownerPeerId, hostPeerId, controllers: [peerId...] }`.
|
||||
- `ROOM_DATA` gains `ownerPeerId` + `controllers`.
|
||||
|
||||
## 3. Gate generalization (the core change)
|
||||
|
||||
Today the gate compares against a single `hostPeerId`. Generalize to set membership:
|
||||
|
||||
- **Server relay gate** (`server/index.js`): `controlMode === 'host-only' && !room.controllers.has(mapping.peerId)` → drop. (Was `mapping.peerId !== room.hostPeerId`.)
|
||||
- **Background gates** (sender + receiver): replace `amHost()` / `senderId !== hostPeerId`
|
||||
with controller-set membership: `controllers.includes(myPeerId)` / `senderId ∈ controllers`.
|
||||
- **Helpers:** split `amHost()` into `amOwner()` (manage rights) and `amController()`
|
||||
(drive rights). The desync/snap-back path keys on `!amController()` instead of `!amHost()`.
|
||||
|
||||
`SET_PEER_ROLE` handler (server): validate sender is owner, target is a current peer in the
|
||||
room, enforce `MAX_CONTROLLERS`, always keep owner in the set, then broadcast `CONTROL_MODE`
|
||||
with the new `controllers`.
|
||||
|
||||
## 4. Client + UI
|
||||
|
||||
- **Owner** sees the peer list with a per-peer **"Controller" toggle** (promote/demote) plus
|
||||
the existing mode toggle.
|
||||
- **Controllers** see a "Controller" badge and are NOT locked out of the remote-control buttons.
|
||||
- **Guests** see "Guest" + the host-only notice (unchanged).
|
||||
- The promote UI + co-host badges render only when the relay advertises the `co-host`
|
||||
capability (feature detection, same pattern as `hostControlSupported`).
|
||||
- i18n: new keys (`ROLE_CONTROLLER`, `BTN_PROMOTE`, `BTN_DEMOTE`, …) across all locales.
|
||||
|
||||
## 5. Backwards compatibility
|
||||
|
||||
- **New client + old server** (host-control only, no `co-host` capability): no co-host UI;
|
||||
behaves as today's single-host. ✓
|
||||
- **Old client + new server**: ignores `controllers` / `SET_PEER_ROLE`. An old client that
|
||||
the owner promotes still **gates itself** (its sender-gate only knows `!amHost`), so it
|
||||
can't drive — it degrades to a guest. Co-host requires a client that understands
|
||||
`controllers`. Document this; not a crash. ✓
|
||||
- No `PROTOCOL_VERSION` bump needed — purely additive, same as host-control.
|
||||
|
||||
## 6. Edge cases
|
||||
- **Controller leaves** → `removePeerFromRoom` also does `room.controllers.delete(peerId)`.
|
||||
- **Owner leaves** → fallback: promote the earliest remaining **controller** to owner (prefer
|
||||
a controller over a random peer); if none, earliest peer; keep the rest of the set. Reuse
|
||||
the `peerJoinLocks` guard so a reconnect/second-tab doesn't demote (same fix as host).
|
||||
- **Promote a peer not in the room** → server rejects (target must be a live peer).
|
||||
- **Promote beyond `MAX_CONTROLLERS`** → server rejects, re-syncs the owner's UI.
|
||||
- **`everyone` mode** → the `controllers` set is still maintained (so flipping to `host-only`
|
||||
keeps the chosen co-hosts), it just isn't enforced while in `everyone`.
|
||||
- **peerId spoofing** → unchanged accepted limitation (see `docs/KNOWN_LIMITATIONS.md`);
|
||||
co-host doesn't widen it materially (still bounded to a temporary room).
|
||||
|
||||
## 7. Scale: the "4 of 510 people" part — read this
|
||||
|
||||
The role change above is moderate. **Putting 510 people in one room is a separate, larger
|
||||
problem** and should be its own track:
|
||||
|
||||
- `MAX_PEERS_PER_ROOM` is **25** today. 510 needs a large raise + load testing.
|
||||
- **The real bottleneck at scale is heartbeat fan-out, not control events.** Every peer
|
||||
heartbeats and the relay broadcasts each to all peers → O(N²) per interval. At 510 that's
|
||||
~510×509 / 15s ≈ **17k msg/s just for heartbeats** — the scaling wall.
|
||||
- **Co-host actually *helps* the control-event side:** in `host-only` mode only the few
|
||||
controllers emit play/pause/seek, so event *sources* drop from N to K (e.g. 4). Restricting
|
||||
who can drive is synergistic with big rooms.
|
||||
- Large rooms therefore need (independent of co-host):
|
||||
- **Heartbeat fan-out reduction** — e.g. only relay controller/owner heartbeats to everyone,
|
||||
relay guest heartbeats only to the owner/controllers (for the UI), or server-side
|
||||
aggregation into periodic snapshots instead of per-peer relay.
|
||||
- **`ROOM_DATA` payload trimming** — a 510-entry peer list is large; send counts + controller
|
||||
details, lazy-load the full roster.
|
||||
- Possibly the **socket.io Redis adapter** for horizontal scaling, and broadcast tuning.
|
||||
|
||||
## 8. Effort estimate
|
||||
- **Co-host roles** (server gate generalization + `SET_PEER_ROLE` + owner-leave fallback +
|
||||
client gates + promote UI + i18n), at the current ≤25-peer scale: **~3–4 dev days** (same
|
||||
shape as host-control itself — mostly generalizing host→controller-set).
|
||||
- **Large-room scaling (510)**: a **separate ~1–2 week** track (heartbeat redesign + payload
|
||||
trimming + cap raise + load testing), independent of co-host. Recommend shipping co-host at
|
||||
the current cap first, then scaling rooms as its own project.
|
||||
|
||||
## 9. Suggested sequencing
|
||||
1. `CAPABILITIES.CO_HOST` + `controllers`/`ownerPeerId` in room state + `ROOM_DATA` (additive).
|
||||
2. Server `SET_PEER_ROLE` + gate generalization + owner-leave fallback + WS tests.
|
||||
3. Background: controller-set membership in both gates + `amOwner`/`amController`.
|
||||
4. Popup: promote/demote toggles (owner) + Controller badge + i18n.
|
||||
5. (Separate track) large-room scaling.
|
||||
@@ -1,283 +0,0 @@
|
||||
# Host Control Mode — Branch Overview, Goals & Edge Cases
|
||||
|
||||
> **This is the canonical entry-point doc for branch `feature/host-control-mode`.**
|
||||
> If you're an agent or contributor picking this up: read this file first, then the
|
||||
> implementation plan in [`host-control-mode-plan.md`](./host-control-mode-plan.md).
|
||||
> Temporary working doc — delete or fold into permanent docs before merging to `main`.
|
||||
>
|
||||
> Status legend: 🔴 open / unresolved · 🟡 idea, needs testing · 🟢 decided
|
||||
|
||||
---
|
||||
|
||||
## 0. Implementation status
|
||||
|
||||
All five layers are implemented & pushed (server + background + content + popup + i18n).
|
||||
Automated checks green: ESLint, WS integration tests (incl. host-only gate, toggle
|
||||
reject, host-leave fallback), content video-finder, locale consistency (15 langs),
|
||||
full release verification.
|
||||
|
||||
**Still needs real-device testing** — the EC test matrix in §7 (YouTube/Netflix/
|
||||
Twitch/Disney+/Jellyfin): involuntary-pause classification (EC-1/EC-5/EC-8), snap-back
|
||||
reliability and fight-loops (EC-4), and the desync/resync flow across players. The
|
||||
intent classifier (EC-9) and snap-back cooldown are first-pass heuristics tuned by
|
||||
reading the code, not yet by watching them behave on each site.
|
||||
|
||||
Deferred by decision (see §8): host grace period on disconnect (EC-10).
|
||||
|
||||
### Capability detection (forward-compat hook)
|
||||
The relay advertises `capabilities: ['host-control']` in `ROOM_DATA`
|
||||
(`SERVER_CAPABILITIES` in server, `CAPABILITIES` in shared/constants). The client
|
||||
enables host-control UI/behavior only when the flag is present, so the feature
|
||||
degrades cleanly on an older relay (absent → off) and old clients ignore the
|
||||
field. This is the extensible hook for the planned **co-host** feature (owner
|
||||
promotes guests to additional controllers): it will add a `'co-host'` capability
|
||||
+ events without a protocol bump or breaking older relays/clients. Add new flags
|
||||
to `CAPABILITIES` / `SERVER_CAPABILITIES` as features land.
|
||||
|
||||
### Pre-test self-audit (fixed)
|
||||
- **Popup remote buttons froze for guests** — in host-only a guest's Play/Pause/SYNC
|
||||
click was gated server-side but the button stuck on "Playing"/disabled with no
|
||||
feedback. Now the remote controls are locked (disabled + tooltip) for guests, with
|
||||
backstop guards in the handlers. (popup.js)
|
||||
- **Desync dialog could break under strict CSP** — it used `innerHTML` with inline
|
||||
`style=""` attributes, which Netflix/YouTube/Disney+ strip via `style-src`. Rebuilt
|
||||
with the DOM API (CSSOM `.style` is CSP-safe) inside a **Shadow DOM** so page CSS
|
||||
can't restyle/hide it. (content.js)
|
||||
- **Live-DVR not detected (EC-15)** — `duration === Infinity` misses Twitch/YouTube
|
||||
live-DVR (finite, sliding duration). Added a `seekable.start(0) > 1` sliding-window
|
||||
heuristic in `hcmIsLive()`. (content.js)
|
||||
|
||||
### Pre-test self-audit
|
||||
- ~~**EC-4/EC-1 snap-back thrash:**~~ FIXED — implemented the **buffer-aware deferred
|
||||
snap-back** (`hcmDeferredSnapBack`): on an involuntary event, if the player isn't
|
||||
ready (`readyState<3` or seeking) we wait (poll, 8s cap) until it can play, then snap
|
||||
ONCE to the host's re-queried position instead of repeatedly fighting the buffer.
|
||||
Done defensively/player-agnostically — we can't enumerate every site, so this is safe
|
||||
whether a player fires `pause()` or only `waiting`. Aborts if the user goes solo or is
|
||||
no longer a gated guest; single pending poll (no stacking).
|
||||
- ~~**Control-mode race at join:**~~ FIXED — `hcmHandleBlocked` now treats `HOST_BLOCKED`
|
||||
as authoritative (adopts host-only/guest role) instead of re-checking local mode,
|
||||
since background only sends it to gated guests.
|
||||
- ~~**Dialog/badge text is English-only**~~ FIXED — background resolves the strings
|
||||
via GET_HCM_STRINGS (it has the i18n loader); content fetches them on init with
|
||||
English fallback. 6 new keys (HCM_DIALOG_*/HCM_BADGE_*) across all 15 locales.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this branch is for
|
||||
|
||||
Origin: a GitHub feature request. When watching with larger groups, anyone can pause
|
||||
or seek and disrupt everyone else. The requester wants the room creator to optionally
|
||||
restrict control to a single **host**, the way Teleparty works — guests who try to
|
||||
pause get asked whether they want to pause *only their own* player (and desync), and
|
||||
otherwise get snapped back to the room's position.
|
||||
|
||||
**Goal:** Add an optional per-room **Host Control Mode**. A room can be switched
|
||||
between:
|
||||
- **`everyone`** (default, current behavior): anyone can play/pause/seek for the room.
|
||||
- **`host-only`**: only the host drives the room. A guest's deliberate play/pause/seek
|
||||
is not broadcast; instead they're snapped back to the room position — unless they
|
||||
explicitly choose to desync (go solo) with a "Resync" escape hatch.
|
||||
|
||||
## 2. Trust model (read this before over-engineering)
|
||||
|
||||
This is **client-side trust, by design**. It's a watch party, not a security boundary.
|
||||
The point is preventing *accidental* and *casual* disruption, not stopping someone
|
||||
determined to patch their own extension. We do **not** add auth, tokens, or
|
||||
cryptographic host identity. `peerId` is unauthenticated and that's fine here.
|
||||
|
||||
(We still gate server-side as the robust chokepoint — see plan — but that's about
|
||||
killing spam reliably, not about defeating a hostile client.)
|
||||
|
||||
## 3. Scope / non-goals
|
||||
|
||||
In scope:
|
||||
- Host designation (first joiner = host), mode toggle, host-only gating of all
|
||||
room-moving events, guest snap-back, deliberate-desync flow + resync, host UI.
|
||||
|
||||
Explicit non-goals (for this branch):
|
||||
- Authenticated / spoof-proof host identity.
|
||||
- Persistent host across server restarts (room state is in-memory).
|
||||
- Syncing around personalized ad breaks.
|
||||
- Host transfer UI (auto-fallback to `everyone` when host leaves; manual transfer
|
||||
is a possible later add).
|
||||
|
||||
## 4. Architecture summary
|
||||
|
||||
Three-layer gate for room-moving events from a non-host in host-only mode
|
||||
(`PLAY`, `PAUSE`, `SEEK`, `FORCE_SYNC_PREPARE`, `FORCE_SYNC_EXECUTE`, `EPISODE_LOBBY`):
|
||||
1. **Server** — doesn't relay them (robust chokepoint, kills spam regardless of client).
|
||||
2. **Sender (guest)** — doesn't emit; shows confirm dialog / disables host-only buttons.
|
||||
3. **Receiver** — drops any that slip through (covers old/buggy/modified clients).
|
||||
|
||||
Snap-back reuses the existing `_setSuppress` mechanism (content.js:442) so applying
|
||||
the room state programmatically doesn't echo back as a new event. Target position is
|
||||
extrapolated from the host's last known state (±1s). Full detail + code hooks in the
|
||||
plan doc.
|
||||
|
||||
---
|
||||
|
||||
## 5. The central challenge
|
||||
|
||||
Everything hard about this feature reduces to **one question** (see EC-9):
|
||||
|
||||
> How do we reliably tell a **deliberate** guest pause/seek from an **involuntary**
|
||||
> player/browser event (buffering, ads, tab throttling, source swaps, DRM hiccups)?
|
||||
|
||||
If we get this wrong, guests get spammed with desync dialogs and snap-back loops for
|
||||
things they never did. The host/role plumbing is the easy part; this classifier is the
|
||||
real work. **Design the intent-classifier before writing the gate.**
|
||||
|
||||
---
|
||||
|
||||
## 6. Edge cases
|
||||
|
||||
### EC-1 🔴 Buffering / loading fires a `pause` event
|
||||
content.js listens to `play`/`pause`/`seeked`/`loadeddata` only (content.js:1000-1003),
|
||||
not `waiting`/`stalled`. Pure HTML5 buffering fires `waiting` → harmless. But custom
|
||||
players (Netflix/YouTube/Twitch/JW) often call `video.pause()` during buffering/ads →
|
||||
real `pause` → guest gate would mis-classify as deliberate. Sub-cases: (a) initial load
|
||||
sits paused, no event, fine; (b) mid-stream stall, player-dependent; (c) seek-induced
|
||||
re-buffering may outlast the suppress window and leak. Mitigation: `isBuffering` flag
|
||||
from `waiting`/`playing`, or grace window; in host-only the guest's own state is
|
||||
irrelevant so just ignore involuntary pauses and let catch-up logic (content.js:489)
|
||||
re-sync them.
|
||||
|
||||
### EC-2 🟢 Force-Sync / Episode-Lobby abuse by guests
|
||||
Guest could seek + spam Force-Sync to drag everyone, or spam Episode-Lobby to pause
|
||||
everyone. Decision: host-only blocks guest *initiation* of `FORCE_SYNC_*` and
|
||||
`EPISODE_LOBBY`; guests may only respond (`FORCE_SYNC_ACK`, `EPISODE_READY`). Guests'
|
||||
legitimate path is the personal "Resync" button.
|
||||
|
||||
### EC-3 🟢 Host leaves the room
|
||||
Fall back to `controlMode = 'everyone'`, broadcast `CONTROL_MODE`. Never a stuck locked
|
||||
room. (Auto-promote next peer deferred.)
|
||||
|
||||
### EC-4 🔴 Snap-back fight loop (pause/play/skip back/pause/play)
|
||||
Mashing controls or a janky player → each snap-back may emit events → ping-pong.
|
||||
Mitigation: cooldown (~600ms) after snap-back; ensure snap-back runs fully under
|
||||
suppress. Also: if target is unreachable (seek past buffered range), retry K times then
|
||||
give up — no infinite loop.
|
||||
|
||||
### EC-5 🔴 Ad breaks (YouTube/Twitch/…)
|
||||
Mid-roll ads pause/swap the media element, differ per peer → desync is unavoidable and
|
||||
must NOT spam the dialog. Probably covered by EC-1 buffering grace; flag for explicit
|
||||
testing.
|
||||
|
||||
### EC-6 🟡 Snap-back target accuracy
|
||||
No continuous room clock; extrapolate from host's `currentTime` + `lastHeartbeat`
|
||||
(±1s, worse if stale). The follow-up host correction must also be suppressed so it
|
||||
doesn't read as guest input.
|
||||
|
||||
### EC-7 🟢 Old / buggy / modified guest client
|
||||
Covered by receiver-side + server-side gates.
|
||||
|
||||
### EC-8 🔴 Tab throttling / background tab
|
||||
Backgrounding throttles timers and may pause media. There's existing
|
||||
`visibilityGraceUntil` handling for seeks (content.js:892). Confirm a
|
||||
background-induced pause isn't treated as deliberate in host-only; reuse the grace flag.
|
||||
|
||||
### EC-9 🔴 What counts as "deliberate" — the central unresolved question
|
||||
Collapses EC-1/EC-5/EC-8. Candidate signals: `readyState`/`networkState`/`video.seeking`
|
||||
at event time; recent `waiting`; recent user gesture (`navigator.userActivation`,
|
||||
keydown/click); visibility/focus. Build one shared **intent-classifier** helper in
|
||||
content.js that all host-only gating flows through.
|
||||
|
||||
### EC-10 🔴 Host brief disconnect / reconnect (network blip)
|
||||
Host's wifi drops for 3s and reconnects. With "host leaves → fallback to everyone",
|
||||
a blip would silently unlock the room and demote the host (peerId persists in
|
||||
chrome.storage so they rejoin with the same id, but the server already cleared
|
||||
`hostPeerId`). Mitigation idea: short **host grace period** (e.g. keep `hostPeerId`
|
||||
reserved for ~30s after disconnect; if the same peerId rejoins, restore host + mode).
|
||||
Needs the server reaper (server:644) and `removePeerFromRoom` (server:168) to cooperate.
|
||||
|
||||
### EC-11 🔴 New guest joins mid-session in host-only mode
|
||||
On join they must (a) immediately sync to the host's current position without the host
|
||||
doing anything, and (b) see they're a guest in the UI. ROOM_DATA already carries peers;
|
||||
add `hostPeerId`/`controlMode` so a fresh client knows its role instantly. Verify the
|
||||
existing "newcomer syncs without waiting" path (content.js:542) still fires.
|
||||
|
||||
### EC-12 🔴 Desync semantics — what does "solo" actually mean?
|
||||
When a guest chooses "pause only me", do they (a) fully ignore all subsequent host
|
||||
events until they Resync, or (b) keep receiving but not auto-applying? Define clearly.
|
||||
Proposed: full solo — ignore host play/pause/seek while desynced; Resync re-attaches and
|
||||
snaps to current host position. Also: what state does Resync land them in if the host is
|
||||
currently paused vs playing?
|
||||
|
||||
### EC-13 🔴 Race: host flips to host-only exactly as a guest pauses
|
||||
Event ordering between `SET_CONTROL_MODE`/`CONTROL_MODE` and an in-flight guest `PAUSE`.
|
||||
The `seq` ordering helps, but define the tie-break. Likely: server is authoritative —
|
||||
once it has `host-only`, it drops the guest event regardless of client-side timing.
|
||||
|
||||
### EC-14 🟡 Volume / mute / audio-options must NOT be gated
|
||||
Those are per-peer, not room control. The gate must target only play/pause/seek +
|
||||
forcesync/episode — not `PEER_STATUS` volume/mute fields. Easy to over-block; add a test.
|
||||
|
||||
### EC-15 🔴 Live streams (Twitch live, live DVR)
|
||||
"Room timestamp" is fuzzy on live edge; seeking semantics differ. Decide whether
|
||||
host-only even makes sense for live, or degrade gracefully. Low priority but log it.
|
||||
|
||||
### EC-16 🟡 Host's own involuntary events still drive the room
|
||||
If the host buffers and the player auto-pauses, that pause is "allowed" and pauses
|
||||
everyone. That's existing behavior, but in host-only it means the host's buffering
|
||||
stalls the whole room. Acceptable? Probably yes (host is authoritative), but note it.
|
||||
|
||||
### EC-17 🟡 Server restart drops room state
|
||||
`hostPeerId`/`controlMode` are in-memory. After a server restart, whoever rejoins first
|
||||
becomes the new host and mode resets to `everyone`. Acceptable for now (non-goal), but
|
||||
document so it's not a surprise.
|
||||
|
||||
### EC-18 🟡 Dialog dismissed without choosing
|
||||
Guest clicks away / presses Esc on the desync prompt. Default = treat as "No" → snap
|
||||
back. Make sure an un-answered dialog can't leave them in limbo (paused + not desynced +
|
||||
no dialog).
|
||||
|
||||
### EC-19 🟡 Multiple video elements / element swap (SPA, ad → content)
|
||||
Players that swap the `<video>` element mid-session: re-attach handlers (content.js
|
||||
already re-binds on `loadeddata`) and make sure host-only gating follows the new element.
|
||||
|
||||
### EC-20 🟡 Peer list shape (object vs legacy string)
|
||||
Throughout background.js peers may be objects or bare peerId strings
|
||||
(`typeof p === 'object' ? p.peerId : p`). All new `hostPeerId` comparisons must handle
|
||||
both forms, or we get a host that's never recognized.
|
||||
|
||||
### EC-21 🟡 Mode toggle spam / rate limiting
|
||||
Host hammering the toggle → many `SET_CONTROL_MODE`. Covered by existing
|
||||
`checkEventRate` (server), but debounce in the UI and ignore no-op transitions.
|
||||
|
||||
---
|
||||
|
||||
## 7. Test matrix (fill in during dev)
|
||||
|
||||
| Player | Buffer→`pause`? | Ad behavior | Snap-back works? | Element swap? | Notes |
|
||||
|-------------------|-----------------|-------------|------------------|---------------|-------|
|
||||
| Generic HTML5 | | | | | |
|
||||
| YouTube | | | | | |
|
||||
| Netflix | | | | | |
|
||||
| Twitch (VOD) | | | | | |
|
||||
| Twitch (live) | | | | | |
|
||||
| Disney+ / DRM | | | | | |
|
||||
| Jellyfin / Emby | | | | | |
|
||||
|
||||
## 8. Decisions (audited)
|
||||
- [x] **Intent-classifier (EC-9):** A `pause`/`seek` is **involuntary** if ANY of:
|
||||
`readyState < 3`, `video.seeking`, a `waiting` fired < ~1500ms ago (`isBuffering`
|
||||
flag), inside `visibilityGraceUntil`, OR no own-tracked user gesture
|
||||
(`Date.now() - lastUserGestureAt < 1000`, via capturing keydown/pointerdown — do
|
||||
NOT use sticky `navigator.userActivation.hasBeenActive`). Bias: only *clearly*
|
||||
involuntary is ignored; everything else = deliberate. **Note:** in host-only the
|
||||
guest never broadcasts anyway, so this only decides dialog-vs-silent — a UX call,
|
||||
not a room-integrity call. Start simple, tune later.
|
||||
- [x] **Host grace on disconnect (EC-10): NOT in v1.** Immediate fallback to
|
||||
`everyone` (EC-3). A grace window risks a multi-second hard-lock if the host never
|
||||
returns. Revisit as polish once the core flow works.
|
||||
- [x] **Desync semantics (EC-12): full solo.** Ignore host play/pause/seek while
|
||||
desynced; Resync snaps to host position + adopts host play/pause state. Desync
|
||||
auto-clears on new media/episode. Requires a persistent, obvious "You are desynced"
|
||||
UI.
|
||||
- [x] **Snap-back cooldown (EC-4): until-settled, not fixed.** Suppress re-trigger
|
||||
until `readyState>=3 && playing && |Δt|<tol`, hard-cap ~1500ms. Retry target up to
|
||||
3×, then give up (no infinite loop).
|
||||
- [x] **Live streams (EC-15): degrade.** Disable the gate when
|
||||
`video.duration === Infinity`. Caveat: live-DVR may report finite duration and slip
|
||||
through — acceptable for v1.
|
||||
@@ -1,83 +0,0 @@
|
||||
# Host Control Mode — Beta Testing Guide
|
||||
|
||||
> Temporary doc for branch `feature/host-control-mode`. Remove before merge.
|
||||
> The feature needs the **server** half too — the official relay
|
||||
> (`wss://syncserver.koalastuff.net`) doesn't run it yet, so test against a beta
|
||||
> server first.
|
||||
|
||||
## 1. Run the beta relay (Docker)
|
||||
|
||||
The branch publishes the server image to GHCR under non-production tags
|
||||
(`:beta` = newest branch build, `:sha-<commit>` = immutable pin). `:latest` is
|
||||
never touched.
|
||||
|
||||
```bash
|
||||
# Log in (the package is private → PAT with read:packages)
|
||||
echo "$GHCR_PAT" | docker login ghcr.io -u <your-gh-user> --password-stdin
|
||||
|
||||
# Pull + (re)create — NOTE: `docker restart` does NOT pick up a new image,
|
||||
# you must remove and re-run (or use compose / Watchtower).
|
||||
docker pull ghcr.io/shik3i/koalasync:beta
|
||||
docker rm -f koala-beta 2>/dev/null || true
|
||||
docker run -d --name koala-beta -p 3000:3000 \
|
||||
-e SERVER_SALT='choose-your-own-salt' \
|
||||
ghcr.io/shik3i/koalasync:beta
|
||||
```
|
||||
|
||||
To always run the newest beta automatically, point **Watchtower** at the
|
||||
container — it does the pull → recreate whenever `:beta` moves.
|
||||
|
||||
The `OFFICIAL_SERVER_TOKEN` is baked into `shared/constants.js`, so no token env
|
||||
is needed. Set `SERVER_SALT` (used for room-password hashing).
|
||||
|
||||
## 2. Connect the extension to it
|
||||
|
||||
⚠️ The client **force-upgrades `ws://` to `wss://` for any non-localhost host**
|
||||
(see background.js "Upgraded to wss:// for remote host"). So a bare
|
||||
`ws://your-server:3000` will fail without TLS. Two options:
|
||||
|
||||
- **Quick (no TLS):** SSH-tunnel the port so it counts as local:
|
||||
```bash
|
||||
ssh -L 3000:localhost:3000 your-beta-server
|
||||
```
|
||||
then in the popup → **Manual Connect / Advanced → Custom →** `ws://localhost:3000`.
|
||||
- **Proper:** put the container behind a TLS reverse proxy (Caddy does automatic
|
||||
HTTPS) → `wss://beta.yourdomain`.
|
||||
|
||||
Then create/join a room.
|
||||
|
||||
## 3. ⚠️ Use two *new* clients
|
||||
|
||||
Test with **two browser profiles both running this branch build** (load unpacked
|
||||
from `extension/`, or install the built zip). A stock release client as a guest
|
||||
will be correctly gated by the server but has none of the content-side code, so
|
||||
it silently desyncs with no dialog/snap-back — that's expected degradation, not a
|
||||
bug, but it looks like one during testing.
|
||||
|
||||
## 4. Verification checklist
|
||||
|
||||
| # | Step | Expect |
|
||||
|---|------|--------|
|
||||
| 1 | Create a room (host) | Host Control card shows **Host** + the toggle |
|
||||
| 2 | Second profile joins | Guest sees no card while mode is "everyone" |
|
||||
| 3 | Host enables "Only I can control" | Guest's Play/Pause/SYNC buttons lock; card shows **Guest** |
|
||||
| 4 | Guest presses pause/space on the video | Brief flicker, snaps back to host position |
|
||||
| 5 | Guest pauses again | Dialog: "Stay in sync" / "Watch on my own" |
|
||||
| 6 | Guest → "Watch on my own" | Persistent "Solo" badge; host's peer list shows **Solo** |
|
||||
| 7 | Guest → "Resync" | Snaps back to host; Solo badge clears on both sides |
|
||||
| 8 | Guest tries Force-Sync / seek spam | Nothing propagates to the room |
|
||||
| 9 | Host disables host-only | Card hides for guest; controls unlock |
|
||||
| 10 | Host leaves the room | Room falls back to "everyone"; a remaining peer becomes host |
|
||||
| 11 | Reload the guest's page while desynced | Still shows Solo (state survives reload) |
|
||||
| 12 | Switch the popup language | Dialog/badge text is localized |
|
||||
|
||||
## 5. Capability detection
|
||||
|
||||
The relay advertises `capabilities: ['host-control']` in `ROOM_DATA`. The client
|
||||
only enables the feature when that flag is present, so:
|
||||
- against this beta server → feature on;
|
||||
- against the old official server → feature cleanly hidden (no errors).
|
||||
|
||||
This is the extensible hook for the planned **co-host** feature (owner promotes
|
||||
guests to additional controllers) — it'll add a `'co-host'` capability + events
|
||||
without breaking older relays/clients.
|
||||
@@ -1,122 +0,0 @@
|
||||
# Host Control Mode — Implementierungsplan
|
||||
|
||||
Branch: `feature/host-control-mode`
|
||||
Issue: GitHub feature request (wasserrutschentester) — nur Host darf den Raum steuern; Gäste werden zurückgesnappt oder gehen bewusst in Desync.
|
||||
|
||||
## Ziel
|
||||
|
||||
Ein Raum kann zwischen zwei Modi umgeschaltet werden:
|
||||
- **`everyone`** (Default, heutiges Verhalten): jeder kann play/pause/seek für alle auslösen.
|
||||
- **`host-only`**: nur der Host steuert den Raum. Pause/Seek eines Gasts wird **nicht** gebroadcastet; stattdessen snappt die eigene Extension den Gast zurück auf den Raum-Zustand — es sei denn, der Gast entscheidet sich bewusst für Desync.
|
||||
|
||||
Trust-Modell: client-seitig durchgesetzt. Kein Token, keine Auth. Es geht um versehentliches Stören, nicht um Angriffsschutz.
|
||||
|
||||
---
|
||||
|
||||
## Datenmodell
|
||||
|
||||
### Server (`server/index.js`, Room-Objekt ~Z.331)
|
||||
Room bekommt zwei neue Felder:
|
||||
```js
|
||||
room = {
|
||||
...,
|
||||
hostPeerId: peerId, // gesetzt beim Anlegen = erster Joiner
|
||||
controlMode: 'everyone', // 'everyone' | 'host-only'
|
||||
}
|
||||
```
|
||||
- In `ROOM_DATA` (~Z.418) mitschicken: `hostPeerId`, `controlMode`.
|
||||
- Neues Event `SET_CONTROL_MODE` (siehe unten): nur akzeptieren, wenn `senderPeerId === room.hostPeerId`. Server setzt `room.controlMode`, broadcastet die Änderung an alle.
|
||||
- **Host-Migration:** in `removePeerFromRoom` (~Z.168) — wenn der gehende Peer `hostPeerId` war: entweder neuen Host bestimmen (nächster Peer) **oder** `controlMode` auf `everyone` zurückfallen lassen. → Entscheidung: **Fallback auf `everyone`** (simpel, nie verwaister gesperrter Raum). Optional später: Host-Transfer-Button.
|
||||
|
||||
### Shared Constants (`shared/constants.js`)
|
||||
Neue Events im `EVENTS`-Objekt:
|
||||
```js
|
||||
SET_CONTROL_MODE: "set_control_mode", // Client->Server: Host ändert Modus
|
||||
CONTROL_MODE: "control_mode", // Server->Client: Modus geändert { controlMode, hostPeerId }
|
||||
```
|
||||
⚠️ Danach `node scripts/build-extension.cjs` laufen lassen (Single Source of Truth propagieren). Ggf. `PROTOCOL_VERSION` bumpen — **nein, nur wenn alte Clients brechen würden**. Da alles additiv ist und alte Clients die neuen Felder/Events einfach ignorieren, ist KEIN Protokoll-Bump nötig. (Alter Client in host-only-Raum kennt den Modus nicht und sendet weiter → Host-Extensions ignorieren fremde Events nicht... → siehe Edge Case 7. Evtl. doch Bump erwägen.)
|
||||
|
||||
### Extension State (`background.js`)
|
||||
```js
|
||||
let controlMode = 'everyone';
|
||||
let hostPeerId = null;
|
||||
// abgeleitet: const amHost = () => hostPeerId === peerId;
|
||||
```
|
||||
Aus `ROOM_DATA` / `CONTROL_MODE` befüllen, in `chrome.storage.session` persistieren (wie `currentRoom`).
|
||||
|
||||
---
|
||||
|
||||
## Implementierung nach Schichten
|
||||
|
||||
### 1. Server (`server/index.js`)
|
||||
- [ ] Room-Objekt um `hostPeerId` + `controlMode` erweitern (~Z.331).
|
||||
- [ ] `ROOM_DATA`-Payload erweitern (~Z.418).
|
||||
- [ ] Handler `SET_CONTROL_MODE`: validieren (Host-Check + Wert in {everyone, host-only}), setzen, `CONTROL_MODE` an Raum broadcasten.
|
||||
- [ ] Host-Migration in `removePeerFromRoom`: Fallback auf `everyone` + neues `CONTROL_MODE` broadcasten, wenn Host geht.
|
||||
- [ ] `SET_CONTROL_MODE` in die `relayEvents`-Liste? **Nein** — eigener Handler, da Sonderlogik + Host-Check. (relayEvents broadcastet blind.)
|
||||
|
||||
### 2. Shared / Build
|
||||
- [ ] `EVENTS.SET_CONTROL_MODE`, `EVENTS.CONTROL_MODE` ergänzen.
|
||||
- [ ] `node scripts/build-extension.cjs`.
|
||||
|
||||
### 3. background.js (Gast-Logik = Kern)
|
||||
- [ ] `controlMode` / `hostPeerId` aus `ROOM_DATA` (~Z.875) und neuem `CONTROL_MODE`-Case übernehmen + persistieren + an Popup/Content pushen.
|
||||
- [ ] **Emit-Gate** im SEND-Pfad (~Z.1786): bei `host-only && !amHost()` und action ∈ {play, pause, seek}:
|
||||
- NICHT `emit`en.
|
||||
- Stattdessen Content-Script anweisen: "snap back" ODER Desync-Confirm anzeigen.
|
||||
- [ ] **Snap-Back-Zielzeit berechnen:** aus Host-Peer-State (`playbackState`, `currentTime`, `lastHeartbeat`) extrapolieren:
|
||||
`targetTime = host.currentTime + (host.playbackState==='playing' ? (now - host.lastHeartbeat)/1000 : 0)`.
|
||||
Genauigkeit ~±1s, für Watchparty ok. (Force-Sync-Maschinerie als Referenz für Ziel-Zeit-Koordination.)
|
||||
- [ ] Nachricht an content.js: `{ type: 'HOST_BLOCK', action, targetTime, hostPlaybackState }`.
|
||||
|
||||
### 4. content.js (Player-Reaktion + Dialog)
|
||||
- [ ] Handler für `HOST_BLOCK`: Confirm-Dialog im Player-Overlay rendern:
|
||||
"Pause only your own player and desync from the group? [Yes] [No]".
|
||||
- **No** (Default): Player via bestehende `_setSuppress`-Mechanik ([content.js:442](../extension/content.js:442)) wieder in Raum-Zustand zwingen (play + seek auf targetTime). Suppress verhindert Re-Broadcast.
|
||||
- **Yes:** lokal pausiert lassen, `isDesynced = true` setzen, dezenten "Desynced — Resync"-Button zeigen.
|
||||
- [ ] "Resync"-Button → snappt zurück auf aktuelle Raum-Zeit, `isDesynced = false`.
|
||||
- [ ] **Loop-Schutz:** nach einer Snap-Back-Aktion kurzes Cooldown-Fenster (z.B. 600ms), in dem weitere lokale pause/seek-Events nicht erneut den Dialog triggern (verhindert pause→play→pause-Pingpong).
|
||||
|
||||
### 5. popup (Host-UI)
|
||||
- [ ] Host-Toggle "Only I can control" (nur sichtbar wenn `amHost()`), sendet `SET_CONTROL_MODE`.
|
||||
- [ ] Rollen-Badge: "Host" / "Guest" + aktueller Modus.
|
||||
- [ ] Gast-Hinweis wenn host-only aktiv: "The host controls playback".
|
||||
- [ ] i18n-Keys in `extension/_locales` / `locales` für ~15 Sprachen.
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases (Test-Checkliste)
|
||||
|
||||
1. **Pause nicht verhinderbar, nur revidierbar** → kurzer Flicker (~½s) beim Gast ist erwartet/akzeptabel.
|
||||
2. **Snap-Back-Zielzeit** aus Heartbeat extrapoliert, ±1s. Bei stark veraltetem Host-State (kein Heartbeat) → letzten bekannten Wert nehmen.
|
||||
3. **Kampf-Loop pause/play/skip back/pause/play** → Cooldown-Fenster nach Snap-Back. Testen mit aggressivem Mashing.
|
||||
4. **Desync-Escape:** Gast kann bewusst pausieren (Klo/Telefon) → "Yes" → solo, dann Resync.
|
||||
5. **Host verlässt Raum** → Fallback auf `everyone`, alle bekommen `CONTROL_MODE`-Update. Testen: Host schließt Tab / Disconnect / Netzabbruch.
|
||||
6. **host-only + Episode-Auto-Sync / Force-Sync** (server/index.js:503-516): **Gast darf NICHT initiieren.** Force-Sync trägt eine `targetTime` und zwingt ALLE darauf (background.js:1261) — ein Gast könnte seeken → Force-Sync spammen und damit host-only komplett aushebeln. Episode-Lobby pausiert ebenfalls alle. → Im host-only-Modus dürfen `FORCE_SYNC_PREPARE`/`FORCE_SYNC_EXECUTE` und `EPISODE_LOBBY` nur vom Host **initiiert** werden. Gäste dürfen weiterhin nur **reagieren**: `FORCE_SYNC_ACK`, `EPISODE_READY`. Gäste brauchen Force-Sync nicht — ihr legitimer Fall ist der "Resync"-Button (snappt nur sie selbst, nicht alle).
|
||||
7. **Alter Client (ohne Feature) in host-only-Raum** → kennt Modus nicht, sendet weiter pause/seek → andere Extensions wenden es an. Mitigation: Empfänger-seitiges Gate (host-only-Clients ignorieren play/pause/seek von Nicht-Host) ODER `MIN_VERSION`/Protokoll-Bump. → **Empfehlung: zusätzlich Empfänger-seitig filtern** (robuster als nur Sender-Gate).
|
||||
8. **Seek getrennt von Pause** → host-only blockt auch Gast-Seeks, nicht nur Pausen.
|
||||
9. **Mehrere Tabs / Multi-Peer mit gleicher peerId** (Dedup, server:381) → Host-Identität bleibt an peerId hängen, ok.
|
||||
10. **DAU-Verwirrung** "warum kann ich nicht mehr pausieren?" → klare UI-Botschaft + der Desync-Dialog erklärt sich selbst.
|
||||
|
||||
## Architektur-Entscheidung zu Edge Case 7 (wichtig)
|
||||
Wir setzen das Gate **doppelt** und über **alle raum-verschiebenden Events**, nicht nur play/pause/seek:
|
||||
Geblockte Initiierungen für Nicht-Host im host-only-Modus:
|
||||
`PLAY`, `PAUSE`, `SEEK`, `FORCE_SYNC_PREPARE`, `FORCE_SYNC_EXECUTE`, `EPISODE_LOBBY`.
|
||||
Weiterhin erlaubt für Gäste (reine Reaktion, verschiebt niemanden): `FORCE_SYNC_ACK`, `EPISODE_READY`, `PEER_STATUS`, `PING`/`PONG`.
|
||||
|
||||
- **Sender-seitig** (Gast sendet erst gar nicht) → saubere UX, Confirm-Dialog bei play/pause/seek; Force-Sync-/Episode-Lobby-Buttons im Gast-UI deaktiviert/ausgeblendet.
|
||||
- **Empfänger-seitig** (in `handleServerEvent`, background.js:969 + Force-Sync-/Episode-Cases): wenn `host-only` und `data.senderId !== hostPeerId` → Event verwerfen (nicht an Content routen, keine State-Mutation).
|
||||
So sind auch alte/buggy/manipulierte Clients abgedeckt, ohne harten Protokoll-Bump.
|
||||
|
||||
Optional zusätzlich **server-seitig** in den `relayEvents` (server/index.js:445): im host-only-Modus Initiierungs-Events von Nicht-Host gar nicht erst relayen. Spart Traffic + deckt alles zentral ab. Empfehlenswert, da der Server `hostPeerId`/`controlMode` ohnehin kennt.
|
||||
|
||||
---
|
||||
|
||||
## Reihenfolge der Umsetzung (kleine, testbare Schritte)
|
||||
1. Constants + Build (Events da, nichts kaputt).
|
||||
2. Server: hostPeerId/controlMode + ROOM_DATA + SET_CONTROL_MODE + Migration.
|
||||
3. background.js: State übernehmen + Empfänger-seitiges Gate (Edge 7) — testbar ohne UI.
|
||||
4. background.js: Sender-seitiges Gate + Snap-Back-Zielzeit.
|
||||
5. content.js: Snap-Back-Apply + Confirm-Dialog + Loop-Cooldown.
|
||||
6. popup: Host-Toggle + Badge + i18n.
|
||||
7. Durchtesten der Edge-Case-Liste auf YT / Netflix / generischem HTML5-Player.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Host Control Mode
|
||||
|
||||
This document describes the Host Control Mode implementation in the relay and
|
||||
extension. It only covers behavior implemented in the current codebase.
|
||||
|
||||
## Modes
|
||||
|
||||
### `everyone`
|
||||
|
||||
- Default room mode.
|
||||
- Any peer may send room-moving playback events.
|
||||
|
||||
### `host-only`
|
||||
|
||||
- Only controllers may send room-moving playback events.
|
||||
- The host is always a controller.
|
||||
- The host can promote additional peers to controllers.
|
||||
- Guests can keep watching locally in solo/desynced mode, but their local actions
|
||||
still do not drive the shared room.
|
||||
|
||||
Room-moving events are:
|
||||
|
||||
- `play`
|
||||
- `pause`
|
||||
- `seek`
|
||||
- `force_sync_prepare`
|
||||
- `force_sync_execute`
|
||||
- `episode_lobby`
|
||||
- `episode_lobby_cancel`
|
||||
|
||||
Heartbeats, force-sync ACKs, episode-ready events, ping/pong, and command ACKs
|
||||
remain allowed for guests.
|
||||
|
||||
## Server State
|
||||
|
||||
Rooms store Host Control state in memory:
|
||||
|
||||
```js
|
||||
{
|
||||
hostPeerId,
|
||||
controlMode,
|
||||
controllers,
|
||||
lastControlModeChangeAt,
|
||||
lastRoleChangeAt,
|
||||
forceSyncInitiator
|
||||
}
|
||||
```
|
||||
|
||||
`controllers` is a `Set` on the server and is serialized as an array in
|
||||
`room_data` and `control_mode`.
|
||||
|
||||
State is not persisted across relay restarts.
|
||||
|
||||
## Authority Rules
|
||||
|
||||
### Changing mode
|
||||
|
||||
Only `hostPeerId` may send `set_control_mode`.
|
||||
|
||||
Valid values:
|
||||
|
||||
- `everyone`
|
||||
- `host-only`
|
||||
|
||||
Invalid values are ignored. Non-host attempts are ignored and the sender receives
|
||||
the current `control_mode` snapshot so optimistic UI can revert.
|
||||
|
||||
Mode changes are debounced per room for 500 ms.
|
||||
|
||||
### Promoting and demoting controllers
|
||||
|
||||
Only `hostPeerId` may send `set_peer_role`.
|
||||
|
||||
The host cannot demote themself. No-op role changes are ignored. Role changes are
|
||||
debounced per room for 500 ms.
|
||||
|
||||
### Host leaving
|
||||
|
||||
When the host leaves and peers remain:
|
||||
|
||||
- the next peer becomes `hostPeerId`;
|
||||
- `controlMode` falls back to `everyone`;
|
||||
- `controllers` is reset to the new host;
|
||||
- the relay broadcasts `control_mode`.
|
||||
|
||||
When a non-host controller leaves, the relay removes that peer from
|
||||
`controllers` and broadcasts `control_mode`.
|
||||
|
||||
## Enforcement
|
||||
|
||||
The implementation has two enforcement points:
|
||||
|
||||
- The extension background script blocks local guest attempts in `host-only` and
|
||||
sends `HOST_BLOCKED` to the content script for local UX.
|
||||
- The relay drops room-moving events from non-controllers in `host-only`, so old
|
||||
or modified clients cannot drive the room.
|
||||
|
||||
The relay is the authority for room-wide effects.
|
||||
|
||||
## Guest UX
|
||||
|
||||
When a guest action is blocked locally, the content script classifies it:
|
||||
|
||||
- deliberate user action: show the host-control dialog;
|
||||
- likely involuntary player action (buffering, tab refocus, no recent gesture):
|
||||
silently snap back when safe;
|
||||
- live/DVR stream: degrade without forcing snap-back.
|
||||
|
||||
The dialog offers:
|
||||
|
||||
- stay in sync: resync to the host;
|
||||
- watch on my own: enter solo/desynced mode.
|
||||
|
||||
In solo/desynced mode:
|
||||
|
||||
- the guest can control their local video;
|
||||
- host room commands are ignored locally, except force-sync preparation is ACKed
|
||||
so the host's flow can continue;
|
||||
- the guest can resync to the host.
|
||||
|
||||
The extension reports `desynced` in peer status so the host UI can show that a
|
||||
guest is watching solo.
|
||||
|
||||
## Force Sync Edge Case
|
||||
|
||||
The relay tracks `forceSyncInitiator` after a controller sends
|
||||
`force_sync_prepare`.
|
||||
|
||||
This allows that same initiator's `force_sync_execute` through even if their
|
||||
controller role changes before execute arrives. Without this, a demotion in the
|
||||
middle of a force-sync flow could leave peers waiting after prepare.
|
||||
|
||||
The relay clears `forceSyncInitiator` after execute or when the initiator leaves.
|
||||
|
||||
## Capabilities
|
||||
|
||||
The relay advertises Host Control support in `room_data.capabilities`:
|
||||
|
||||
- `host-control`
|
||||
- `co-host`
|
||||
|
||||
The extension hides or disables matching UI when capabilities are missing.
|
||||
|
||||
## Related Events
|
||||
|
||||
See [PROTOCOL.md](PROTOCOL.md) for payloads and relay behavior for:
|
||||
|
||||
- `set_control_mode`
|
||||
- `control_mode`
|
||||
- `set_peer_role`
|
||||
- host-only gated relay events
|
||||
@@ -1,6 +1,8 @@
|
||||
import noUnsanitized from "eslint-plugin-no-unsanitized";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["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
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { EVENTS, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js';
|
||||
import { generateUsername } from './shared/names.js';
|
||||
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
|
||||
import { sameEpisode } from './episode-utils.js';
|
||||
import { sameEpisode, extractEpisodeId } from './episode-utils.js';
|
||||
import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js';
|
||||
import { initTabManager } from './modules/tab-manager.js';
|
||||
import './page-api-seek-overrides.js';
|
||||
|
||||
// --- Uninstall URL Initialization ---
|
||||
let uninstallURLInitPromise = null;
|
||||
@@ -789,13 +790,13 @@ function updateBadgeStatus() {
|
||||
|
||||
if (status === 'reconnecting') {
|
||||
chrome.action.setBadgeText({ text: '...' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#f59e0b' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#c96736' });
|
||||
} else if (status === 'connecting') {
|
||||
chrome.action.setBadgeText({ text: '...' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#fbbf24' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#de7949' });
|
||||
} else if (status === 'connected' && currentRoom && currentTabId) {
|
||||
chrome.action.setBadgeText({ text: 'ON' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#22c55e' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#56ae6c' });
|
||||
} else {
|
||||
chrome.action.setBadgeText({ text: '' });
|
||||
}
|
||||
@@ -1324,6 +1325,9 @@ function handleServerEvent(event, data) {
|
||||
}
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
|
||||
if (episodeLobby) {
|
||||
checkEpisodeLobbyCompletion();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1592,6 +1596,188 @@ async function routeToContent(action, payload) {
|
||||
_routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, 0);
|
||||
}
|
||||
|
||||
function getTabVideoState(tabId) {
|
||||
return new Promise((resolve) => {
|
||||
chrome.tabs.sendMessage(tabId, { type: 'GET_VIDEO_STATE' }, (res) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
resolve({ error: chrome.runtime.lastError.message });
|
||||
return;
|
||||
}
|
||||
resolve(res);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getReadyTabVideoState(tabId) {
|
||||
let state = await getTabVideoState(tabId);
|
||||
if (!state || state.error) {
|
||||
await injectContentScript(tabId);
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
state = await getTabVideoState(tabId);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async function simulateRemoteSeek(delta, explicitTargetTime = null) {
|
||||
if (!currentTabId) return { status: 'no_tab' };
|
||||
const tabId = parseInt(currentTabId);
|
||||
if (isNaN(tabId)) return { status: 'no_tab' };
|
||||
|
||||
const state = await getReadyTabVideoState(tabId);
|
||||
if (!state || state.error) return { status: 'error', message: state?.error || 'No video state' };
|
||||
if (!state.found || !Number.isFinite(state.currentTime)) return { status: 'no_video' };
|
||||
|
||||
let targetTime = explicitTargetTime !== null ? explicitTargetTime : Math.max(0, state.currentTime + (delta || 0));
|
||||
if (Number.isFinite(state.duration) && state.duration > 0) {
|
||||
targetTime = Math.min(targetTime, Math.max(0, state.duration - 0.1));
|
||||
}
|
||||
|
||||
const senderId = 'KoalaDev';
|
||||
const timestamp = Date.now();
|
||||
const payload = {
|
||||
senderId,
|
||||
actionTimestamp: timestamp,
|
||||
currentTime: targetTime,
|
||||
targetTime
|
||||
};
|
||||
|
||||
addToHistory(EVENTS.SEEK, senderId);
|
||||
showNotification(senderId, EVENTS.SEEK);
|
||||
updateLastAction(EVENTS.SEEK, senderId, timestamp);
|
||||
lastActionState.targetTime = targetTime;
|
||||
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
||||
updateLocalPeerState(senderId, { currentTime: targetTime });
|
||||
routeToContent(EVENTS.SEEK, payload);
|
||||
|
||||
return { status: 'ok', targetTime };
|
||||
}
|
||||
|
||||
async function devRemoteToolsAllowed() {
|
||||
const data = await chrome.storage.local.get(['username']);
|
||||
return data.username === 'KoalaDev';
|
||||
}
|
||||
|
||||
function shouldUsePageApiSeek(url) {
|
||||
return typeof globalThis.koalaFindPageApiSeekProvider === 'function' &&
|
||||
!!globalThis.koalaFindPageApiSeekProvider(url);
|
||||
}
|
||||
|
||||
function installPageApiSeekBridge() {
|
||||
if (window.__koalaPageApiSeekBridgeInstalled) return;
|
||||
window.__koalaPageApiSeekBridgeInstalled = true;
|
||||
|
||||
function currentMatch() {
|
||||
return typeof window.koalaFindPageApiSeekProvider === 'function'
|
||||
? window.koalaFindPageApiSeekProvider(window.location.hostname)
|
||||
: null;
|
||||
}
|
||||
|
||||
// Disney+ ("hive"/BAM) player: the real media player hangs off the
|
||||
// <disney-web-player> custom element as `.mediaPlayer`, exposing precise
|
||||
// seek(ms) and timeline.info (playhead/duration in ms).
|
||||
function disneyMediaPlayer() {
|
||||
const el = document.querySelector('disney-web-player');
|
||||
return el && el.mediaPlayer ? el.mediaPlayer : null;
|
||||
}
|
||||
|
||||
function seekWithPageApi(time) {
|
||||
const match = currentMatch();
|
||||
if (!match) return;
|
||||
|
||||
try {
|
||||
if (match.provider === 'netflix') {
|
||||
const videoPlayer = window.netflix?.appContext?.state?.playerApp?.getAPI?.().videoPlayer;
|
||||
const ids = videoPlayer?.getAllPlayerSessionIds?.();
|
||||
const sessionId = ids ? ids[0] : null;
|
||||
const player = sessionId ? videoPlayer.getVideoPlayerBySessionId(sessionId) : null;
|
||||
player?.seek(Math.round(time * 1000));
|
||||
} else if (match.provider === 'disney') {
|
||||
const mp = disneyMediaPlayer();
|
||||
if (mp && typeof mp.seek === 'function') mp.seek(Math.round(time * 1000));
|
||||
}
|
||||
} catch (_e) {
|
||||
// Player not ready or private API changed; the next sync tick can retry.
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.source !== window) return;
|
||||
const data = event.data;
|
||||
if (!data || data.__koalaPageApiSeek !== 1 || data.kind !== 'seek' || typeof data.time !== 'number') return;
|
||||
seekWithPageApi(data.time);
|
||||
});
|
||||
|
||||
// Disney+'s <video> currentTime is blob-relative and its scrubber lags, so
|
||||
// the isolated-world content script can't read an accurate position. Push
|
||||
// the real playhead/duration (seconds) from the page's media player.
|
||||
setInterval(() => {
|
||||
try {
|
||||
const match = currentMatch();
|
||||
if (!match || match.provider !== 'disney') return;
|
||||
const mp = disneyMediaPlayer();
|
||||
const info = mp && mp.timeline && mp.timeline.info;
|
||||
if (!info || typeof info.playheadPositionMs !== 'number' || typeof info.programDurationMs !== 'number') return;
|
||||
if (info.programDurationMs <= 0) return;
|
||||
window.postMessage({
|
||||
__koalaPlayerTime: 1,
|
||||
provider: 'disney',
|
||||
position: info.playheadPositionMs / 1000,
|
||||
duration: info.programDurationMs / 1000
|
||||
}, '*');
|
||||
} catch (_e) {
|
||||
// Ignore transient errors (player teardown / element swap).
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function setPageApiSeekEnabled(enabled) {
|
||||
window.KOALA_PAGE_API_SEEK_ENABLED = enabled === true;
|
||||
}
|
||||
|
||||
async function injectContentScript(tabId) {
|
||||
let needsPageApiSeek = false;
|
||||
let pageApiSeekReady = false;
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
const url = tab?.url || '';
|
||||
needsPageApiSeek = shouldUsePageApiSeek(url);
|
||||
} catch (_e) {
|
||||
// Fall through to the generic content script injection.
|
||||
}
|
||||
|
||||
if (needsPageApiSeek) {
|
||||
try {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
world: 'MAIN',
|
||||
files: ['page-api-seek-overrides.js']
|
||||
});
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
world: 'MAIN',
|
||||
func: installPageApiSeekBridge
|
||||
});
|
||||
pageApiSeekReady = true;
|
||||
} catch (err) {
|
||||
addLog(`Page API seek bridge injection failed: ${err.message}`, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['page-api-seek-overrides.js']
|
||||
});
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: setPageApiSeekEnabled,
|
||||
args: [pageApiSeekReady]
|
||||
});
|
||||
return chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['content.js']
|
||||
});
|
||||
}
|
||||
|
||||
function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries) {
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
type: 'SERVER_COMMAND',
|
||||
@@ -1606,10 +1792,7 @@ function _routeToContentInternal(tabId, action, payload, actionTimestamp, comman
|
||||
return;
|
||||
}
|
||||
if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['content.js']
|
||||
}).then(() => {
|
||||
injectContentScript(tabId).then(() => {
|
||||
setTimeout(() => _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries + 1), 500);
|
||||
}).catch(_err => {
|
||||
addLog(`Auto-reinject failed for tab ${tabId}`, 'warn');
|
||||
@@ -1997,6 +2180,22 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
sendResponse(res);
|
||||
}
|
||||
});
|
||||
} else if (message.type === 'DEV_SIMULATE_REMOTE_SEEK') {
|
||||
if (!(await devRemoteToolsAllowed())) {
|
||||
sendResponse({ status: 'forbidden' });
|
||||
return;
|
||||
}
|
||||
const delta = message.delta !== null && message.delta !== undefined ? Number(message.delta) : null;
|
||||
const targetTime = message.targetTime !== null && message.targetTime !== undefined ? Number(message.targetTime) : null;
|
||||
|
||||
if (delta === null && targetTime === null) {
|
||||
sendResponse({ status: 'invalid_params' });
|
||||
return;
|
||||
}
|
||||
simulateRemoteSeek(delta, targetTime).then(sendResponse).catch(err => {
|
||||
addLog(`Remote seek simulation failed: ${err.message}`, 'warn');
|
||||
sendResponse({ status: 'error', message: err.message });
|
||||
});
|
||||
} else if (message.type === 'CONTENT_EVENT') {
|
||||
const processEvent = async () => {
|
||||
// Host Control Mode (sender-side): a non-controller in host-only mode must
|
||||
@@ -2031,12 +2230,40 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = message.payload && typeof message.payload === 'object' ? message.payload : {};
|
||||
const payloadNumber = (value) => value !== undefined && value !== null && value !== '' ? Number(value) : NaN;
|
||||
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
|
||||
const targetTime = payloadNumber(payload.targetTime);
|
||||
if (!Number.isFinite(targetTime)) {
|
||||
sendResponse({ status: 'invalid_params' });
|
||||
return;
|
||||
}
|
||||
payload.targetTime = targetTime;
|
||||
} else if (message.action === EVENTS.SEEK) {
|
||||
const targetTime = payloadNumber(payload.targetTime !== undefined ? payload.targetTime : payload.currentTime);
|
||||
if (!Number.isFinite(targetTime)) {
|
||||
sendResponse({ status: 'invalid_params' });
|
||||
return;
|
||||
}
|
||||
payload.currentTime = targetTime;
|
||||
payload.targetTime = targetTime;
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
localSeq++;
|
||||
chrome.storage.session.set({ localSeq });
|
||||
updateLastAction(message.action, 'You', timestamp);
|
||||
|
||||
const payload = message.payload || {};
|
||||
const hasPlaybackTime = Number.isFinite(payload.currentTime) || Number.isFinite(payload.targetTime);
|
||||
if (!sender?.tab && (message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE) && !hasPlaybackTime) {
|
||||
const tabId = currentTabId ? parseInt(currentTabId) : NaN;
|
||||
if (!isNaN(tabId)) {
|
||||
const state = await getReadyTabVideoState(tabId);
|
||||
if (state && !state.error && state.found && Number.isFinite(state.currentTime)) {
|
||||
payload.currentTime = state.currentTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
lastActionState.targetTime = payload.targetTime !== undefined ? payload.targetTime : payload.currentTime;
|
||||
if (storageInitialized) chrome.storage.session.set({ lastActionState });
|
||||
|
||||
@@ -2050,6 +2277,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
currentTime: payload.currentTime !== undefined ? payload.currentTime : (payload.targetTime !== undefined ? payload.targetTime : undefined)
|
||||
});
|
||||
|
||||
if (!sender?.tab && (message.action === EVENTS.PLAY || message.action === EVENTS.PAUSE || message.action === EVENTS.SEEK)) {
|
||||
routeToContent(message.action, message.payload);
|
||||
}
|
||||
|
||||
if (message.action === EVENTS.FORCE_SYNC_PREPARE) {
|
||||
isForceSyncInitiator = true;
|
||||
forceSyncAcks.clear();
|
||||
@@ -2103,7 +2334,6 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
sendResponse({ status: 'error' });
|
||||
});
|
||||
} else {
|
||||
routeToContent(message.action, message.payload);
|
||||
processEvent().catch(err => {
|
||||
addLog('Content event privacy error: ' + err.message, 'error');
|
||||
sendResponse({ status: 'error' });
|
||||
@@ -2196,6 +2426,20 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
addLog('Heartbeat settings error: ' + err.message, 'error');
|
||||
sendResponse({ status: 'ok' });
|
||||
});
|
||||
} else if (message.type === 'INJECT_CONTENT_SCRIPT') {
|
||||
const tabId = Number(message.tabId);
|
||||
if (!Number.isInteger(tabId)) {
|
||||
sendResponse({ status: 'invalid_tab' });
|
||||
return true;
|
||||
}
|
||||
|
||||
injectContentScript(tabId).then(() => {
|
||||
sendResponse({ status: 'ok' });
|
||||
}).catch(err => {
|
||||
addLog(`Failed to inject into tab: ${err.message}`, 'warn');
|
||||
sendResponse({ status: 'error', message: err.message });
|
||||
});
|
||||
return true;
|
||||
} else if (message.type === 'SET_TARGET_TAB') {
|
||||
const previousTabId = currentTabId;
|
||||
currentTabId = message.tabId;
|
||||
@@ -2213,10 +2457,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
|
||||
if (currentTabId) {
|
||||
const selectedTabId = currentTabId;
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId: selectedTabId },
|
||||
files: ['content.js']
|
||||
})
|
||||
injectContentScript(selectedTabId)
|
||||
.then(() => applyAudioSettingsToTab(selectedTabId))
|
||||
.catch(err => {
|
||||
addLog(`Failed to inject into tab: ${err.message}`, 'warn');
|
||||
@@ -2238,6 +2479,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
|
||||
const newTitle = message.payload && message.payload.newTitle;
|
||||
if (newTitle && extractEpisodeId(newTitle) === null) {
|
||||
addLog(`Episode change detected ("${newTitle}") but no episode ID was found; ignoring.`, 'info');
|
||||
sendResponse({ status: 'not_an_episode' });
|
||||
return;
|
||||
}
|
||||
if (!newTitle) {
|
||||
sendResponse({ status: 'no_title' });
|
||||
return;
|
||||
@@ -2328,6 +2574,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
|
||||
sendResponse({ status: 'lobby_created' });
|
||||
} else if (message.type === 'EPISODE_READY_LOCAL') {
|
||||
if (sender.tab) {
|
||||
const senderTabId = sender.tab.id;
|
||||
if (!currentTabId || currentTabId !== senderTabId) {
|
||||
sendResponse({ status: 'ignored_unselected_tab' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Content script confirmed it loaded the lobby episode
|
||||
if (episodeLobby && message.payload && sameEpisode(message.payload.title, episodeLobby.expectedTitle)) {
|
||||
if (!episodeLobby.readyPeers.includes(peerId)) {
|
||||
@@ -2366,6 +2619,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'CONTENT_BOOT') {
|
||||
if (sender.tab) {
|
||||
const senderTabId = sender.tab.id;
|
||||
if (!currentTabId || currentTabId !== senderTabId) {
|
||||
sendResponse({ status: 'ignored_unselected_tab' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Content script re-injected, check if there's an active lobby
|
||||
if (episodeLobby) {
|
||||
sendResponse({ lobbyActive: true, expectedTitle: episodeLobby.expectedTitle });
|
||||
@@ -2399,6 +2659,7 @@ initTabManager({
|
||||
getSettings,
|
||||
emit,
|
||||
applyAudioSettingsToTab,
|
||||
injectContentScript,
|
||||
ensureState,
|
||||
EVENTS
|
||||
});
|
||||
|
||||
@@ -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!
|
||||
*/
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -209,6 +209,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 +239,9 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -209,6 +209,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 +239,9 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -209,6 +209,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 +239,9 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -209,6 +209,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 +239,9 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -209,6 +209,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 +239,9 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -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": "自分に合わせる",
|
||||
@@ -209,6 +209,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "動画再生に圧縮などのオーディオエフェクトを適用します",
|
||||
"AUDIO_OPEN_SETTINGS": "開く",
|
||||
"NEW_FEATURE_AUDIO": "新機能: オーディオ処理 — コンプレッサーを試してみよう!",
|
||||
"NEW_FEATURE_THEME": "新機能:テーマ — クラシックな外観やミニマルなグラファイトに切り替え。",
|
||||
"AUDIO_BACK": "← 戻る",
|
||||
"AUDIO_PAGE_TITLE": "オーディオ設定",
|
||||
"AUDIO_MASTER_TOGGLE": "オーディオ処理",
|
||||
@@ -238,5 +239,9 @@
|
||||
"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": "再生、通知、オーディオ設定をカスタマイズします。"
|
||||
}
|
||||
|
||||
@@ -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": "나에게 이동",
|
||||
@@ -209,6 +209,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "비디오 재생에 압축과 같은 오디오 효과를 적용합니다",
|
||||
"AUDIO_OPEN_SETTINGS": "열기",
|
||||
"NEW_FEATURE_AUDIO": "신규: 오디오 처리 — 컴프레서를 사용해보세요!",
|
||||
"NEW_FEATURE_THEME": "신규: 테마 — 클래식 룩이나 미니멀 그래파이트로 전환하세요.",
|
||||
"AUDIO_BACK": "← 뒤로",
|
||||
"AUDIO_PAGE_TITLE": "오디오 설정",
|
||||
"AUDIO_MASTER_TOGGLE": "오디오 처리",
|
||||
@@ -238,5 +239,9 @@
|
||||
"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": "재생, 알림 및 오디오 설정을 맞춤설정합니다."
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -209,6 +209,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 +239,9 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -209,6 +209,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 +239,9 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -209,6 +209,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 +239,9 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -209,6 +209,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 +239,9 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -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": "Синхронизировать по мне",
|
||||
@@ -209,6 +209,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Применяет аудиоэффекты, такие как сжатие, к воспроизведению видео",
|
||||
"AUDIO_OPEN_SETTINGS": "Открыть",
|
||||
"NEW_FEATURE_AUDIO": "Новое: Обработка звука — попробуйте компрессор!",
|
||||
"NEW_FEATURE_THEME": "Новое: Темы — переключитесь на классический вид или минимальный графит.",
|
||||
"AUDIO_BACK": "← Назад",
|
||||
"AUDIO_PAGE_TITLE": "Настройки звука",
|
||||
"AUDIO_MASTER_TOGGLE": "Обработка звука",
|
||||
@@ -238,5 +239,9 @@
|
||||
"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": "Настройте воспроизведение, уведомления и параметры звука."
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -209,6 +209,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 +239,9 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -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": "Перейти до мене",
|
||||
@@ -209,6 +209,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Застосуйте звукові ефекти, такі як стиснення, до відтворення відео",
|
||||
"AUDIO_OPEN_SETTINGS": "Відкрити",
|
||||
"NEW_FEATURE_AUDIO": "Новинка: обробка звуку — спробуйте компресор!",
|
||||
"NEW_FEATURE_THEME": "Нове: Теми — перемкніться на класичний вигляд або мінімальний графіт.",
|
||||
"AUDIO_BACK": "← Назад",
|
||||
"AUDIO_PAGE_TITLE": "Параметри звуку",
|
||||
"AUDIO_MASTER_TOGGLE": "Обробка аудіо",
|
||||
@@ -238,5 +239,9 @@
|
||||
"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": "Налаштуйте відтворення, сповіщення та параметри звуку."
|
||||
}
|
||||
|
||||
@@ -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": "跳到我这里",
|
||||
@@ -209,6 +209,7 @@
|
||||
"LABEL_AUDIO_PROCESSING_TOOLTIP": "将压缩等音频效果应用于视频播放",
|
||||
"AUDIO_OPEN_SETTINGS": "打开",
|
||||
"NEW_FEATURE_AUDIO": "新功能:音频处理 - 尝试压缩器!",
|
||||
"NEW_FEATURE_THEME": "新功能:主题 — 切换到经典外观或极简石墨风格。",
|
||||
"AUDIO_BACK": "← 返回",
|
||||
"AUDIO_PAGE_TITLE": "音频设置",
|
||||
"AUDIO_MASTER_TOGGLE": "音频处理",
|
||||
@@ -238,5 +239,9 @@
|
||||
"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": "自定义播放、通知和音频首选项。"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
"default_locale": "en",
|
||||
"name": "KoalaSync",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
|
||||
@@ -12,6 +12,7 @@ export function initTabManager({
|
||||
getSettings,
|
||||
emit,
|
||||
applyAudioSettingsToTab,
|
||||
injectContentScript,
|
||||
ensureState,
|
||||
EVENTS
|
||||
}) {
|
||||
@@ -83,10 +84,7 @@ export function initTabManager({
|
||||
await ensureState();
|
||||
const curTabId = getCurrentTabId();
|
||||
if (curTabId && tabId === parseInt(curTabId) && changeInfo.status === 'complete') {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['content.js']
|
||||
})
|
||||
injectContentScript(tabId)
|
||||
.then(() => applyAudioSettingsToTab(tabId))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
(function(root) {
|
||||
const PAGE_API_SEEK_FIXES = [
|
||||
{
|
||||
name: 'netflix-page-api-seek',
|
||||
urls: ['netflix.com'],
|
||||
provider: 'netflix'
|
||||
},
|
||||
{
|
||||
name: 'disney-page-api-seek',
|
||||
urls: ['disneyplus.com'],
|
||||
provider: 'disney'
|
||||
}
|
||||
];
|
||||
|
||||
function normalizeHost(input) {
|
||||
try {
|
||||
return new URL(input).hostname.toLowerCase();
|
||||
} catch (_e) {
|
||||
return String(input || '').toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function matchesDomain(host, domain) {
|
||||
const normalizedDomain = normalizeHost(domain);
|
||||
return normalizedDomain && (host === normalizedDomain || host.endsWith(`.${normalizedDomain}`));
|
||||
}
|
||||
|
||||
root.KOALA_PAGE_API_SEEK_FIXES = PAGE_API_SEEK_FIXES;
|
||||
root.KOALA_PAGE_API_SEEK_PROVIDERS = PAGE_API_SEEK_FIXES;
|
||||
root.koalaFindPageApiSeekProvider = (input) => {
|
||||
const host = normalizeHost(input);
|
||||
return PAGE_API_SEEK_FIXES.find(entry =>
|
||||
Array.isArray(entry.urls) && entry.urls.some(url => matchesDomain(host, url))
|
||||
) || null;
|
||||
};
|
||||
})(globalThis);
|
||||
@@ -2,7 +2,7 @@ import { EVENTS, OFFICIAL_LANDING_PAGE_URL, SUPPORT_URL, getReviewUrl } from './
|
||||
import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
|
||||
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js';
|
||||
import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js';
|
||||
import { TITLE_PRIVACY_MODES, normalizeSendTabTitle } from './title-privacy.js';
|
||||
import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js';
|
||||
|
||||
|
||||
const elements = {
|
||||
@@ -64,6 +64,8 @@ const elements = {
|
||||
autoCopyInvite: document.getElementById('autoCopyInvite'),
|
||||
cancelLobbyBtn: document.getElementById('cancelLobbyBtn'),
|
||||
langSelector: document.getElementById('langSelector'),
|
||||
themeSelector: document.getElementById('themeSelector'),
|
||||
paletteSelector: document.getElementById('paletteSelector'),
|
||||
|
||||
audioSettingsLink: document.getElementById('audioSettingsLink'),
|
||||
settingsSupportLink: document.getElementById('settingsSupportLink'),
|
||||
@@ -71,7 +73,11 @@ const elements = {
|
||||
settingsVersion: document.getElementById('settingsVersion'),
|
||||
devSupportLink: document.getElementById('devSupportLink'),
|
||||
devReviewLink: document.getElementById('devReviewLink'),
|
||||
syncTabCopyInvite: document.getElementById('syncTabCopyInvite')
|
||||
syncTabCopyInvite: document.getElementById('syncTabCopyInvite'),
|
||||
devToolsTabBtn: document.getElementById('devToolsTabBtn'),
|
||||
remoteSeekBack: document.getElementById('remoteSeekBack'),
|
||||
remoteSeekForward: document.getElementById('remoteSeekForward'),
|
||||
remoteSeekFiveMin: document.getElementById('remoteSeekFiveMin')
|
||||
};
|
||||
|
||||
let localPeerId = null;
|
||||
@@ -88,6 +94,19 @@ let errorToken = 0;
|
||||
let forceSyncDone = false;
|
||||
let connectionErrorTimer = null;
|
||||
let pendingConnectionErrorMsg = null;
|
||||
|
||||
function devToolsEnabled() {
|
||||
return elements.username && elements.username.value.trim() === 'KoalaDev';
|
||||
}
|
||||
|
||||
function syncDevToolsVisibility() {
|
||||
if (!elements.devToolsTabBtn) return;
|
||||
const enabled = devToolsEnabled();
|
||||
elements.devToolsTabBtn.style.display = enabled ? '' : 'none';
|
||||
if (!enabled && document.getElementById('tab-devtools')?.classList.contains('active')) {
|
||||
document.querySelector('.tab-btn[data-tab="tab-settings"]')?.click();
|
||||
}
|
||||
}
|
||||
let roomListRefreshTimer = null;
|
||||
let roomListRefreshInterval = null;
|
||||
const ROOM_LIST_REFRESH_COOLDOWN_MS = 11000;
|
||||
@@ -97,6 +116,12 @@ const FEATURE_HINTS = [
|
||||
selector: '#audioProcessingLabel',
|
||||
position: 'beforebegin',
|
||||
i18nTooltip: 'NEW_FEATURE_AUDIO'
|
||||
},
|
||||
{
|
||||
key: 'theme_picker',
|
||||
selector: '#paletteLabel',
|
||||
position: 'afterend',
|
||||
i18nTooltip: 'NEW_FEATURE_THEME'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -115,6 +140,118 @@ function openAudioSettingsPage() {
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL('audio-options.html') });
|
||||
}
|
||||
|
||||
function localizeThemeSelector(locale) {
|
||||
const labels = {
|
||||
de: ['Darstellung', 'System', 'Dunkel', 'Hell', 'Folge dem System-Design oder wähle ein festes Design'],
|
||||
fr: ['Apparence', 'Système', 'Sombre', 'Clair', 'Suivre le thème du système ou choisir une apparence fixe'],
|
||||
es: ['Apariencia', 'Sistema', 'Oscuro', 'Claro', 'Seguir el tema del sistema o elegir una apariencia fija'],
|
||||
it: ['Aspetto', 'Sistema', 'Scuro', 'Chiaro', 'Segui il tema di sistema o scegli un aspetto fisso'],
|
||||
nl: ['Weergave', 'Systeem', 'Donker', 'Licht', 'Volg het systeemthema of kies een vaste weergave'],
|
||||
pl: ['Wygląd', 'System', 'Ciemny', 'Jasny', 'Dostosuj do motywu systemowego lub wybierz stały wygląd'],
|
||||
pt: ['Aparência', 'Sistema', 'Escuro', 'Claro', 'Seguir o tema do sistema ou escolher uma aparência fixa'],
|
||||
'pt-BR': ['Aparência', 'Sistema', 'Escuro', 'Claro', 'Seguir o tema do sistema ou escolher uma aparência fixa'],
|
||||
tr: ['Görünüm', 'Sistem', 'Koyu', 'Açık', 'Sistem temasını takip et veya sabit bir görünüm seç'],
|
||||
ru: ['Оформление', 'Системная', 'Тёмная', 'Светлая', 'Следовать системной теме или выбрать фиксированное оформление'],
|
||||
ja: ['外観', 'システム', 'ダーク', 'ライト', 'システムテーマに従うか、固定の外観を選択します'],
|
||||
ko: ['테마', '시스템', '다크', '라이트', '시스템 테마를 따르거나 고정된 테마를 선택하세요'],
|
||||
zh: ['外观', '跟随系统', '深色', '浅色', '跟随系统主题或选择固定外观'],
|
||||
uk: ['Вигляд', 'Системна', 'Темна', 'Світла', 'Дотримуватися системної теми або вибрати фіксоване оформлення'],
|
||||
en: ['Appearance', 'System', 'Dark', 'Light', 'Follow the system theme or choose a fixed appearance']
|
||||
};
|
||||
const [label, system, dark, light, tooltip] = labels[locale] || labels.en;
|
||||
const labelEl = document.getElementById('themeLabel');
|
||||
const systemEl = document.getElementById('themeOptionSystem');
|
||||
const darkEl = document.getElementById('themeOptionDark');
|
||||
const lightEl = document.getElementById('themeOptionLight');
|
||||
const systemButton = document.getElementById('themeChoiceSystem');
|
||||
const darkButton = document.getElementById('themeChoiceDark');
|
||||
const lightButton = document.getElementById('themeChoiceLight');
|
||||
if (labelEl) {
|
||||
labelEl.textContent = label;
|
||||
if (tooltip) labelEl.setAttribute('title', tooltip);
|
||||
}
|
||||
if (systemEl) systemEl.textContent = system;
|
||||
if (darkEl) darkEl.textContent = dark;
|
||||
if (lightEl) lightEl.textContent = light;
|
||||
if (systemButton) systemButton.textContent = system;
|
||||
if (darkButton) darkButton.textContent = dark;
|
||||
if (lightButton) lightButton.textContent = light;
|
||||
if (elements.themeSelector) elements.themeSelector.setAttribute('aria-label', label);
|
||||
|
||||
// The palette scheme names (Eucalyptus / Cyber / Graphite) are brand
|
||||
// words kept verbatim; only the row label is localized.
|
||||
const paletteLabels = {
|
||||
de: ['Farbschema', 'Wähle ein kuratiertes Farbschema für die Erweiterung'],
|
||||
fr: ['Thème', 'Choisissez une palette de couleurs personnalisée pour l\'extension'],
|
||||
es: ['Tema', 'Elige una paleta de colores seleccionada para la extensión'],
|
||||
it: ['Tema', 'Scegli una combinazione di colori curata per l\'estensione'],
|
||||
nl: ['Thema', 'Kies een samengesteld kleurenschema voor de extensie'],
|
||||
pl: ['Motyw', 'Wybierz dopasowany schemat kolorów dla rozszerzenia'],
|
||||
pt: ['Tema', 'Escolha um esquema de cores personalizado para a extensão'],
|
||||
'pt-BR': ['Tema', 'Escolha um esquema de cores personalizado para a extensão'],
|
||||
tr: ['Tema', 'Uzantı için özel olarak seçilmiş bir renk şeması seçin'],
|
||||
ru: ['Тема', 'Выберите цветовую схему для расширения'],
|
||||
ja: ['テーマ', '拡張機能の厳選された配色を選択します'],
|
||||
ko: ['테마', '확장 프로그램에 맞는 특별한 색상 테마를 선택하세요'],
|
||||
zh: ['主题', '为扩展程序选择精心设计的配色方案'],
|
||||
uk: ['Тема', 'Виберіть колірну схему для розширення'],
|
||||
en: ['Theme', 'Pick a curated colour scheme for the extension']
|
||||
};
|
||||
const [paletteLabel, paletteTooltip] = paletteLabels[locale] || paletteLabels.en;
|
||||
const paletteLabelEl = document.getElementById('paletteLabel');
|
||||
if (paletteLabelEl) {
|
||||
paletteLabelEl.textContent = paletteLabel;
|
||||
if (paletteTooltip) paletteLabelEl.setAttribute('title', paletteTooltip);
|
||||
}
|
||||
if (elements.paletteSelector) elements.paletteSelector.setAttribute('aria-label', paletteLabel);
|
||||
|
||||
// Micro-descriptors under each swatch card: [nature, classic, minimal].
|
||||
const paletteDescs = {
|
||||
de: ['Natur', 'Klassik', 'Minimal'], fr: ['Nature', 'Classique', 'Minimal'],
|
||||
es: ['Naturaleza', 'Clásico', 'Minimal'], it: ['Natura', 'Classico', 'Minimal'],
|
||||
nl: ['Natuur', 'Klassiek', 'Minimaal'], pl: ['Natura', 'Klasyczny', 'Minimal'],
|
||||
pt: ['Natureza', 'Clássico', 'Minimal'], 'pt-BR': ['Natureza', 'Clássico', 'Minimal'],
|
||||
tr: ['Doğa', 'Klasik', 'Minimal'], ru: ['Природа', 'Классика', 'Минимал'],
|
||||
ja: ['ネイチャー', 'クラシック', 'ミニマル'], ko: ['자연', '클래식', '미니멀'],
|
||||
zh: ['自然', '经典', '极简'], uk: ['Природа', 'Класика', 'Мінімал'],
|
||||
en: ['Nature', 'Classic', 'Minimal']
|
||||
};
|
||||
const [natureDesc, classicDesc, minimalDesc] = paletteDescs[locale] || paletteDescs.en;
|
||||
const descMap = { eucalyptus: natureDesc, cyber: classicDesc, graphite: minimalDesc };
|
||||
document.querySelectorAll('.palette-choice').forEach(btn => {
|
||||
const desc = btn.querySelector('.palette-desc');
|
||||
if (desc) desc.textContent = descMap[btn.dataset.paletteValue] || desc.textContent;
|
||||
});
|
||||
}
|
||||
|
||||
function syncThemePicker() {
|
||||
const activeMode = elements.themeSelector?.value || 'system';
|
||||
document.querySelectorAll('.theme-choice').forEach(button => {
|
||||
const active = button.dataset.themeValue === activeMode;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
});
|
||||
}
|
||||
|
||||
function syncPalettePicker() {
|
||||
const activePalette = elements.paletteSelector?.value || 'eucalyptus';
|
||||
document.querySelectorAll('.palette-choice').forEach(button => {
|
||||
const active = button.dataset.paletteValue === activePalette;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
});
|
||||
}
|
||||
|
||||
// Briefly enable global colour transitions so a theme/appearance switch
|
||||
// cross-fades instead of snapping. Self-clearing; a no-op under reduced motion.
|
||||
let themeAnimTimer = null;
|
||||
function flashThemeTransition() {
|
||||
const root = document.documentElement;
|
||||
root.classList.add('theme-anim');
|
||||
if (themeAnimTimer) clearTimeout(themeAnimTimer);
|
||||
themeAnimTimer = setTimeout(() => root.classList.remove('theme-anim'), 300);
|
||||
}
|
||||
|
||||
async function updateFeatureHints() {
|
||||
const data = await chrome.storage.sync.get(['dismissedHints']);
|
||||
const dismissed = Array.isArray(data.dismissedHints) ? data.dismissedHints : [];
|
||||
@@ -187,7 +324,7 @@ function setRoomRefreshCooldown() {
|
||||
async function init() {
|
||||
// Local-only by design — settings and room credentials never come from
|
||||
// storage.sync (only onboardingComplete + dismissedHints live there).
|
||||
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab']);
|
||||
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
|
||||
|
||||
let activeLang = localData.locale;
|
||||
if (!activeLang) {
|
||||
@@ -197,8 +334,13 @@ async function init() {
|
||||
|
||||
await loadLocale(activeLang);
|
||||
translateDOM();
|
||||
localizeThemeSelector(activeLang);
|
||||
|
||||
if (elements.langSelector) elements.langSelector.value = activeLang;
|
||||
if (elements.themeSelector) elements.themeSelector.value = ['dark', 'light'].includes(localData.themeMode) ? localData.themeMode : 'system';
|
||||
if (elements.paletteSelector) elements.paletteSelector.value = ['cyber', 'graphite'].includes(localData.themePalette) ? localData.themePalette : 'eucalyptus';
|
||||
syncThemePicker();
|
||||
syncPalettePicker();
|
||||
|
||||
let username = localData.username;
|
||||
if (!username) {
|
||||
@@ -210,6 +352,7 @@ async function init() {
|
||||
elements.roomId.value = localData.roomId || '';
|
||||
elements.password.value = localData.password || '';
|
||||
elements.username.value = username;
|
||||
syncDevToolsVisibility();
|
||||
if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false;
|
||||
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = localData.autoSyncNextEpisode !== false;
|
||||
const legacyTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.titlePrivacyMode) ? localData.titlePrivacyMode : TITLE_PRIVACY_MODES.FULL;
|
||||
@@ -277,13 +420,13 @@ async function init() {
|
||||
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
|
||||
if (syncTabBtn) syncTabBtn.click();
|
||||
showSelectVideoHint();
|
||||
} else if (localData.activeTab) {
|
||||
} else if (localData.activeTab && (localData.activeTab !== 'tab-devtools' || devToolsEnabled())) {
|
||||
const btn = document.querySelector(`.tab-btn[data-tab="${localData.activeTab}"]`);
|
||||
if (btn) btn.click();
|
||||
}
|
||||
} else {
|
||||
await populateTabs();
|
||||
if (localData.activeTab) {
|
||||
if (localData.activeTab && (localData.activeTab !== 'tab-devtools' || devToolsEnabled())) {
|
||||
const btn = document.querySelector(`.tab-btn[data-tab="${localData.activeTab}"]`);
|
||||
if (btn) btn.click();
|
||||
}
|
||||
@@ -458,8 +601,8 @@ function updateLastActionUI(state, peers) {
|
||||
const safeAcks = state.acks || [];
|
||||
|
||||
const actionNames = {
|
||||
'play': getMessage('BTN_PLAY').replace('▶ ', '').toUpperCase(),
|
||||
'pause': getMessage('BTN_PAUSE').replace('⏸ ', '').toUpperCase(),
|
||||
'play': getMessage('BTN_PLAY'),
|
||||
'pause': getMessage('BTN_PAUSE'),
|
||||
'seek': getMessage('NOTIF_SEEK').toUpperCase(),
|
||||
'force_sync_prepare': getMessage('BTN_STATE_SYNCING').toUpperCase(),
|
||||
'force_sync_execute': getMessage('BTN_STATE_SYNCED').toUpperCase()
|
||||
@@ -482,7 +625,7 @@ function updateLastActionUI(state, peers) {
|
||||
actionSpan.textContent = actionNames[state.action] || state.action.toUpperCase();
|
||||
|
||||
const infoSpan = document.createElement('span');
|
||||
infoSpan.style.cssText = 'font-size:9px; color:var(--text-muted);';
|
||||
infoSpan.style.cssText = 'font-size:10px; color:var(--text-muted);';
|
||||
infoSpan.textContent = `${senderName} @ ${timeStr}`;
|
||||
|
||||
header.appendChild(actionSpan);
|
||||
@@ -491,14 +634,14 @@ function updateLastActionUI(state, peers) {
|
||||
|
||||
if (state.targetTime !== undefined && state.action === 'seek') {
|
||||
const timeInfo = document.createElement('div');
|
||||
timeInfo.style.cssText = 'font-size:9px; color:var(--text-muted); margin-top:4px;';
|
||||
timeInfo.style.cssText = 'font-size:10px; color:var(--text-muted); margin-top:4px;';
|
||||
timeInfo.textContent = `Target: ${formatTime(state.targetTime)}`;
|
||||
elements.lastActionCard.appendChild(timeInfo);
|
||||
}
|
||||
|
||||
if (state.targetTime !== undefined && state.action.includes('force_sync')) {
|
||||
const timeInfo = document.createElement('div');
|
||||
timeInfo.style.cssText = 'font-size:9px; color:var(--text-muted); margin-top:4px;';
|
||||
timeInfo.style.cssText = 'font-size:10px; color:var(--text-muted); margin-top:4px;';
|
||||
timeInfo.textContent = `Sync to: ${formatTime(state.targetTime)}`;
|
||||
elements.lastActionCard.appendChild(timeInfo);
|
||||
}
|
||||
@@ -511,7 +654,7 @@ function updateLastActionUI(state, peers) {
|
||||
if (pId === localPeerId) return;
|
||||
const pName = (typeof peer === 'object' && peer.username) ? peer.username : pId.substring(0, 4);
|
||||
const isAcked = safeAcks.includes(pId) || pId === state.senderId;
|
||||
const color = isAcked ? 'var(--success)' : '#475569';
|
||||
const color = isAcked ? 'var(--success)' : 'var(--border-strong)';
|
||||
const icon = isAcked ? '✓' : '...';
|
||||
const avatar = getAvatarForName(pName);
|
||||
|
||||
@@ -520,11 +663,11 @@ function updateLastActionUI(state, peers) {
|
||||
peerItem.style.cssText = `display:flex; flex-direction:column; align-items:center; opacity: ${isAcked ? 1 : 0.6};`;
|
||||
|
||||
const dot = document.createElement('div');
|
||||
dot.style.cssText = `width:18px; height:18px; border-radius:50%; background:${color}; color:white; display:flex; align-items:center; justify-content:center; font-size:9px; font-weight:bold; margin-bottom:1px;`;
|
||||
dot.style.cssText = `width:18px; height:18px; border-radius:50%; background:${color}; color:${isAcked ? 'var(--text-on-green)' : 'var(--text-primary)'}; display:flex; align-items:center; justify-content:center; font-size:9px; font-weight:bold; margin-bottom:1px;`;
|
||||
dot.textContent = icon;
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.style.cssText = 'font-size:7px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:36px;';
|
||||
nameSpan.style.cssText = 'font-size:9px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:42px;';
|
||||
nameSpan.textContent = `${avatar} ${pName}`;
|
||||
|
||||
peerItem.appendChild(dot);
|
||||
@@ -612,7 +755,10 @@ function renderEmpty(container, type) {
|
||||
copyBtn.title = getMessage('BTN_COPY_INVITE_TOOLTIP');
|
||||
copyBtn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(elements.inviteLink.value)
|
||||
.then(() => showToast(getMessage('TOAST_INVITE_COPIED'), 'success', 2000))
|
||||
.then(() => {
|
||||
showToast(getMessage('TOAST_INVITE_COPIED'), 'success', 2000);
|
||||
spawnLeafBurst(copyBtn);
|
||||
})
|
||||
.catch(() => showToast(getMessage('TOAST_COPY_FAILED'), 'error'));
|
||||
});
|
||||
wrapper.appendChild(copyBtn);
|
||||
@@ -658,7 +804,7 @@ function updatePeerList(peers) {
|
||||
|
||||
const peerItem = document.createElement('div');
|
||||
peerItem.className = 'peer-item';
|
||||
peerItem.style.cssText = 'position:relative; display:block; padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.05);';
|
||||
peerItem.style.cssText = 'position:relative; display:block; padding: 8px 0; border-bottom: 1px solid var(--border-soft);';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding-right: 24px;';
|
||||
@@ -668,7 +814,7 @@ function updatePeerList(peers) {
|
||||
const avatar = getAvatarForName(pUsername || pId);
|
||||
if (pUsername) {
|
||||
const u = document.createElement('span');
|
||||
u.style.cssText = 'font-weight:600; color:white; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px; display: inline-block;';
|
||||
u.style.cssText = 'font-weight:600; color:var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px; display: inline-block;';
|
||||
u.textContent = `${avatar} ${pUsername}`;
|
||||
const i = document.createElement('span');
|
||||
i.style.cssText = 'font-size:10px; opacity:0.6; font-style:italic; white-space: nowrap; flex-shrink: 0;';
|
||||
@@ -707,7 +853,7 @@ function updatePeerList(peers) {
|
||||
// Host Control Mode: show "Solo" badge for peers watching on their own.
|
||||
if (typeof p === 'object' && p.desynced) {
|
||||
const solo = document.createElement('span');
|
||||
solo.style.cssText = 'font-size:10px; color:#fff; background:#b45309; padding:2px 6px; border-radius:6px; font-weight:600;';
|
||||
solo.style.cssText = 'font-size:10px; color:var(--text-on-warm); background:var(--accent-terracotta); padding:2px 6px; border-radius:6px; font-weight:700;';
|
||||
const soloText = getMessage('BADGE_DESYNCED') || 'Solo';
|
||||
solo.textContent = soloText;
|
||||
solo.title = getMessage('TOOLTIP_PEER_DESYNCED') || soloText;
|
||||
@@ -724,7 +870,7 @@ function updatePeerList(peers) {
|
||||
const isController = !isOwner && hcmControllers.includes(pId);
|
||||
if (isOwner || isController) {
|
||||
const roleBadge = document.createElement('span');
|
||||
roleBadge.style.cssText = 'font-size:10px; color:#fff; background:var(--accent); padding:2px 6px; border-radius:6px; font-weight:600;';
|
||||
roleBadge.style.cssText = 'font-size:10px; color:var(--text-on-green); background:var(--accent); padding:2px 6px; border-radius:6px; font-weight:700;';
|
||||
roleBadge.textContent = isOwner ? (getMessage('BADGE_HOST') || 'Host') : (getMessage('BADGE_CONTROLLER') || 'Controller');
|
||||
rightGroup.appendChild(roleBadge);
|
||||
}
|
||||
@@ -737,7 +883,7 @@ function updatePeerList(peers) {
|
||||
btn.title = revoke ? (getMessage('BTN_REVOKE_CONTROL') || 'Revoke') : (getMessage('BTN_GIVE_CONTROL') || 'Give control');
|
||||
btn.addEventListener('mouseenter', () => {
|
||||
btn.style.background = revoke ? 'var(--text-muted)' : 'var(--accent)';
|
||||
btn.style.color = '#fff';
|
||||
btn.style.color = 'var(--text-on-green)';
|
||||
});
|
||||
btn.addEventListener('mouseleave', () => {
|
||||
btn.style.background = 'transparent';
|
||||
@@ -903,7 +1049,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||
// Smart Matching Logic — exclude own tabTitle to prevent self-match (computed once)
|
||||
const cleanTitle = (rawTitle) => {
|
||||
if (!rawTitle) return '';
|
||||
return rawTitle
|
||||
return (normalizeTabTitle(rawTitle) || '')
|
||||
.replace(/(?:\s*[-\|•]\s*(?:YouTube|Twitch|Jellyfin|Emby|Netflix|Vimeo|Dailymotion).*)$/i, '')
|
||||
.replace(/^(?:Netflix|Twitch|YouTube|Emby|Jellyfin)\s*[-\|•]\s*/i, '')
|
||||
.trim();
|
||||
@@ -993,25 +1139,8 @@ function applyConnectionStatus(status) {
|
||||
const idle = status === 'idle';
|
||||
|
||||
if (elements.connDot) {
|
||||
elements.connDot.className = 'status-dot ' + (connected ? 'status-online' : ((connecting || reconnecting) ? 'status-online' : 'status-offline'));
|
||||
|
||||
if (reconnecting) {
|
||||
elements.connDot.style.background = '#f59e0b';
|
||||
elements.connDot.style.boxShadow = '0 0 8px #f59e0b';
|
||||
} else if (connecting) {
|
||||
elements.connDot.style.background = '#fbbf24';
|
||||
elements.connDot.style.boxShadow = '0 0 8px #fbbf24';
|
||||
} else if (idle) {
|
||||
// Neutral grey — ready, not failed.
|
||||
elements.connDot.style.background = '#9ca3af';
|
||||
elements.connDot.style.boxShadow = 'none';
|
||||
} else if (!connected) {
|
||||
elements.connDot.style.background = '#ef4444';
|
||||
elements.connDot.style.boxShadow = 'none';
|
||||
} else {
|
||||
elements.connDot.style.background = '';
|
||||
elements.connDot.style.boxShadow = '';
|
||||
}
|
||||
const dotStatus = connected ? 'status-online' : ((connecting || reconnecting) ? 'status-connecting' : (idle ? 'status-idle' : 'status-offline'));
|
||||
elements.connDot.className = `status-dot ${dotStatus}`;
|
||||
}
|
||||
|
||||
if (elements.connText) {
|
||||
@@ -1048,16 +1177,16 @@ function updatePingDisplay(pingMs) {
|
||||
if (!elements.connPing) return;
|
||||
if (pingMs === null || pingMs === undefined || typeof pingMs !== 'number' || !Number.isFinite(pingMs)) {
|
||||
elements.connPing.textContent = '';
|
||||
elements.connPing.style.color = '';
|
||||
elements.connPing.className = 'conn-ping';
|
||||
return;
|
||||
}
|
||||
elements.connPing.textContent = `${Math.round(pingMs)}ms`;
|
||||
if (pingMs < 50) {
|
||||
elements.connPing.style.color = '#22c55e';
|
||||
if (pingMs < 75) {
|
||||
elements.connPing.className = 'conn-ping conn-ping-good';
|
||||
} else if (pingMs < 150) {
|
||||
elements.connPing.style.color = '#f59e0b';
|
||||
elements.connPing.className = 'conn-ping conn-ping-warning';
|
||||
} else {
|
||||
elements.connPing.style.color = '#ef4444';
|
||||
elements.connPing.className = 'conn-ping conn-ping-bad';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1075,10 +1204,10 @@ function updateHistory(history) {
|
||||
const actionLabel = item.action.toUpperCase().replace('FORCE_SYNC_', '');
|
||||
|
||||
const entry = document.createElement('div');
|
||||
entry.style.cssText = 'margin-bottom: 4px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 2px;';
|
||||
entry.style.cssText = 'margin-bottom: 4px; border-bottom: 1px solid var(--border-soft); padding-bottom: 2px;';
|
||||
|
||||
const timeSpan = document.createElement('span');
|
||||
timeSpan.style.color = '#64748b';
|
||||
timeSpan.style.color = 'var(--text-muted)';
|
||||
timeSpan.textContent = `[${time}] `;
|
||||
|
||||
const actionBold = document.createElement('b');
|
||||
@@ -1121,7 +1250,7 @@ function updateRoomList(rooms) {
|
||||
rooms.forEach(r => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'room-item';
|
||||
item.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor:pointer;';
|
||||
item.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding: 8px; border-bottom: 1px solid var(--border-soft); cursor:pointer;';
|
||||
item.dataset.id = r.id;
|
||||
|
||||
const leftSide = document.createElement('div');
|
||||
@@ -1255,6 +1384,43 @@ if (elements.autoCopyInvite) {
|
||||
});
|
||||
}
|
||||
|
||||
if (elements.themeSelector) {
|
||||
elements.themeSelector.addEventListener('change', () => {
|
||||
const themeMode = elements.themeSelector.value;
|
||||
flashThemeTransition();
|
||||
window.koalaTheme?.setMode(themeMode);
|
||||
chrome.storage.local.set({ themeMode });
|
||||
syncThemePicker();
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.theme-choice').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
if (!elements.themeSelector) return;
|
||||
elements.themeSelector.value = button.dataset.themeValue;
|
||||
elements.themeSelector.dispatchEvent(new window.Event('change'));
|
||||
});
|
||||
});
|
||||
|
||||
if (elements.paletteSelector) {
|
||||
elements.paletteSelector.addEventListener('change', () => {
|
||||
const themePalette = elements.paletteSelector.value;
|
||||
flashThemeTransition();
|
||||
window.koalaTheme?.setPalette(themePalette);
|
||||
chrome.storage.local.set({ themePalette });
|
||||
syncPalettePicker();
|
||||
dismissFeatureHint('theme_picker');
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.palette-choice').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
if (!elements.paletteSelector) return;
|
||||
elements.paletteSelector.value = button.dataset.paletteValue;
|
||||
elements.paletteSelector.dispatchEvent(new window.Event('change'));
|
||||
});
|
||||
});
|
||||
|
||||
if (elements.audioSettingsLink) {
|
||||
elements.audioSettingsLink.addEventListener('click', async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -1275,8 +1441,10 @@ elements.serverUrl.addEventListener('input', () => {
|
||||
chrome.storage.local.set({ serverUrl: elements.serverUrl.value });
|
||||
});
|
||||
|
||||
elements.username.addEventListener('input', syncDevToolsVisibility);
|
||||
elements.username.addEventListener('change', () => {
|
||||
chrome.storage.local.set({ username: elements.username.value });
|
||||
syncDevToolsVisibility();
|
||||
});
|
||||
|
||||
if (elements.langSelector) {
|
||||
@@ -1285,6 +1453,7 @@ if (elements.langSelector) {
|
||||
await chrome.storage.local.set({ locale: selectedLang });
|
||||
await loadLocale(selectedLang);
|
||||
translateDOM();
|
||||
localizeThemeSelector(selectedLang);
|
||||
configureFooterLinks();
|
||||
await updateFeatureHints();
|
||||
|
||||
@@ -1317,6 +1486,19 @@ if (elements.langSelector) {
|
||||
});
|
||||
}
|
||||
|
||||
// Accordion behavior for settings details categories (collapses others when one opens)
|
||||
document.querySelectorAll('#tab-settings details.form-group').forEach(det => {
|
||||
det.addEventListener('toggle', () => {
|
||||
if (det.open) {
|
||||
document.querySelectorAll('#tab-settings details.form-group').forEach(other => {
|
||||
if (other !== det && other.open) {
|
||||
other.removeAttribute('open');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
elements.serverUrl.addEventListener('change', () => {
|
||||
let url = elements.serverUrl.value.trim();
|
||||
if (url && !url.includes('://')) {
|
||||
@@ -1369,6 +1551,39 @@ function showToast(message, type = 'info', duration = 3000) {
|
||||
setTimeout(() => toast.remove(), duration);
|
||||
}
|
||||
|
||||
// Eucalyptus confetti: a few tiny leaves burst from the given element
|
||||
// (matches the website's copy-invite moment). Subtle by design — few,
|
||||
// tiny, short-lived — and skipped entirely under reduced motion.
|
||||
function spawnLeafBurst(sourceEl) {
|
||||
if (!sourceEl || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
const rect = sourceEl.getBoundingClientRect();
|
||||
if (!rect.width) return;
|
||||
const originY = rect.top + rect.height / 2;
|
||||
const count = 4 + Math.floor(Math.random() * 3);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const leaf = document.createElement('span');
|
||||
// Three tones of the palette: deep green, sage, a dash of amber
|
||||
const tone = Math.random();
|
||||
leaf.className = 'click-leaf' + (tone < 0.25 ? ' click-leaf-amber' : tone < 0.55 ? ' click-leaf-sage' : '');
|
||||
// Fan out upward to a burst peak spread across the button's width;
|
||||
// the keyframes brake there and let each leaf sink while it rocks.
|
||||
const angle = (-90 + (Math.random() - 0.5) * 150) * Math.PI / 180;
|
||||
const dist = 18 + Math.random() * 24;
|
||||
const dur = 0.85 + Math.random() * 0.4;
|
||||
leaf.style.left = (rect.left + 4 + Math.random() * Math.max(1, rect.width - 8)) + 'px';
|
||||
leaf.style.top = (originY - rect.height * 0.2) + 'px';
|
||||
leaf.style.setProperty('--leaf-x', (Math.cos(angle) * dist).toFixed(1) + 'px');
|
||||
leaf.style.setProperty('--leaf-y', (Math.sin(angle) * dist).toFixed(1) + 'px');
|
||||
leaf.style.setProperty('--leaf-rot', ((Math.random() - 0.5) * 160).toFixed(0) + 'deg');
|
||||
leaf.style.setProperty('--leaf-scale', (0.55 + Math.random() * 0.55).toFixed(2));
|
||||
leaf.style.setProperty('--leaf-dur', dur.toFixed(2) + 's');
|
||||
leaf.style.setProperty('--flutter-dur', (0.35 + Math.random() * 0.3).toFixed(2) + 's');
|
||||
leaf.appendChild(document.createElement('i'));
|
||||
document.body.appendChild(leaf);
|
||||
setTimeout(() => leaf.remove(), dur * 1000 + 150);
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
if (!elements.roomError) return;
|
||||
const currentToken = ++errorToken;
|
||||
@@ -1568,8 +1783,9 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
}
|
||||
const peers = status.peers || [];
|
||||
const otherTimes = peers
|
||||
.filter(p => typeof p === 'object' && p.peerId !== localPeerId && p.currentTime != null && !isNaN(p.currentTime))
|
||||
.map(p => p.currentTime);
|
||||
.filter(p => typeof p === 'object' && p.peerId !== localPeerId && p.currentTime != null && p.currentTime !== '')
|
||||
.map(p => Number(p.currentTime))
|
||||
.filter(Number.isFinite);
|
||||
|
||||
if (otherTimes.length === 0) {
|
||||
showError(getMessage('ERR_NO_PEERS_TIME'));
|
||||
@@ -1598,38 +1814,57 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
forceSyncResetTimer = setTimeout(forceSyncReset, syncTimeoutMs);
|
||||
const tabId = parseInt(status.targetTabId);
|
||||
|
||||
const failForceSyncTime = () => {
|
||||
if (forceSyncResetTimer) { clearTimeout(forceSyncResetTimer); forceSyncResetTimer = null; }
|
||||
showError(getMessage('ERR_NO_VIDEO_TAB'));
|
||||
forceSyncDone = true;
|
||||
elements.forceSyncBtn.disabled = false;
|
||||
elements.forceSyncBtn.textContent = originalText;
|
||||
};
|
||||
|
||||
const sendForceSync = (time) => {
|
||||
if (time === null || time === undefined) {
|
||||
failForceSyncTime();
|
||||
return;
|
||||
}
|
||||
const target = Number(time);
|
||||
if (!Number.isFinite(target)) {
|
||||
failForceSyncTime();
|
||||
return;
|
||||
}
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'CONTENT_EVENT',
|
||||
action: EVENTS.FORCE_SYNC_PREPARE,
|
||||
payload: { targetTime: parseFloat(time) }
|
||||
payload: { targetTime: target }
|
||||
});
|
||||
};
|
||||
|
||||
if (mode === 'jump-to-me') {
|
||||
const retryQueryTime = () => {
|
||||
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => {
|
||||
if (chrome.runtime.lastError || !retryResponse || !Number.isFinite(retryResponse.currentTime)) {
|
||||
failForceSyncTime();
|
||||
return;
|
||||
}
|
||||
sendForceSync(retryResponse.currentTime);
|
||||
});
|
||||
};
|
||||
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (response) => {
|
||||
if (chrome.runtime.lastError || !response || response.currentTime === undefined) {
|
||||
chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['content.js']
|
||||
}).then(() => {
|
||||
setTimeout(() => {
|
||||
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => {
|
||||
if (chrome.runtime.lastError) return;
|
||||
if (retryResponse && retryResponse.currentTime !== undefined) {
|
||||
sendForceSync(retryResponse.currentTime);
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
}).catch(() => {
|
||||
showError(getMessage('ERR_NO_VIDEO_TAB'));
|
||||
forceSyncDone = true;
|
||||
elements.forceSyncBtn.disabled = false;
|
||||
elements.forceSyncBtn.textContent = originalText;
|
||||
if (Number.isFinite(response?.currentTime)) {
|
||||
sendForceSync(response.currentTime);
|
||||
return;
|
||||
}
|
||||
if (chrome.runtime.lastError || !response) {
|
||||
chrome.runtime.sendMessage({ type: 'INJECT_CONTENT_SCRIPT', tabId }, (injectResponse) => {
|
||||
if (chrome.runtime.lastError || !injectResponse || injectResponse.status !== 'ok') {
|
||||
failForceSyncTime();
|
||||
return;
|
||||
}
|
||||
setTimeout(retryQueryTime, 500);
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendForceSync(response.currentTime);
|
||||
setTimeout(retryQueryTime, 500);
|
||||
});
|
||||
} else {
|
||||
sendForceSync(targetTime);
|
||||
@@ -1690,6 +1925,27 @@ elements.pauseBtn.addEventListener('click', () => {
|
||||
}, 2500);
|
||||
});
|
||||
|
||||
function simulateRemoteSeek({ delta = null, targetTime = null }) {
|
||||
chrome.runtime.sendMessage({ type: 'DEV_SIMULATE_REMOTE_SEEK', delta, targetTime }, (res) => {
|
||||
if (chrome.runtime.lastError || !res || res.status !== 'ok') {
|
||||
showToast(res?.message || 'Remote seek failed', 'error');
|
||||
return;
|
||||
}
|
||||
showToast(`Remote seek -> ${formatTime(res.targetTime)}`, 'success', 1200);
|
||||
refreshDebugInfo();
|
||||
});
|
||||
}
|
||||
|
||||
if (elements.remoteSeekBack) {
|
||||
elements.remoteSeekBack.addEventListener('click', () => simulateRemoteSeek({ delta: -30 }));
|
||||
}
|
||||
if (elements.remoteSeekForward) {
|
||||
elements.remoteSeekForward.addEventListener('click', () => simulateRemoteSeek({ delta: 30 }));
|
||||
}
|
||||
if (elements.remoteSeekFiveMin) {
|
||||
elements.remoteSeekFiveMin.addEventListener('click', () => simulateRemoteSeek({ targetTime: 300 }));
|
||||
}
|
||||
|
||||
elements.clearLogs.addEventListener('click', () => {
|
||||
chrome.runtime.sendMessage({ type: 'CLEAR_LOGS' }, () => {
|
||||
elements.logList.innerHTML = '';
|
||||
@@ -1714,14 +1970,13 @@ elements.copyInvite.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(elements.inviteLink.value).then(() => {
|
||||
const original = elements.copyInvite.textContent;
|
||||
elements.copyInvite.textContent = '✓';
|
||||
elements.copyInvite.style.background = 'var(--success)';
|
||||
elements.copyInvite.style.color = 'white';
|
||||
elements.copyInvite.classList.add('copy-success');
|
||||
showToast(getMessage('TOAST_INVITE_COPIED'), 'success', 2000);
|
||||
spawnLeafBurst(elements.copyInvite);
|
||||
setTimeout(() => {
|
||||
elements.copyInvite.textContent = original;
|
||||
elements.copyInvite.style.background = '';
|
||||
elements.copyInvite.style.color = '';
|
||||
}, 2000);
|
||||
elements.copyInvite.classList.remove('copy-success');
|
||||
}, 1500);
|
||||
}).catch(() => {
|
||||
showToast(getMessage('TOAST_COPY_FAILED'), 'error');
|
||||
});
|
||||
@@ -1731,13 +1986,14 @@ if (elements.syncTabCopyInvite) {
|
||||
elements.syncTabCopyInvite.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(elements.inviteLink.value).then(() => {
|
||||
const original = elements.syncTabCopyInvite.textContent;
|
||||
elements.syncTabCopyInvite.textContent = '✓';
|
||||
elements.syncTabCopyInvite.style.color = 'var(--success)';
|
||||
elements.syncTabCopyInvite.textContent = '✓ Copied';
|
||||
elements.syncTabCopyInvite.classList.add('copy-success');
|
||||
showToast(getMessage('TOAST_INVITE_COPIED'), 'success', 2000);
|
||||
spawnLeafBurst(elements.syncTabCopyInvite);
|
||||
setTimeout(() => {
|
||||
elements.syncTabCopyInvite.textContent = original;
|
||||
elements.syncTabCopyInvite.style.color = '';
|
||||
}, 2000);
|
||||
elements.syncTabCopyInvite.classList.remove('copy-success');
|
||||
}, 1500);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2047,6 +2303,26 @@ elements.copyLogs.addEventListener('click', () => {
|
||||
lines.push(`- **ReadyState:** ${readyOk ? '\u2705' : '\u26A0\uFE0F'} ${safe(vs.readyState, '?')} (${readyLabel})`);
|
||||
lines.push(`- **Network:** ${safe(vs.networkState, '?')} (${netLabel})`);
|
||||
lines.push(`- **Buffered:** ${safe(vs.buffered, '?')}`);
|
||||
if (vs.nativeCurrentTime != null || vs.nativeDuration != null) {
|
||||
lines.push(`- **Native Time:** ${safe(vs.nativeCurrentTime, '?')}s / ${safe(vs.nativeDuration, '?')}s`);
|
||||
}
|
||||
if (vs.siteQuirk) {
|
||||
const quirk = vs.siteQuirk;
|
||||
const label = quirk.name || quirk.key || 'site';
|
||||
if (quirk.timeline) {
|
||||
lines.push(`- **${label} Timeline:** ${safe(quirk.timeline.current, '?')}s / ${safe(quirk.timeline.duration, '?')}s`);
|
||||
}
|
||||
const candidates = Array.isArray(quirk.timelineCandidates) ? quirk.timelineCandidates : [];
|
||||
if (candidates.length > 0) {
|
||||
lines.push(`- **${label} Timeline Candidates:**`);
|
||||
candidates.forEach(c => lines.push(` - ${safe(c.source, '?')}: ${safe(c.current, '?')}s / ${safe(c.duration, '?')}s`));
|
||||
}
|
||||
const buttons = Array.isArray(quirk.seekButtons) ? quirk.seekButtons : [];
|
||||
if (buttons.length > 0) {
|
||||
lines.push(`- **${label} Buttons:**`);
|
||||
buttons.forEach(label => lines.push(` - ${label}`));
|
||||
}
|
||||
}
|
||||
lines.push(`- **Dimensions:** ${safe(vs.videoWidth, '?')}x${safe(vs.videoHeight, '?')}${dimOk ? '' : ' \u26A0\uFE0F 0x0'}`);
|
||||
lines.push(`- **Muted:** ${safe(vs.muted, '?')} | **Volume:** ${safe(vs.volume, '?')} | **Speed:** ${safe(vs.playbackRate, '?')}x`);
|
||||
lines.push(`- **Seeking:** ${safe(vs.seeking, '?')} | **Ended:** ${safe(vs.ended, '?')} | **Loop:** ${safe(vs.loop, '?')}`);
|
||||
@@ -2178,7 +2454,7 @@ function refreshDebugInfo() {
|
||||
|
||||
const addSection = (title) => {
|
||||
const div = document.createElement('div');
|
||||
div.style.cssText = 'margin: 8px 0 4px 0; border-bottom: 1px solid #334155; padding-bottom: 2px; color: var(--accent); font-weight: bold; font-size: 10px; text-transform: uppercase;';
|
||||
div.style.cssText = 'margin: 8px 0 4px 0; border-bottom: 1px solid var(--border-soft); padding-bottom: 2px; color: var(--accent); font-weight: bold; font-size: 10px; text-transform: uppercase;';
|
||||
div.textContent = title;
|
||||
elements.videoDebug.appendChild(div);
|
||||
};
|
||||
@@ -2187,7 +2463,7 @@ function refreshDebugInfo() {
|
||||
// No video found — show diagnostic info
|
||||
addSection('Video Detection');
|
||||
const notFound = document.createElement('div');
|
||||
notFound.style.cssText = 'color: #ef4444; font-weight: 700; margin-bottom: 8px;';
|
||||
notFound.style.cssText = 'color: var(--status-error-text); font-weight: 700; margin-bottom: 8px;';
|
||||
notFound.textContent = 'NO VIDEO ELEMENT FOUND';
|
||||
elements.videoDebug.appendChild(notFound);
|
||||
|
||||
@@ -2205,7 +2481,7 @@ function refreshDebugInfo() {
|
||||
}
|
||||
|
||||
const hint = document.createElement('div');
|
||||
hint.style.cssText = 'margin-top: 12px; padding: 8px; background: rgba(251,191,36,0.1); border-left: 3px solid #fbbf24; border-radius: 4px; font-size: 10px; color: var(--text-muted);';
|
||||
hint.style.cssText = 'margin-top: 12px; padding: 8px; background: color-mix(in oklch, var(--warning), transparent 88%); border-left: 3px solid var(--warning); border-radius: 4px; font-size: 10px; color: var(--text);';
|
||||
hint.textContent = 'Is a video currently playing? The extension only detects <video> elements. Ensure media is actively loaded on the page.';
|
||||
elements.videoDebug.appendChild(hint);
|
||||
return;
|
||||
@@ -2213,14 +2489,25 @@ function refreshDebugInfo() {
|
||||
|
||||
// Video found — full debug
|
||||
addSection('Playback');
|
||||
addField('State', state.paused ? 'PAUSED' : 'PLAYING', state.paused ? 'var(--text-muted)' : '#22c55e');
|
||||
addField('State', state.paused ? 'PAUSED' : 'PLAYING', state.paused ? 'var(--text-muted)' : 'var(--status-success-text)');
|
||||
addField('Time', `${state.currentTime.toFixed(2)}s / ${(state.duration || 0).toFixed(2)}s`);
|
||||
addField('ReadyState', `${state.readyState} (${state.readyStateLabel || '?'})`,
|
||||
state.readyState >= 3 ? '#22c55e' : '#fbbf24');
|
||||
state.readyState >= 3 ? 'var(--status-success-text)' : 'var(--status-warning-text)');
|
||||
addField('Network', `${state.networkState} (${state.networkStateLabel || '?'})`,
|
||||
state.networkState === 1 ? '#22c55e' : state.networkState === 3 ? '#ef4444' : 'var(--text-muted)');
|
||||
state.networkState === 1 ? 'var(--status-success-text)' : state.networkState === 3 ? 'var(--status-error-text)' : 'var(--text-muted)');
|
||||
addField('Buffered', state.buffered || '?');
|
||||
|
||||
if (state.nativeCurrentTime != null || state.nativeDuration != null) {
|
||||
addField('Native Time', `${state.nativeCurrentTime ?? '?'}s / ${state.nativeDuration ?? '?'}s`);
|
||||
}
|
||||
if (state.siteQuirk) {
|
||||
const quirk = state.siteQuirk;
|
||||
const label = quirk.name || quirk.key || 'Site';
|
||||
if (quirk.timeline) {
|
||||
addField(`${label} Timeline`, `${quirk.timeline.current ?? '?'}s / ${quirk.timeline.duration ?? '?'}s`);
|
||||
}
|
||||
const buttons = Array.isArray(quirk.seekButtons) ? quirk.seekButtons.slice(0, 4).join(' | ') : '';
|
||||
if (buttons) addField(`${label} Buttons`, buttons);
|
||||
}
|
||||
addSection('Properties');
|
||||
addField('Seeking', String(state.seeking));
|
||||
addField('Ended', String(state.ended));
|
||||
@@ -2231,18 +2518,18 @@ function refreshDebugInfo() {
|
||||
|
||||
addSection('Dimensions');
|
||||
const dimsOk = state.videoWidth > 0 && state.videoHeight > 0;
|
||||
addField('Resolution', `${state.videoWidth}x${state.videoHeight}`, dimsOk ? '#22c55e' : '#ef4444');
|
||||
addField('Resolution', `${state.videoWidth}x${state.videoHeight}`, dimsOk ? 'var(--status-success-text)' : 'var(--status-error-text)');
|
||||
if (!dimsOk) {
|
||||
const dimHint = document.createElement('div');
|
||||
dimHint.style.cssText = 'color: #fbbf24; font-size: 9px; margin: 2px 0 6px 12px;';
|
||||
dimHint.style.cssText = 'color: var(--status-warning-text); font-size: 10px; margin: 2px 0 6px 12px;';
|
||||
dimHint.textContent = '0x0 = video element hidden or not yet loaded';
|
||||
elements.videoDebug.appendChild(dimHint);
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
addSection('Media Error');
|
||||
addField('Code', String(state.error.code), '#ef4444');
|
||||
addField('Message', state.error.message || '?', '#ef4444');
|
||||
addField('Code', String(state.error.code), 'var(--status-error-text)');
|
||||
addField('Message', state.error.message || '?', 'var(--status-error-text)');
|
||||
}
|
||||
|
||||
addSection('Detection');
|
||||
@@ -2316,9 +2603,10 @@ function updateLobbyUI(lobby, peers) {
|
||||
elements.episodeLobbyCard.style.display = 'block';
|
||||
elements.lobbyTitle.textContent = getMessage('LOBBY_WAITING_FOR', { title: lobby.expectedTitle });
|
||||
|
||||
// Build peer readiness list
|
||||
// Build peer readiness list. Peer usernames are remote-controlled input, so
|
||||
// every node is built with the DOM API and filled via textContent.
|
||||
const readySet = new Set(lobby.readyPeers || []);
|
||||
const peerLines = [];
|
||||
const peerItems = [];
|
||||
|
||||
if (peers && peers.length > 0) {
|
||||
peers.forEach(p => {
|
||||
@@ -2326,14 +2614,29 @@ function updateLobbyUI(lobby, peers) {
|
||||
const pName = (typeof p === 'object' && p.username) ? p.username : pId;
|
||||
const avatar = getAvatarForName(pName);
|
||||
const isReady = readySet.has(pId);
|
||||
const icon = isReady ? '\u2705' : '\u23f3';
|
||||
const icon = isReady ? '✅' : '⏳';
|
||||
const label = isReady ? getMessage('LABEL_LOBBY_PEER_READY') : getMessage('LABEL_LOBBY_PEER_LOADING');
|
||||
peerLines.push(`${icon} ${avatar} ${pName} \u2014 ${label}`);
|
||||
const badgeClass = isReady ? 'badge-ready' : 'badge-loading';
|
||||
|
||||
const item = document.createElement('span');
|
||||
item.className = 'lobby-peer-item';
|
||||
item.appendChild(document.createTextNode(`${icon} ${avatar} ${pName} `));
|
||||
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `badge ${badgeClass}`;
|
||||
badge.textContent = label;
|
||||
item.appendChild(badge);
|
||||
|
||||
peerItems.push(item);
|
||||
});
|
||||
}
|
||||
|
||||
if (peerLines.length > 0 && elements.lobbyPeerStatus) {
|
||||
elements.lobbyPeerStatus.textContent = peerLines.join(' | ');
|
||||
if (peerItems.length > 0 && elements.lobbyPeerStatus) {
|
||||
elements.lobbyPeerStatus.textContent = '';
|
||||
peerItems.forEach((item, index) => {
|
||||
if (index > 0) elements.lobbyPeerStatus.appendChild(document.createTextNode(' '));
|
||||
elements.lobbyPeerStatus.appendChild(item);
|
||||
});
|
||||
} else if (elements.lobbyPeerStatus) {
|
||||
elements.lobbyPeerStatus.textContent = getMessage('LOBBY_WAITING_PEERS');
|
||||
}
|
||||
@@ -2341,7 +2644,10 @@ function updateLobbyUI(lobby, peers) {
|
||||
// Show elapsed time
|
||||
if (lobby.createdAt && elements.lobbyPeerStatus) {
|
||||
const elapsed = Math.floor((Date.now() - lobby.createdAt) / 1000);
|
||||
elements.lobbyPeerStatus.textContent += ` (${elapsed}s)`;
|
||||
const timerSpan = document.createElement('span');
|
||||
timerSpan.style.cssText = 'font-size: 10px; color: var(--text-muted); margin-left: 6px; display: inline-block; vertical-align: middle;';
|
||||
timerSpan.textContent = `(${elapsed}s)`;
|
||||
elements.lobbyPeerStatus.appendChild(timerSpan);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -17,9 +17,15 @@ export function normalizeSendTabTitle(sendTabTitle, legacyMode = TITLE_PRIVACY_M
|
||||
return normalizeTitlePrivacyMode(legacyMode) === TITLE_PRIVACY_MODES.FULL;
|
||||
}
|
||||
|
||||
export function normalizeTabTitle(title) {
|
||||
if (typeof title !== 'string') return null;
|
||||
const normalized = title.replace(/^\s*(?:\(\d+\+?\)|\[\d+\+?\])\s+/, '').trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function sanitizeTabTitle(title, sendTabTitle) {
|
||||
if (!sendTabTitle) return null;
|
||||
return typeof title === 'string' && title.length > 0 ? title : null;
|
||||
return normalizeTabTitle(title);
|
||||
}
|
||||
|
||||
export function sanitizeSharedTitle(title, mode) {
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:extension": "node scripts/build-extension.cjs",
|
||||
"indexnow": "node website/submit-indexnow.cjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node scripts/verify-release.mjs",
|
||||
"subset-flags": "node website/tools/subset-flag-font.mjs",
|
||||
"test": "npm run verify",
|
||||
"test:unit": "vitest run",
|
||||
"verify": "node scripts/verify-release.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"addons-linter": "^10.9.0",
|
||||
"archiver": "^7.0.1",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-plugin-no-unsanitized": "^4.1.5",
|
||||
"fs-extra": "^11.2.0",
|
||||
"htmlnano": "^3.4.0",
|
||||
"sharp": "^0.34.5",
|
||||
"svgo": "^4.0.1"
|
||||
"svgo": "^4.0.1",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -36,6 +36,13 @@ console.log('✓ constants.js, blacklist.js, names.js, and README.md synced to e
|
||||
// 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 +86,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 +94,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,14 +128,19 @@ 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}`);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,14 @@ function extractFunction(name, text) {
|
||||
throw new Error(`${name} body did not terminate`);
|
||||
}
|
||||
|
||||
function makeSeekable(ranges = []) {
|
||||
return {
|
||||
length: ranges.length,
|
||||
start(i) { return ranges[i][0]; },
|
||||
end(i) { return ranges[i][1]; }
|
||||
};
|
||||
}
|
||||
|
||||
function makeVideo(name, width, height, options = {}) {
|
||||
return {
|
||||
name,
|
||||
@@ -28,7 +36,9 @@ function makeVideo(name, width, height, options = {}) {
|
||||
offsetWidth: width,
|
||||
offsetHeight: height,
|
||||
muted: options.muted ?? true,
|
||||
duration: options.duration ?? 0
|
||||
duration: options.duration ?? 0,
|
||||
currentTime: options.currentTime ?? 0,
|
||||
seekable: options.seekable ?? makeSeekable()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,4 +71,83 @@ assert.strictEqual(
|
||||
'findVideo should score Shadow DOM videos together with light DOM videos'
|
||||
);
|
||||
|
||||
function makeDocument(nodes = []) {
|
||||
return {
|
||||
querySelectorAll() { return nodes; }
|
||||
};
|
||||
}
|
||||
|
||||
function loadTimelineFns(hostname, document = makeDocument(), pageApiTime = null) {
|
||||
const disneyPageApiTime = pageApiTime
|
||||
? `let disneyPageApiTime = { position: ${pageApiTime.position}, duration: ${pageApiTime.duration}, at: Date.now() - ${pageApiTime.ageMs || 0} };`
|
||||
: 'let disneyPageApiTime = null;';
|
||||
return Function('window', 'document', [
|
||||
disneyPageApiTime,
|
||||
extractFunction('hostMatchesUrl', source),
|
||||
extractFunction('matchesPlayerUrls', source),
|
||||
extractFunction('isDisneyPlusHost', source),
|
||||
extractFunction('getDisneyPlusTimeline', source),
|
||||
extractFunction('getSiteQuirkAdapters', source),
|
||||
extractFunction('getActiveSiteQuirk', source),
|
||||
extractFunction('getSiteQuirkTimeline', source),
|
||||
extractFunction('getSiteQuirkDebug', source),
|
||||
extractFunction('getSyncCurrentTime', source),
|
||||
extractFunction('getSyncDuration', source),
|
||||
extractFunction('toNativeSeekTime', source),
|
||||
'return { getActiveSiteQuirk, getSyncCurrentTime, getSyncDuration, toNativeSeekTime };'
|
||||
].join('\n'))({ location: { hostname } }, document);
|
||||
}
|
||||
|
||||
function loadPlayerFixFns(hostname) {
|
||||
return Function('window', [
|
||||
extractFunction('hostMatchesUrl', source),
|
||||
extractFunction('matchesPlayerUrls', source),
|
||||
extractFunction('getPlayerActionFixes', source),
|
||||
extractFunction('getActivePlayerActionFix', source),
|
||||
'return { getPlayerActionFixes, getActivePlayerActionFix };'
|
||||
].join('\n'))({ location: { hostname } });
|
||||
}
|
||||
|
||||
const disneyFns = loadTimelineFns('www.disneyplus.com', makeDocument(), {
|
||||
position: 9,
|
||||
duration: 10800
|
||||
});
|
||||
assert.equal(disneyFns.getActiveSiteQuirk().name, 'disneyplus-page-api');
|
||||
assert.deepEqual(disneyFns.getActiveSiteQuirk().urls, ['disneyplus.com']);
|
||||
const disneyVideo = makeVideo('disney-offset', 1920, 1080, {
|
||||
currentTime: 29,
|
||||
duration: 0,
|
||||
seekable: makeSeekable([[0, 32400]])
|
||||
});
|
||||
assert.equal(disneyFns.getSyncCurrentTime(disneyVideo), 9);
|
||||
assert.equal(disneyFns.getSyncDuration(disneyVideo), 10800);
|
||||
assert.equal(disneyFns.toNativeSeekTime(disneyVideo, 39), 39);
|
||||
assert.equal(disneyFns.getSyncCurrentTime(makeVideo('disney-native-broken', 1920, 1080, {
|
||||
currentTime: Number.NaN,
|
||||
duration: 0
|
||||
})), 9);
|
||||
|
||||
const disneyNoPageApiFns = loadTimelineFns('www.disneyplus.com');
|
||||
const disneyOffsetVideo = makeVideo('disney-offset', 1920, 1080, {
|
||||
currentTime: 29,
|
||||
duration: 0,
|
||||
seekable: makeSeekable([[20, 10820]])
|
||||
});
|
||||
assert.equal(disneyNoPageApiFns.getSyncCurrentTime(disneyOffsetVideo), null);
|
||||
assert.equal(disneyNoPageApiFns.getSyncDuration(disneyOffsetVideo), 0);
|
||||
assert.equal(disneyNoPageApiFns.toNativeSeekTime(disneyOffsetVideo, 39), 39);
|
||||
|
||||
const genericFns = loadTimelineFns('example.com');
|
||||
assert.equal(genericFns.getActiveSiteQuirk(), null);
|
||||
assert.equal(genericFns.getSyncCurrentTime(disneyVideo), 29);
|
||||
assert.equal(genericFns.getSyncDuration(disneyVideo), 0);
|
||||
assert.equal(genericFns.toNativeSeekTime(disneyVideo, 39), 39);
|
||||
|
||||
const twitchFixFns = loadPlayerFixFns('player.twitch.tv');
|
||||
assert.equal(twitchFixFns.getActivePlayerActionFix().name, 'twitch-player-buttons');
|
||||
assert.deepEqual(twitchFixFns.getActivePlayerActionFix().urls, ['twitch.tv']);
|
||||
|
||||
const genericFixFns = loadPlayerFixFns('example.com');
|
||||
assert.equal(genericFixFns.getActivePlayerActionFix(), null);
|
||||
|
||||
console.log('content video finder tests passed');
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
checkEventRate,
|
||||
checkHealthRate,
|
||||
checkAdminMetricsAuthRate,
|
||||
checkLeaveRoomRate,
|
||||
checkAuthRate,
|
||||
recordAuthFailure,
|
||||
clearRateLimitMaps,
|
||||
@@ -13,11 +14,13 @@ import {
|
||||
healthCounts,
|
||||
adminMetricsAuthCounts,
|
||||
roomListCooldowns,
|
||||
leaveRoomCounts,
|
||||
rateLimitDenied,
|
||||
startRateLimitCleanup,
|
||||
stopRateLimitCleanup,
|
||||
CONNECTION_RATE_LIMIT,
|
||||
EVENT_RATE_LIMIT
|
||||
EVENT_RATE_LIMIT,
|
||||
LEAVE_ROOM_RATE_LIMIT
|
||||
} from '../server/rate-limiter.js';
|
||||
|
||||
// Helper: mock io for cleanup
|
||||
@@ -26,7 +29,7 @@ const mockIo = { sockets: { sockets: new Map() } };
|
||||
// Reset state before each test group
|
||||
function reset() {
|
||||
clearRateLimitMaps();
|
||||
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 });
|
||||
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 0 });
|
||||
stopRateLimitCleanup();
|
||||
}
|
||||
|
||||
@@ -52,6 +55,16 @@ assert.equal(rateLimitDenied.events, 1);
|
||||
reset();
|
||||
assert.equal(checkEventRate('sock2'), true, 'separate socket independent');
|
||||
|
||||
// --- checkLeaveRoomRate ---
|
||||
reset();
|
||||
assert.equal(checkLeaveRoomRate('sock-leave-1'), true, 'first leave-room event allowed');
|
||||
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT - 1; i++) checkLeaveRoomRate('sock-leave-1');
|
||||
assert.equal(checkLeaveRoomRate('sock-leave-1'), false, `leave-room beyond ${LEAVE_ROOM_RATE_LIMIT}/window blocked`);
|
||||
assert.equal(rateLimitDenied.leaveRoom, 1);
|
||||
|
||||
reset();
|
||||
assert.equal(checkLeaveRoomRate('sock-leave-2'), true, 'separate leave-room socket independent');
|
||||
|
||||
// --- checkHealthRate ---
|
||||
reset();
|
||||
assert.equal(checkHealthRate('1.2.3.4'), true, 'first health check allowed');
|
||||
@@ -91,12 +104,14 @@ eventCounts.set('sock1', { count: 1, resetTime: Date.now() + 10000 });
|
||||
healthCounts.set('ip2', { count: 1, resetTime: Date.now() + 60000 });
|
||||
adminMetricsAuthCounts.set('ip3', { count: 1, resetTime: Date.now() + 60000 });
|
||||
roomListCooldowns.set('sock2', Date.now());
|
||||
leaveRoomCounts.set('sock3', { count: 1, resetTime: Date.now() + 60000 });
|
||||
clearRateLimitMaps();
|
||||
assert.equal(connectionCounts.size, 0, 'connectionCounts cleared');
|
||||
assert.equal(eventCounts.size, 0, 'eventCounts cleared');
|
||||
assert.equal(healthCounts.size, 0, 'healthCounts cleared');
|
||||
assert.equal(adminMetricsAuthCounts.size, 0, 'adminMetricsAuthCounts cleared');
|
||||
assert.equal(roomListCooldowns.size, 0, 'roomListCooldowns cleared');
|
||||
assert.equal(leaveRoomCounts.size, 0, 'leaveRoomCounts cleared');
|
||||
|
||||
// --- startRateLimitCleanup / stopRateLimitCleanup ---
|
||||
reset();
|
||||
@@ -108,7 +123,9 @@ assert.ok(true, 'cleanup start/stop does not throw');
|
||||
// --- rateLimitDenied reset ---
|
||||
reset();
|
||||
rateLimitDenied.connections = 5;
|
||||
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 });
|
||||
rateLimitDenied.leaveRoom = 5;
|
||||
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 0 });
|
||||
assert.equal(rateLimitDenied.connections, 0, 'denial counter resettable');
|
||||
assert.equal(rateLimitDenied.leaveRoom, 0, 'leave-room denial counter resettable');
|
||||
|
||||
console.log('rate-limiter tests passed');
|
||||
|
||||
@@ -52,7 +52,7 @@ const basicHealth = buildHealthPayload({
|
||||
now: 1234,
|
||||
uptime: 99,
|
||||
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
|
||||
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 }
|
||||
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6, leaveRoom: 7 }
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
@@ -68,7 +68,8 @@ const adminHealth = buildHealthPayload({
|
||||
now: 1234,
|
||||
uptime: 99,
|
||||
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
|
||||
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 }
|
||||
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6, leaveRoom: 7 },
|
||||
rateLimitDenied: { leaveRoom: 8 }
|
||||
});
|
||||
|
||||
assert.equal(adminHealth.peers, 5, 'admin metrics should include aggregate peer count');
|
||||
@@ -79,8 +80,8 @@ assert.deepEqual(adminHealth.memory, { rss: 10, heapUsed: 5, heapTotal: 8 }, 'ad
|
||||
assert.deepEqual(
|
||||
adminHealth.rateLimits,
|
||||
{
|
||||
trackedClients: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 },
|
||||
denied: { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 }
|
||||
trackedClients: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6, leaveRoom: 7 },
|
||||
denied: { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 8 }
|
||||
},
|
||||
'admin metrics should expose rate-limit tracking and denial counts'
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
TITLE_PRIVACY_MODES,
|
||||
applyTitlePrivacyToPayload,
|
||||
normalizeSendTabTitle,
|
||||
normalizeTabTitle,
|
||||
normalizeTitlePrivacyMode,
|
||||
sanitizeSharedTitle,
|
||||
sanitizeTabTitle
|
||||
@@ -15,8 +16,18 @@ assert.equal(normalizeSendTabTitle(undefined, TITLE_PRIVACY_MODES.FULL), true);
|
||||
assert.equal(normalizeSendTabTitle(undefined, TITLE_PRIVACY_MODES.EPISODE), false);
|
||||
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('(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');
|
||||
assert.equal(sanitizeTabTitle('(12) Private Tab', true), 'Private Tab');
|
||||
assert.equal(sanitizeTabTitle('Private Tab', false), null);
|
||||
assert.equal(sanitizeTabTitle('', true), null);
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/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 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 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');
|
||||
}
|
||||
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 ((page.match(/class="legal-inline-link"/g) || []).length !== 3) {
|
||||
throw new Error(`${name} must mark all three prose links as legal-inline-link`);
|
||||
}
|
||||
if ((page.match(/class="legal-inline-label"/g) || []).length !== 2 || /opacity:\s*0\.6/.test(page)) {
|
||||
throw new Error(`${name} contact labels must use full-opacity theme text`);
|
||||
}
|
||||
}
|
||||
|
||||
if (/<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('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);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import path from 'node:path';
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const checks = [
|
||||
['vitest unit tests', 'npm', ['run', 'test:unit']],
|
||||
['server ops', 'node', ['scripts/test-server-ops.mjs']],
|
||||
['server routes', 'node', ['scripts/test-server-routes.mjs'], {
|
||||
env: { ADMIN_METRICS_TOKEN: 'verify-admin-token-with-more-than-32-chars' }
|
||||
@@ -27,10 +28,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']]
|
||||
];
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ PORT="3000"
|
||||
MIN_VERSION="1.0.0"
|
||||
MAX_ROOMS="1000"
|
||||
MAX_PEERS_PER_ROOM="25"
|
||||
|
||||
# Required for production/self-hosting: unique random salt used for room-password hashes.
|
||||
# Generate one with: openssl rand -base64 32
|
||||
SERVER_SALT=""
|
||||
|
||||
# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer <token>
|
||||
# Use a long random token, 32+ characters recommended.
|
||||
ADMIN_METRICS_TOKEN=""
|
||||
|
||||
@@ -16,6 +16,11 @@ PORT="3000"
|
||||
MAX_ROOMS="1000"
|
||||
MAX_PEERS_PER_ROOM="25"
|
||||
MIN_VERSION="1.0.0"
|
||||
|
||||
# Required for production/self-hosting: unique random salt used for room-password hashes.
|
||||
# Generate one with: openssl rand -base64 32
|
||||
SERVER_SALT=""
|
||||
|
||||
# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer <token>
|
||||
# Use a long random token, 32+ characters recommended.
|
||||
ADMIN_METRICS_TOKEN=""
|
||||
@@ -24,6 +29,19 @@ ADMIN_METRICS_TOKEN=""
|
||||
DEBUG_LOGGING="0"
|
||||
```
|
||||
|
||||
### Room Password Salt
|
||||
`SERVER_SALT` is used to HMAC room passwords before they are stored in RAM. The relay has a built-in fallback so development keeps working, but that fallback is public in the repository and the server logs a startup warning when it is used.
|
||||
|
||||
For every real self-hosted deployment, set a unique random value:
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
Then add it to `server/.env` or your Compose environment:
|
||||
```bash
|
||||
SERVER_SALT=replace-with-a-long-random-salt
|
||||
```
|
||||
|
||||
### Health & Metrics
|
||||
`GET /` and `GET /health` are IP-rate-limited to 10 requests per minute per client IP. These health-style responses are cached server-side for 60 seconds and refreshed lazily on request. By default `/health` returns only basic service status, uptime, room count, connection count, and a timestamp.
|
||||
|
||||
@@ -63,11 +81,20 @@ npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
### Verification
|
||||
The server is covered by the root verification suite. From the repository root, run:
|
||||
```bash
|
||||
npm run verify
|
||||
```
|
||||
|
||||
For focused server checks, see `scripts/test-server-ops.mjs`, `scripts/test-server-routes.mjs`, `scripts/test-server-ws.mjs`, and `scripts/test-rate-limiter.mjs`.
|
||||
|
||||
## Security
|
||||
- **Rate Limiting**: IP-based connection limits and socket-based event limits.
|
||||
- **Health Endpoint Throttle**: `GET /` and `GET /health` are limited to 10 requests per minute per IP, with 60-second lazy server-side response caching and stricter throttling for wrong admin bearer attempts.
|
||||
- **Room Discovery Throttle**: Room-list refreshes are rate-limited server-side to one request every 10 seconds per socket.
|
||||
- **Token Handshake**: Requires a valid token defined in the root `shared/constants.js`.
|
||||
- **Password Hash Salt**: Set `SERVER_SALT` in every deployment so room-password hashes are not derived with the public fallback salt.
|
||||
- **Single Source of Truth**: The server imports constants directly from the root `shared/` directory.
|
||||
- **In-Memory**: Rooms are automatically pruned after 2 hours of inactivity.
|
||||
- **Reverse Proxy Boundary**: The server trusts one reverse proxy hop for client IP detection. Keep the Node port private/firewalled so clients can only reach it through Caddy or another trusted proxy.
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
healthCounts,
|
||||
adminMetricsAuthCounts,
|
||||
roomListCooldowns,
|
||||
leaveRoomCounts,
|
||||
rateLimitDenied,
|
||||
checkAuthRate,
|
||||
recordAuthFailure,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
checkEventRate,
|
||||
checkHealthRate,
|
||||
checkAdminMetricsAuthRate,
|
||||
checkLeaveRoomRate,
|
||||
startRateLimitCleanup,
|
||||
stopRateLimitCleanup,
|
||||
clearRateLimitMaps
|
||||
@@ -114,7 +116,8 @@ app.get('/health', (req, res) => {
|
||||
health: healthCounts.size,
|
||||
adminMetricsAuth: adminMetricsAuthCounts.size,
|
||||
authFailures: failedAuthAttempts.size,
|
||||
roomList: roomListCooldowns.size
|
||||
roomList: roomListCooldowns.size,
|
||||
leaveRoom: leaveRoomCounts.size
|
||||
},
|
||||
rateLimitDenied
|
||||
})
|
||||
@@ -661,6 +664,11 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
|
||||
socket.on(EVENTS.LEAVE_ROOM, () => {
|
||||
if (!checkLeaveRoomRate(socket.id)) {
|
||||
log('SECURITY', `LEAVE_ROOM rate limit exceeded for socket: ${socket.id}`);
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (mapping) {
|
||||
@@ -839,6 +847,7 @@ io.on('connection', (socket) => {
|
||||
socket.on('disconnect', () => {
|
||||
eventCounts.delete(socket.id);
|
||||
roomListCooldowns.delete(socket.id);
|
||||
leaveRoomCounts.delete(socket.id);
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (mapping) {
|
||||
try {
|
||||
@@ -919,12 +928,14 @@ function gracefulShutdown(signal) {
|
||||
log('SERVER', `${signal} received — starting graceful shutdown...`);
|
||||
// 1. Notify all connected clients so they can display a meaningful message
|
||||
io.emit(EVENTS.ERROR, { message: 'Server is restarting. Reconnecting automatically...' });
|
||||
// 2. Stop accepting new HTTP connections
|
||||
// 2. Gracefully disconnect all Socket.IO clients
|
||||
io.disconnectSockets(true);
|
||||
// 3. Stop accepting new HTTP connections
|
||||
httpServer.close(() => {
|
||||
log('SERVER', 'HTTP server closed. Exiting.');
|
||||
process.exit(0);
|
||||
});
|
||||
// 3. Safety net: force-exit after 5s if connections don't drain
|
||||
// 4. Safety net: force-exit after 5s if connections don't drain
|
||||
setTimeout(() => {
|
||||
log('SERVER', 'Force-exit after timeout.');
|
||||
process.exit(1);
|
||||
@@ -943,7 +954,7 @@ export async function stopServerForTests() {
|
||||
healthResponseCache.clear();
|
||||
io.removeAllListeners();
|
||||
io.disconnectSockets(true);
|
||||
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 });
|
||||
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 0 });
|
||||
if (!httpServer.listening) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
httpServer.close((err) => err ? reject(err) : resolve());
|
||||
|
||||
@@ -97,14 +97,16 @@ export function buildHealthPayload({
|
||||
health: rateLimitSizes.health || 0,
|
||||
adminMetricsAuth: rateLimitSizes.adminMetricsAuth || 0,
|
||||
authFailures: rateLimitSizes.authFailures || 0,
|
||||
roomList: rateLimitSizes.roomList || 0
|
||||
roomList: rateLimitSizes.roomList || 0,
|
||||
leaveRoom: rateLimitSizes.leaveRoom || 0
|
||||
},
|
||||
denied: {
|
||||
connections: rateLimitDenied.connections || 0,
|
||||
events: rateLimitDenied.events || 0,
|
||||
health: rateLimitDenied.health || 0,
|
||||
adminMetricsAuth: rateLimitDenied.adminMetricsAuth || 0,
|
||||
roomList: rateLimitDenied.roomList || 0
|
||||
roomList: rateLimitDenied.roomList || 0,
|
||||
leaveRoom: rateLimitDenied.leaveRoom || 0
|
||||
}
|
||||
},
|
||||
memory: {
|
||||
|
||||
@@ -14,6 +14,8 @@ export const EVENT_RATE_LIMIT = 50; // max relayed events per soc
|
||||
export const EVENT_RATE_WINDOW_MS = 10000; // 10 seconds
|
||||
export const HEALTH_RATE_WINDOW_MS = 60000; // 1 minute
|
||||
export const ADMIN_METRICS_AUTH_WINDOW_MS = 60000; // 1 minute
|
||||
export const LEAVE_ROOM_RATE_LIMIT = 10; // max LEAVE_ROOM events per socket per window
|
||||
export const LEAVE_ROOM_RATE_WINDOW_MS = 60000; // 1 minute
|
||||
|
||||
export const connectionCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
|
||||
@@ -21,13 +23,15 @@ export const eventCounts = new Map(); // socketId -> { count, resetTime }
|
||||
export const healthCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
|
||||
export const leaveRoomCounts = new Map(); // socketId -> { count, resetTime }
|
||||
|
||||
export const rateLimitDenied = {
|
||||
connections: 0,
|
||||
events: 0,
|
||||
health: 0,
|
||||
adminMetricsAuth: 0,
|
||||
roomList: 0
|
||||
roomList: 0,
|
||||
leaveRoom: 0
|
||||
};
|
||||
|
||||
let authCleanupId = null;
|
||||
@@ -122,6 +126,20 @@ export function checkAdminMetricsAuthRate(ip) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkLeaveRoomRate(socketId) {
|
||||
const now = Date.now();
|
||||
const entry = leaveRoomCounts.get(socketId) || { count: 0, resetTime: now + LEAVE_ROOM_RATE_WINDOW_MS };
|
||||
if (now > entry.resetTime) {
|
||||
entry.count = 0;
|
||||
entry.resetTime = now + LEAVE_ROOM_RATE_WINDOW_MS;
|
||||
}
|
||||
entry.count++;
|
||||
leaveRoomCounts.set(socketId, entry);
|
||||
if (entry.count <= LEAVE_ROOM_RATE_LIMIT) return true;
|
||||
rateLimitDenied.leaveRoom++;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function startRateLimitCleanup(io) {
|
||||
if (authCleanupId !== null || rateLimitCleanupId !== null) return; // guard double-start
|
||||
// Clean up old auth failure records (every 15 minutes)
|
||||
@@ -145,6 +163,11 @@ export function startRateLimitCleanup(io) {
|
||||
eventCounts.delete(socketId);
|
||||
}
|
||||
}
|
||||
for (const [socketId, entry] of leaveRoomCounts.entries()) {
|
||||
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
|
||||
leaveRoomCounts.delete(socketId);
|
||||
}
|
||||
}
|
||||
for (const [ip, entry] of healthCounts.entries()) {
|
||||
if (now > entry.resetTime) healthCounts.delete(ip);
|
||||
}
|
||||
@@ -166,7 +189,9 @@ export function clearRateLimitMaps() {
|
||||
connectionCounts.clear();
|
||||
failedAuthAttempts.clear();
|
||||
eventCounts.clear();
|
||||
|
||||
healthCounts.clear();
|
||||
adminMetricsAuthCounts.clear();
|
||||
roomListCooldowns.clear();
|
||||
leaveRoomCounts.clear();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
checkLeaveRoomRate,
|
||||
LEAVE_ROOM_RATE_LIMIT,
|
||||
LEAVE_ROOM_RATE_WINDOW_MS,
|
||||
rateLimitDenied,
|
||||
leaveRoomCounts,
|
||||
clearRateLimitMaps
|
||||
} from './rate-limiter.js';
|
||||
|
||||
describe('LEAVE_ROOM Rate Limiter', () => {
|
||||
const testSocketId = 'test-socket-123';
|
||||
|
||||
beforeEach(() => {
|
||||
clearRateLimitMaps();
|
||||
rateLimitDenied.leaveRoom = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearRateLimitMaps();
|
||||
});
|
||||
|
||||
it('should allow LEAVE_ROOM within limit', () => {
|
||||
// Test within the rate limit
|
||||
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT; i++) {
|
||||
const result = checkLeaveRoomRate(testSocketId);
|
||||
expect(result).toBe(true);
|
||||
}
|
||||
expect(rateLimitDenied.leaveRoom).toBe(0);
|
||||
});
|
||||
|
||||
it('should block LEAVE_ROOM when exceeding limit', () => {
|
||||
// Fill up to the limit
|
||||
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT; i++) {
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
}
|
||||
|
||||
// Next request should be blocked
|
||||
const result = checkLeaveRoomRate(testSocketId);
|
||||
expect(result).toBe(false);
|
||||
expect(rateLimitDenied.leaveRoom).toBe(1);
|
||||
});
|
||||
|
||||
it('should reset count after window expires', () => {
|
||||
// Fill up to the limit
|
||||
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT; i++) {
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
}
|
||||
|
||||
// Verify we're at the limit
|
||||
let result = checkLeaveRoomRate(testSocketId);
|
||||
expect(result).toBe(false);
|
||||
|
||||
// Fast-forward time beyond the rate limit window
|
||||
const entry = leaveRoomCounts.get(testSocketId);
|
||||
entry.resetTime = Date.now() - LEAVE_ROOM_RATE_WINDOW_MS - 1000;
|
||||
leaveRoomCounts.set(testSocketId, entry);
|
||||
|
||||
// Next request should be allowed again
|
||||
result = checkLeaveRoomRate(testSocketId);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle multiple sockets independently', () => {
|
||||
const socketId2 = 'test-socket-456';
|
||||
|
||||
// Fill up first socket
|
||||
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT; i++) {
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
}
|
||||
|
||||
// Second socket should still be allowed
|
||||
const result = checkLeaveRoomRate(socketId2);
|
||||
expect(result).toBe(true);
|
||||
|
||||
// First socket should be blocked
|
||||
const result2 = checkLeaveRoomRate(testSocketId);
|
||||
expect(result2).toBe(false);
|
||||
});
|
||||
|
||||
it('should increment rateLimitDenied counter on block', () => {
|
||||
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT; i++) {
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
}
|
||||
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
expect(rateLimitDenied.leaveRoom).toBe(1);
|
||||
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
expect(rateLimitDenied.leaveRoom).toBe(2);
|
||||
});
|
||||
|
||||
it('should be cleared by the shared reset helper', () => {
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
expect(leaveRoomCounts.size).toBe(1);
|
||||
|
||||
clearRateLimitMaps();
|
||||
expect(leaveRoomCounts.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limit Constants', () => {
|
||||
it('should have correct rate limit values', () => {
|
||||
expect(LEAVE_ROOM_RATE_LIMIT).toBe(10);
|
||||
expect(LEAVE_ROOM_RATE_WINDOW_MS).toBe(60000); // 1 minute
|
||||
});
|
||||
});
|
||||
@@ -1,33 +1,73 @@
|
||||
# KoalaSync Shared Constants
|
||||
|
||||
This directory contains constants and protocol definitions used by both the extension and the server.
|
||||
This directory is the source of truth for protocol constants and shared browser/server data.
|
||||
|
||||
## Where You Are
|
||||
|
||||
- `constants.js`: protocol version, app version, official URLs/token, event names, control modes, capabilities, and timing constants.
|
||||
- `blacklist.js`: domains hidden by the popup's clutter filter.
|
||||
- `names.js`: generated username parts and helper used by the extension.
|
||||
- `extension/shared/`: generated mirror created by `npm run build:extension`; do not edit that mirror directly.
|
||||
|
||||
## Syncing with the Extension
|
||||
> [!IMPORTANT]
|
||||
> Every time this directory is modified, you must run `node scripts/build-extension.cjs` to keep the extension's copy up to date.
|
||||
|
||||
Because Browser Extensions (Manifest V3) cannot load files outside their root directory, all files in this directory must be copied to `extension/shared/` whenever they are modified. The build script handles this automatically.
|
||||
> [!IMPORTANT]
|
||||
> After modifying any file in this directory, run `npm run build:extension` from the repository root.
|
||||
|
||||
Browser extensions cannot import files outside their own root directory, so the build script copies shared files into `extension/shared/` and injects selected constants into `content.js`.
|
||||
|
||||
## Security & Versioning Constants
|
||||
- `OFFICIAL_SERVER_TOKEN`: A 32-byte hex token required to connect to the official relay server.
|
||||
- `APP_VERSION`: The current version of the extension. Automatically injected from the git tag during CI release builds.
|
||||
- `OFFICIAL_SERVER_URL`: The default endpoint for the official KoalaSync relay.
|
||||
|
||||
- `PROTOCOL_VERSION`: exact protocol version required during `join_room`.
|
||||
- `APP_VERSION`: extension/app version, injected from release tags by CI.
|
||||
- `OFFICIAL_SERVER_URL`: default public relay endpoint.
|
||||
- `OFFICIAL_LANDING_PAGE_URL`: public website and invitation bridge origin.
|
||||
- `OFFICIAL_SERVER_TOKEN`: public client token used to reject non-KoalaSync Socket.IO clients. It is not a secret; see `docs/KNOWN_LIMITATIONS.md`.
|
||||
- `SUPPORT_URL` and `GITHUB_URL`: public project links used by the extension UI.
|
||||
|
||||
## Control Modes & Capabilities
|
||||
|
||||
- `CONTROL_MODES.EVERYONE`: default room mode; any peer may control playback.
|
||||
- `CONTROL_MODES.HOST_ONLY`: only the host and promoted controllers may move playback.
|
||||
- `CAPABILITIES.HOST_CONTROL`: relay supports host-only room authority.
|
||||
- `CAPABILITIES.CO_HOST`: relay supports promoted controller peers.
|
||||
|
||||
Clients should enable capability-gated UI only when the relay advertises the matching flag in `room_data.capabilities`.
|
||||
|
||||
## Protocol Events
|
||||
For the complete and current event list, see the `EVENTS` object in [`constants.js`](constants.js). Key events include:
|
||||
|
||||
For the complete event list, read the `EVENTS` object in [`constants.js`](constants.js). Current event groups:
|
||||
|
||||
| Event | Direction | Purpose |
|
||||
|:------|:----------|:--------|
|
||||
| `JOIN_ROOM` | Client → Server | Request to join a room with credentials |
|
||||
| `LEAVE_ROOM` | Client → Server | Leave the current room |
|
||||
| `ROOM_DATA` | Server → Client | Current room state (peers list) |
|
||||
|:---|:---|:---|
|
||||
| `JOIN_ROOM` | Client -> Server | Join or create a room with credentials and protocol version |
|
||||
| `LEAVE_ROOM` | Client -> Server | Leave the current room |
|
||||
| `ROOM_DATA` | Server -> Client | Initial room snapshot, peers, host state, capabilities |
|
||||
| `ERROR` | Server -> Client | Connection, auth, capacity, or protocol error |
|
||||
| `SET_CONTROL_MODE` | Client -> Server | Host switches between `everyone` and `host-only` |
|
||||
| `CONTROL_MODE` | Server -> Client | Host/control-mode/controller snapshot changed |
|
||||
| `SET_PEER_ROLE` | Client -> Server | Host promotes or demotes a co-host/controller |
|
||||
| `PLAY` / `PAUSE` / `SEEK` | Bidirectional relay | Media control commands |
|
||||
| `PEER_STATUS` | Bidirectional relay | Heartbeat or join/leave notification |
|
||||
| `FORCE_SYNC_PREPARE` | Bidirectional relay | Phase 1: Pause & seek to target time |
|
||||
| `FORCE_SYNC_ACK` | Bidirectional relay | Phase 1 confirmation: peer is buffered |
|
||||
| `FORCE_SYNC_EXECUTE` | Bidirectional relay | Phase 2: Resume playback simultaneously |
|
||||
| `EVENT_ACK` | Server → Client | Delivery confirmation for UI feedback |
|
||||
| `EPISODE_LOBBY` | Bidirectional relay | Episode transition: waiting for all peers |
|
||||
| `EPISODE_READY` | Bidirectional relay | Episode confirmation: peer has loaded |
|
||||
| `GET_ROOMS` / `ROOM_LIST` | Client ↔ Server | Room discovery |
|
||||
| `ERROR` | Server → Client | Error message |
|
||||
| `PEER_STATUS` | Bidirectional relay | Heartbeat and current media state |
|
||||
| `FORCE_SYNC_PREPARE` | Bidirectional relay | Phase 1: pause and seek peers to a target time |
|
||||
| `FORCE_SYNC_ACK` | Bidirectional relay | Phase 1 confirmation that a peer is ready |
|
||||
| `FORCE_SYNC_EXECUTE` | Bidirectional relay | Phase 2: resume playback together |
|
||||
| `EVENT_ACK` | Server -> Client | Delivery confirmation for UI feedback |
|
||||
| `EPISODE_LOBBY` | Bidirectional relay | Episode transition lobby started |
|
||||
| `EPISODE_READY` | Bidirectional relay | Peer has loaded the episode and is ready |
|
||||
| `EPISODE_LOBBY_CANCEL` | Bidirectional relay | Active episode lobby cancelled |
|
||||
| `GET_ROOMS` / `ROOM_LIST` | Client <-> Server | Room discovery with server-side cooldown |
|
||||
| `PING` / `PONG` | Client <-> Server/Peer | Server RTT and peer latency checks |
|
||||
|
||||
## Timing Constants
|
||||
|
||||
- `HEARTBEAT_INTERVAL`: content heartbeat interval in milliseconds.
|
||||
- `FORCE_SYNC_TIMEOUT`: max wait for force-sync ACKs.
|
||||
- `EPISODE_LOBBY_TIMEOUT`: max wait for episode-lobby readiness.
|
||||
|
||||
## Do Not Break
|
||||
|
||||
- Do not rename events without updating `docs/PROTOCOL.md`, `server/index.js`, `extension/background.js`, and the build-injected `content.js` path.
|
||||
- Do not edit `extension/shared/` directly.
|
||||
- Keep new server-gated features behind `CAPABILITIES` so old clients and old relays degrade cleanly.
|
||||
- Run `npm run build:extension` and `npm run verify` after protocol changes.
|
||||
|
||||
@@ -53,10 +53,6 @@ export const BLACKLIST_DOMAINS = [
|
||||
'docs.google.com',
|
||||
'sheets.google.com',
|
||||
'slides.google.com',
|
||||
'drive.google.com',
|
||||
'dropbox.com',
|
||||
'onedrive.live.com',
|
||||
'icloud.com',
|
||||
|
||||
// Messengers
|
||||
'web.whatsapp.com',
|
||||
@@ -117,7 +113,6 @@ export const BLACKLIST_DOMAINS = [
|
||||
'status.koalastuff.net',
|
||||
'sync.koalastuff.net',
|
||||
'timer.koalastuff.net',
|
||||
'localhost',
|
||||
'zoom.us',
|
||||
'teams.microsoft.com',
|
||||
'meet.google.com',
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = "1.0.0";
|
||||
export const APP_VERSION = "2.5.0";
|
||||
export const APP_VERSION = "2.6.0";
|
||||
|
||||
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
|
||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: 'node',
|
||||
include: [
|
||||
'server/**/*.test.js',
|
||||
'server/**/*.test.mjs',
|
||||
'shared/**/*.test.js',
|
||||
'shared/**/*.test.mjs'
|
||||
],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'lcov'],
|
||||
include: ['server/**/*.js', 'shared/**/*.js'],
|
||||
exclude: [
|
||||
'**/node_modules/**',
|
||||
'**/scripts/**',
|
||||
'**/extension/**'
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="lang-en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>404 – Page Not Found | KoalaSync</title>
|
||||
<!-- Absolute asset paths: Caddy rewrites any missing URL (also nested ones
|
||||
like /foo/bar) to this file, so relative paths would break. -->
|
||||
<link rel="preload" href="/style.min.css" as="style">
|
||||
<link rel="stylesheet" href="/style.min.css">
|
||||
<link rel="icon" type="image/webp" href="/assets/NewLogoIcon_64.webp">
|
||||
<meta name="description" content="This page could not be found.">
|
||||
<meta name="robots" content="noindex, follow">
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="/lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="container nav-content">
|
||||
<a href="/" class="logo-area" style="text-decoration: none;">
|
||||
<img src="/assets/NewLogoIcon_128.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="/" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
|
||||
<span>Home</span>
|
||||
</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="legal-content">
|
||||
<div class="legal-card" data-reveal style="padding: 2.5rem 2rem; text-align: center;">
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<img src="/assets/KoalaSearching-180.webp" srcset="/assets/KoalaSearching-180.webp 180w, /assets/KoalaSearching-360.webp 360w" sizes="180px" alt="A cute koala looking through a telescope, searching for the missing page" class="legal-mascot" width="180" height="180">
|
||||
</div>
|
||||
<h1 style="margin-bottom: 0.5rem;">404</h1>
|
||||
<p style="text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 1.5rem;">
|
||||
Page not found
|
||||
</p>
|
||||
<p style="max-width: 34rem; margin: 0 auto;">
|
||||
This page wandered off into the bamboo forest. Our koala is still looking for it — in the meantime, head back to safety.
|
||||
</p>
|
||||
<div style="display: flex; justify-content: center; gap: 1rem; margin-top: 2rem; flex-wrap: wrap;">
|
||||
<a href="/" class="btn btn-primary">
|
||||
Back to home
|
||||
</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" rel="noopener" class="btn btn-secondary">
|
||||
Report a broken link
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. Open source under the MIT License.</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="/imprint" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="/privacy" style="color: var(--text-muted); text-decoration: none;">Privacy Policy</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/app.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
4650678bb36b488d8f64343a67f1b931
|
||||
@@ -1,74 +1,110 @@
|
||||
# KoalaSync Website & Invitation Bridge
|
||||
|
||||
This directory contains the KoalaSync website. It serves a dual purpose: it is both the **marketing landing page** and the **technical bridge** for joining synchronized rooms.
|
||||
This directory contains the static KoalaSync website. It is both the public marketing/onboarding site and the browser-side invitation bridge for joining sync rooms.
|
||||
|
||||
## Where You Are
|
||||
|
||||
- `template.html`, `style.css`, `styles/*.css`, `app.js`, `lang-init.js`, and `locales/*.json` are source files. `style.css` is the local-development manifest; production concatenates the modules in the order declared by `website/build.cjs`.
|
||||
- `build.cjs` compiles the static site into `website/www/`.
|
||||
- `website/www/` is generated output. Do not edit files there directly.
|
||||
- `join.html` handles room-invite links and communicates with the extension through `bridge.js`.
|
||||
- `llms.txt` gives AI tools a detailed product profile and is linked from every localized landing-page `<head>`.
|
||||
- `alternatives/` contains comparison pages for users evaluating KoalaSync against other watch-party approaches.
|
||||
|
||||
## Core Roles
|
||||
|
||||
### 1. Marketing & Onboarding
|
||||
Provides a premium, multi-language (EN/DE/FR/ES/PT-BR/RU/IT/PL/TR/NL/JA/KO/PT) overview of features, setup instructions, and direct links to the extension stores.
|
||||
|
||||
### 2. The Invitation Bridge (`join.html`)
|
||||
The website handles incoming invitation links. When a user clicks a link like `sync.koalastuff.net/join.html#join:roomID:pass`, the website:
|
||||
- **Detects the Extension**: Verifies if KoalaSync is installed via the `bridge.js` content script.
|
||||
- **Privacy-First Handshake**: The room credentials (ID/Password) are stored in the **URL Hash (#)**. This ensures the sensitive credentials **never reach the web server** and are processed entirely within the user's browser.
|
||||
- **Auto-Join**: If the extension is detected, it automatically triggers the join flow without requiring user input.
|
||||
The site explains KoalaSync, links to browser stores, documents self-hosting, and provides localized user-facing copy in 15 languages:
|
||||
|
||||
`en`, `de`, `fr`, `es`, `it`, `nl`, `pl`, `pt`, `pt-BR`, `tr`, `ru`, `ja`, `ko`, `zh`, and `uk`.
|
||||
|
||||
### 2. Invitation Bridge (`join.html`)
|
||||
|
||||
When a user opens an invitation such as `https://sync.koalastuff.net/join.html#join:roomID:pass`, the page:
|
||||
|
||||
- detects whether the KoalaSync extension is installed via the extension's `bridge.js` content script,
|
||||
- keeps room credentials inside the URL hash so they are not sent to the web server,
|
||||
- asks the extension to join the target room automatically when possible.
|
||||
|
||||
## Architecture
|
||||
|
||||
The website is 100% **Static HTML, CSS, and JS**.
|
||||
- **Static i18n Compiler**: The site uses a lightweight, zero-dependency Node.js compiler (`build.cjs`) to parse dictionary files inside `/locales/` against a single source-of-truth template (`template.html`), outputting the fully deployable static folder to `/www/`.
|
||||
- **Build-time Minification**: `build.cjs` automatically minifies CSS and JS during compilation using a built-in state-machine tokenizer (no npm dependencies). Source files are written unminified (`style.css`, `app.js`, `lang-init.js`) — always edit source files, never the generated `.min.*` files in `www/`.
|
||||
- **Zero Backend**: No Node.js, PHP, or databases are required to host the compiled website.
|
||||
- **Zero Tracking**: All assets (fonts, icons) are self-hosted to prevent third-party tracking.
|
||||
- **Responsive**: Fully optimized for mobile with a native-feel hamburger menu.
|
||||
The website is 100% static HTML, CSS, and JavaScript.
|
||||
|
||||
- **Static i18n compiler**: `build.cjs` combines `template.html` with dictionaries in `locales/`.
|
||||
- **Build-time minification**: Source CSS/JS stays readable; generated output uses `.min.css` and `.min.js`.
|
||||
- **Strangler CSS architecture**: `styles/*.css` supplies page-specific production bundles; `style.legacy.css` is the byte-identical, unloaded reference monolith. The landing bundle preserves the legacy cascade while excluding Join- and Alternatives-only rules. Structural CSS is not activated after paint because that causes layout shifts.
|
||||
- **Zero backend**: The compiled site can be hosted by any static file server.
|
||||
- **Zero external assets**: Fonts, icons, scripts, and images must remain self-hosted.
|
||||
- **Generated SEO/runtime files**: `version.json`, `robots.txt`, `llms.txt`, clean URLs, localized pages, and minified assets are copied into `www/`; `sitemap.xml` is generated there from the current route set.
|
||||
|
||||
## Local Development & Compilation
|
||||
|
||||
From the repository root:
|
||||
```bash
|
||||
node website/build.cjs
|
||||
npx serve -l 5000 website/www
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
- `http://localhost:5000/` for the default page.
|
||||
- `http://localhost:5000/de/` for a localized page.
|
||||
- `http://localhost:5000/join.html#join:test-room:test-pass` to test the invitation bridge path.
|
||||
|
||||
Focused verification:
|
||||
```bash
|
||||
node scripts/test-website-locales.mjs
|
||||
node scripts/test-website-theme.mjs
|
||||
node --check website/app.js
|
||||
node --check website/lang-init.js
|
||||
```
|
||||
|
||||
Full verification:
|
||||
```bash
|
||||
npm run verify
|
||||
```
|
||||
|
||||
## Hosting with Caddy
|
||||
|
||||
Caddy is the recommended web server. It provides automatic HTTPS and high-performance static file serving.
|
||||
|
||||
### Recommended Caddyfile
|
||||
|
||||
For a more comprehensive configuration that includes the Relay Server reverse proxy, see the root [Caddyfile.example](../examples/Caddyfile.example).
|
||||
Caddy is the recommended production server because it handles HTTPS and static file serving cleanly. For a complete website plus relay reverse-proxy setup, use the root [Caddyfile.example](../examples/Caddyfile.example).
|
||||
|
||||
Minimal static-site block:
|
||||
```caddy
|
||||
sync.koalastuff.net {
|
||||
root * /var/www/koalasync/website/www
|
||||
try_files {path} {path}.html {path}/
|
||||
file_server
|
||||
encode zstd gzip
|
||||
|
||||
# Static Caching for high-performance PageSpeed (1 year with validation)
|
||||
# Themed 404 page (Caddy returns an empty 404 without this)
|
||||
handle_errors {
|
||||
@notfound expression {err.status_code} == 404
|
||||
rewrite @notfound /404.html
|
||||
file_server
|
||||
}
|
||||
|
||||
@static {
|
||||
file
|
||||
path *.ico *.css *.js *.png *.svg *.webp
|
||||
path *.ico *.css *.js *.png *.svg *.webp *.avif
|
||||
}
|
||||
header @static Cache-Control "public, max-age=31536000, must-revalidate"
|
||||
|
||||
# Security Headers & Content Security Policy (CSP)
|
||||
header {
|
||||
# Strict Content Security Policy (restricts scripts and connections to self, forbids frames)
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';"
|
||||
# Prevent FLoC tracking
|
||||
Permissions-Policy interest-cohort=()
|
||||
# Security best practices
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none';"
|
||||
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
X-Content-Type-Options nosniff
|
||||
X-Frame-Options DENY
|
||||
Referrer-Policy no-referrer-when-downgrade
|
||||
X-Frame-Options SAMEORIGIN
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Local Development & Compilation
|
||||
## Do Not Break
|
||||
|
||||
1. Run the compilation script from the repository root to generate the `/website/www` folder:
|
||||
```bash
|
||||
node website/build.cjs
|
||||
```
|
||||
2. Serve the compiled `/www` directory using any local development server:
|
||||
```bash
|
||||
npx serve website/www
|
||||
```
|
||||
3. To test the invitation flow locally, navigate to `http://localhost:5000/join.html#join:test-room:test-pass`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Never edit files inside `website/www/` directly.** This directory is fully auto-generated by `build.cjs`. Always edit source files (`template.html`, `style.css`, `app.js`, `lang-init.js`, locale files in `locales/`) and re-run `node website/build.cjs` to apply changes. CSS and JS are output as `style.min.css`, `app.min.js`, and `lang-init.min.js` — the `.min.*` naming makes it visually obvious these are build artifacts. Editing minified files in `www/` will result in lost changes on the next build.
|
||||
- Do not edit `website/www/` manually; rebuild it from sources.
|
||||
- Do not add external CDNs, fonts, analytics, or third-party scripts.
|
||||
- Keep invite credentials in the URL hash, not query parameters.
|
||||
- Keep locale files synchronized with `website/build.cjs` and `scripts/test-website-locales.mjs`.
|
||||
- `website/www/` is gitignored build output. Commit the source changes only; rebuild `www/` locally or in CI for verification and deployment.
|
||||
|
||||
@@ -65,6 +65,23 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Structured Data (Schema.org) for WebPage -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "{{ALT_INDEX_TITLE}}",
|
||||
"url": "https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives",
|
||||
"description": "{{ALT_INDEX_META_DESC}}",
|
||||
"inLanguage": "{{LANG_CODE}}",
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/{{LANG_PREFIX}}"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Organization Schema -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
@@ -81,17 +98,80 @@
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="{{ASSET_PATH}}lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
<div class="blob blob-3"></div>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
@@ -142,38 +222,76 @@
|
||||
<img src="{{ASSET_PATH}}assets/RemoteProf.webp" alt="Koala teaching and explaining concepts" class="legal-mascot" width="180" height="180">
|
||||
</div>
|
||||
|
||||
<h1>
|
||||
<span lang="en">Guides & Comparisons</span>
|
||||
<span lang="de">Vergleiche & Anleitungen</span>
|
||||
</h1>
|
||||
<h1>{{ALT_INDEX_HEADING}}</h1>
|
||||
<p style="text-align: center; font-size: 0.95rem; line-height: 1.6; max-width: 600px; margin: 0 auto 2rem auto; color: var(--text-muted);">
|
||||
<span lang="en">How does KoalaSync stack up against other watch party tools and streaming solutions? Read our honest, technical comparisons to find the best fit for your remote movie nights.</span>
|
||||
<span lang="de">Wie schlägt sich KoalaSync im Vergleich zu anderen Watch-Party-Tools und Streaming-Lösungen? Lies unsere ehrlichen, technischen Vergleiche, um die passende Lösung für deine Filmabende zu finden.</span>
|
||||
{{ALT_INDEX_INTRO}}
|
||||
</p>
|
||||
|
||||
<!-- Cards Grid Layout -->
|
||||
<div class="guides-grid" style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-top: 2rem; margin-bottom: 2rem;">
|
||||
<div class="guides-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1.5rem; margin-top: 2rem; margin-bottom: 2rem;">
|
||||
<!-- Teleparty Card -->
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/teleparty" class="guide-card" style="text-decoration: none; background: rgba(255, 255, 255, 0.03); border: 1px solid var(--glass-border); border-radius: 12px; padding: 1.5rem; display: flex; flex-direction: column; justify-content: space-between;">
|
||||
<div>
|
||||
<h3 style="margin-top: 0; margin-bottom: 0.75rem; color: #fff; font-size: 1.25rem;">{{ALT_TELEPARTY_TITLE}}</h3>
|
||||
<h2 style="margin-top: 0; margin-bottom: 0.75rem; color: var(--text); font-size: 1.25rem;">{{ALT_TELEPARTY_TITLE}}</h2>
|
||||
<p style="font-size: 0.9rem; line-height: 1.5; color: var(--text-muted); margin-bottom: 1.5rem;">{{ALT_TELEPARTY_META_DESC}}</p>
|
||||
</div>
|
||||
<div class="btn btn-primary" style="align-self: flex-start; padding: 0.5rem 1rem; font-size: 0.85rem; border-radius: 6px;">
|
||||
<span lang="en">Read Comparison →</span>
|
||||
<span lang="de">Vergleich lesen →</span>
|
||||
{{ALT_INDEX_READ_COMPARISON}}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Watch2Gether Card -->
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/watch2gether" class="guide-card" style="text-decoration: none; background: rgba(255, 255, 255, 0.03); border: 1px solid var(--glass-border); border-radius: 12px; padding: 1.5rem; display: flex; flex-direction: column; justify-content: space-between;">
|
||||
<div>
|
||||
<h2 style="margin-top: 0; margin-bottom: 0.75rem; color: var(--text); font-size: 1.25rem;">{{ALT_W2G_TITLE}}</h2>
|
||||
<p style="font-size: 0.9rem; line-height: 1.5; color: var(--text-muted); margin-bottom: 1.5rem;">{{ALT_W2G_META_DESC}}</p>
|
||||
</div>
|
||||
<div class="btn btn-primary" style="align-self: flex-start; padding: 0.5rem 1rem; font-size: 0.85rem; border-radius: 6px;">
|
||||
{{ALT_INDEX_READ_COMPARISON}}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Scener Card -->
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/scener" class="guide-card" style="text-decoration: none; background: rgba(255, 255, 255, 0.03); border: 1px solid var(--glass-border); border-radius: 12px; padding: 1.5rem; display: flex; flex-direction: column; justify-content: space-between;">
|
||||
<div>
|
||||
<h2 style="margin-top: 0; margin-bottom: 0.75rem; color: var(--text); font-size: 1.25rem;">{{ALT_SCENER_TITLE}}</h2>
|
||||
<p style="font-size: 0.9rem; line-height: 1.5; color: var(--text-muted); margin-bottom: 1.5rem;">{{ALT_SCENER_META_DESC}}</p>
|
||||
</div>
|
||||
<div class="btn btn-primary" style="align-self: flex-start; padding: 0.5rem 1rem; font-size: 0.85rem; border-radius: 6px;">
|
||||
{{ALT_INDEX_READ_COMPARISON}}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Kosmi Card -->
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/kosmi" class="guide-card" style="text-decoration: none; background: rgba(255, 255, 255, 0.03); border: 1px solid var(--glass-border); border-radius: 12px; padding: 1.5rem; display: flex; flex-direction: column; justify-content: space-between;">
|
||||
<div>
|
||||
<h2 style="margin-top: 0; margin-bottom: 0.75rem; color: var(--text); font-size: 1.25rem;">{{ALT_KOSMI_TITLE}}</h2>
|
||||
<p style="font-size: 0.9rem; line-height: 1.5; color: var(--text-muted); margin-bottom: 1.5rem;">{{ALT_KOSMI_META_DESC}}</p>
|
||||
</div>
|
||||
<div class="btn btn-primary" style="align-self: flex-start; padding: 0.5rem 1rem; font-size: 0.85rem; border-radius: 6px;">
|
||||
{{ALT_INDEX_READ_COMPARISON}}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- TwoSeven Card -->
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/twoseven" class="guide-card" style="text-decoration: none; background: rgba(255, 255, 255, 0.03); border: 1px solid var(--glass-border); border-radius: 12px; padding: 1.5rem; display: flex; flex-direction: column; justify-content: space-between;">
|
||||
<div>
|
||||
<h2 style="margin-top: 0; margin-bottom: 0.75rem; color: var(--text); font-size: 1.25rem;">{{ALT_TWOSEVEN_TITLE}}</h2>
|
||||
<p style="font-size: 0.9rem; line-height: 1.5; color: var(--text-muted); margin-bottom: 1.5rem;">{{ALT_TWOSEVEN_META_DESC}}</p>
|
||||
</div>
|
||||
<div class="btn btn-primary" style="align-self: flex-start; padding: 0.5rem 1rem; font-size: 0.85rem; border-radius: 6px;">
|
||||
{{ALT_INDEX_READ_COMPARISON}}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Screen Sharing Card -->
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/screen-sharing" class="guide-card" style="text-decoration: none; background: rgba(255, 255, 255, 0.03); border: 1px solid var(--glass-border); border-radius: 12px; padding: 1.5rem; display: flex; flex-direction: column; justify-content: space-between;">
|
||||
<div>
|
||||
<h3 style="margin-top: 0; margin-bottom: 0.75rem; color: #fff; font-size: 1.25rem;">{{ALT_SCREEN_TITLE}}</h3>
|
||||
<h2 style="margin-top: 0; margin-bottom: 0.75rem; color: var(--text); font-size: 1.25rem;">{{ALT_SCREEN_TITLE}}</h2>
|
||||
<p style="font-size: 0.9rem; line-height: 1.5; color: var(--text-muted); margin-bottom: 1.5rem;">{{ALT_SCREEN_META_DESC}}</p>
|
||||
</div>
|
||||
<div class="btn btn-primary" style="align-self: flex-start; padding: 0.5rem 1rem; font-size: 0.85rem; border-radius: 6px;">
|
||||
<span lang="en">Read Comparison →</span>
|
||||
<span lang="de">Vergleich lesen →</span>
|
||||
{{ALT_INDEX_READ_GUIDE}}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
@@ -193,7 +311,7 @@
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="{{ASSET_PATH}}{{FOOTER_LEGAL_LINK}}">{{FOOTER_LEGAL}}</a>
|
||||
<a href="{{ASSET_PATH}}{{FOOTER_PRIVACY_LINK}}">{{FOOTER_PRIVACY}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives"><span><span lang="en">Guides & comparisons</span><span lang="de">Vergleiche & Anleitungen</span></span></a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives">{{ALT_INDEX_FOOTER_LINK}}</a>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="display: inline-flex; align-items: center; gap: 4px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="14" height="14" aria-hidden="true" style="display: block;"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
|
||||
Mastodon
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{LANG_CODE}}" class="lang-{{LANG_CODE}}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ALT_KOSMI_TITLE}}</title>
|
||||
<link rel="preload" href="{{ASSET_PATH}}style.min.css" as="style">
|
||||
<link rel="stylesheet" href="{{ASSET_PATH}}style.min.css">
|
||||
<link rel="icon" type="image/webp" href="{{ASSET_PATH}}assets/NewLogoIcon_64.webp">
|
||||
<link rel="canonical" href="https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives/kosmi">
|
||||
|
||||
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/kosmi">
|
||||
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/alternatives/kosmi">
|
||||
<link rel="alternate" hreflang="fr" href="https://sync.koalastuff.net/fr/alternatives/kosmi">
|
||||
<link rel="alternate" hreflang="es" href="https://sync.koalastuff.net/es/alternatives/kosmi">
|
||||
<link rel="alternate" hreflang="pt-br" href="https://sync.koalastuff.net/pt-BR/alternatives/kosmi">
|
||||
<link rel="alternate" hreflang="ru" href="https://sync.koalastuff.net/ru/alternatives/kosmi">
|
||||
<link rel="alternate" hreflang="it" href="https://sync.koalastuff.net/it/alternatives/kosmi">
|
||||
<link rel="alternate" hreflang="pl" href="https://sync.koalastuff.net/pl/alternatives/kosmi">
|
||||
<link rel="alternate" hreflang="tr" href="https://sync.koalastuff.net/tr/alternatives/kosmi">
|
||||
<link rel="alternate" hreflang="nl" href="https://sync.koalastuff.net/nl/alternatives/kosmi">
|
||||
<link rel="alternate" hreflang="ja" href="https://sync.koalastuff.net/ja/alternatives/kosmi">
|
||||
<link rel="alternate" hreflang="ko" href="https://sync.koalastuff.net/ko/alternatives/kosmi">
|
||||
<link rel="alternate" hreflang="zh" href="https://sync.koalastuff.net/zh/alternatives/kosmi">
|
||||
<link rel="alternate" hreflang="uk" href="https://sync.koalastuff.net/uk/alternatives/kosmi">
|
||||
<link rel="alternate" hreflang="pt" href="https://sync.koalastuff.net/pt/alternatives/kosmi">
|
||||
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/alternatives/kosmi">
|
||||
|
||||
<meta name="description" content="{{ALT_KOSMI_META_DESC}}">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:url" content="https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives/kosmi">
|
||||
<meta property="og:title" content="{{ALT_KOSMI_TITLE}}">
|
||||
<meta property="og:description" content="{{ALT_KOSMI_META_DESC}}">
|
||||
<meta property="og:image" content="https://sync.koalastuff.net/assets/ProContraKoala.webp">
|
||||
<meta property="og:locale" content="{{OG_LOCALE}}">
|
||||
|
||||
<!-- Twitter / X -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ALT_KOSMI_TITLE}}">
|
||||
<meta name="twitter:description" content="{{ALT_KOSMI_META_DESC}}">
|
||||
<meta name="twitter:image" content="https://sync.koalastuff.net/assets/ProContraKoala.webp">
|
||||
|
||||
<!-- Structured Data (Schema.org) for Breadcrumbs -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "Home",
|
||||
"item": "https://sync.koalastuff.net/{{LANG_PREFIX}}"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "Alternatives",
|
||||
"item": "https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 3,
|
||||
"name": "KoalaSync vs Kosmi",
|
||||
"item": "https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives/kosmi"
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Structured Data (Schema.org) for WebPage -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "{{ALT_KOSMI_TITLE}}",
|
||||
"url": "https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives/kosmi",
|
||||
"description": "{{ALT_KOSMI_META_DESC}}",
|
||||
"inLanguage": "{{LANG_CODE}}",
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/{{LANG_PREFIX}}"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Organization Schema -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/",
|
||||
"logo": "https://sync.koalastuff.net/assets/NewLogoIcon_128.webp",
|
||||
"sameAs": [
|
||||
"https://github.com/Shik3i/KoalaSync",
|
||||
"https://mastodon.social/@koalastuff"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="{{ASSET_PATH}}lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="container nav-content">
|
||||
<a href="{{ASSET_PATH}}" class="logo-area" style="text-decoration: none;">
|
||||
<img src="{{ASSET_PATH}}assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="{{ASSET_PATH}}" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
|
||||
<span>Home</span>
|
||||
</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="de">🇩🇪 Deutsch</option>
|
||||
<option value="fr">🇫🇷 Français</option>
|
||||
<option value="es">🇪🇸 Español</option>
|
||||
<option value="pt-BR">🇧🇷 Português (Brasil)</option>
|
||||
<option value="ru">🇷🇺 Русский</option>
|
||||
<option value="it">🇮🇹 Italiano</option>
|
||||
<option value="pl">🇵🇱 Polski</option>
|
||||
<option value="tr">🇹🇷 Türkçe</option>
|
||||
<option value="nl">🇳🇱 Nederlands</option>
|
||||
<option value="ja">🇯🇵 日本語</option>
|
||||
<option value="ko">🇰🇷 한국어</option>
|
||||
<option value="zh">🇨🇳 中文</option>
|
||||
<option value="uk">🇺🇦 Українська</option>
|
||||
<option value="pt">🇵🇹 Português (Portugal)</option>
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="legal-content">
|
||||
<div class="legal-card" data-reveal style="padding: 2rem;">
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<img src="{{ASSET_PATH}}assets/ProContraKoala.webp" alt="Koala weighing pros and cons" class="legal-mascot" width="180" height="180">
|
||||
</div>
|
||||
|
||||
<h1>{{ALT_KOSMI_TITLE}}</h1>
|
||||
|
||||
<!-- Intro -->
|
||||
<section>
|
||||
<p><em>{{ALT_KOSMI_INTRO}}</em></p>
|
||||
</section>
|
||||
|
||||
<!-- When Kosmi may still make sense -->
|
||||
<section>
|
||||
<h2>{{ALT_KOSMI_WHEN_KOSMI_TITLE}}</h2>
|
||||
<p>{{ALT_KOSMI_WHEN_KOSMI_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- When KoalaSync may fit better -->
|
||||
<section>
|
||||
<h2>{{ALT_KOSMI_WHEN_KOALA_TITLE}}</h2>
|
||||
<p>{{ALT_KOSMI_WHEN_KOALA_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- The main difference -->
|
||||
<section>
|
||||
<h2>{{ALT_KOSMI_MAIN_DIFF_TITLE}}</h2>
|
||||
<p>{{ALT_KOSMI_MAIN_DIFF_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- Honest limitations -->
|
||||
<section>
|
||||
<h2>{{ALT_KOSMI_LIMITS_TITLE}}</h2>
|
||||
<p>{{ALT_KOSMI_LIMITS_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- Summary -->
|
||||
<section>
|
||||
<h2>{{ALT_KOSMI_SUMMARY_TITLE}}</h2>
|
||||
<p>{{ALT_KOSMI_SUMMARY_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- CTA block -->
|
||||
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--glass-border);">
|
||||
<a href="{{ASSET_PATH}}" class="btn btn-primary" style="margin-bottom: 1.5rem;">{{ALT_TRY_KOALASYNC}}</a>
|
||||
<p style="font-size: 0.9rem; text-align: center; display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/teleparty" style="color: var(--accent); text-decoration: none;">{{ALT_TELEPARTY_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/scener" style="color: var(--accent); text-decoration: none;">{{ALT_SCENER_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/twoseven" style="color: var(--accent); text-decoration: none;">{{ALT_TWOSEVEN_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/watch2gether" style="color: var(--accent); text-decoration: none;">{{ALT_W2G_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/screen-sharing" style="color: var(--accent); text-decoration: none;">{{ALT_SCREEN_TITLE}}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. {{FOOTER_MIT}}</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">{{FOOTER_RAM}}</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="{{ASSET_PATH}}{{FOOTER_LEGAL_LINK}}">{{FOOTER_LEGAL}}</a>
|
||||
<a href="{{ASSET_PATH}}{{FOOTER_PRIVACY_LINK}}">{{FOOTER_PRIVACY}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives"><span><span lang="en">Guides & comparisons</span><span lang="de">Vergleiche & Anleitungen</span></span></a>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="display: inline-flex; align-items: center; gap: 4px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="14" height="14" aria-hidden="true" style="display: block;"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
|
||||
Mastodon
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="{{ASSET_PATH}}app.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,301 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{LANG_CODE}}" class="lang-{{LANG_CODE}}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ALT_SCENER_TITLE}}</title>
|
||||
<link rel="preload" href="{{ASSET_PATH}}style.min.css" as="style">
|
||||
<link rel="stylesheet" href="{{ASSET_PATH}}style.min.css">
|
||||
<link rel="icon" type="image/webp" href="{{ASSET_PATH}}assets/NewLogoIcon_64.webp">
|
||||
<link rel="canonical" href="https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives/scener">
|
||||
|
||||
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/scener">
|
||||
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/alternatives/scener">
|
||||
<link rel="alternate" hreflang="fr" href="https://sync.koalastuff.net/fr/alternatives/scener">
|
||||
<link rel="alternate" hreflang="es" href="https://sync.koalastuff.net/es/alternatives/scener">
|
||||
<link rel="alternate" hreflang="pt-br" href="https://sync.koalastuff.net/pt-BR/alternatives/scener">
|
||||
<link rel="alternate" hreflang="ru" href="https://sync.koalastuff.net/ru/alternatives/scener">
|
||||
<link rel="alternate" hreflang="it" href="https://sync.koalastuff.net/it/alternatives/scener">
|
||||
<link rel="alternate" hreflang="pl" href="https://sync.koalastuff.net/pl/alternatives/scener">
|
||||
<link rel="alternate" hreflang="tr" href="https://sync.koalastuff.net/tr/alternatives/scener">
|
||||
<link rel="alternate" hreflang="nl" href="https://sync.koalastuff.net/nl/alternatives/scener">
|
||||
<link rel="alternate" hreflang="ja" href="https://sync.koalastuff.net/ja/alternatives/scener">
|
||||
<link rel="alternate" hreflang="ko" href="https://sync.koalastuff.net/ko/alternatives/scener">
|
||||
<link rel="alternate" hreflang="zh" href="https://sync.koalastuff.net/zh/alternatives/scener">
|
||||
<link rel="alternate" hreflang="uk" href="https://sync.koalastuff.net/uk/alternatives/scener">
|
||||
<link rel="alternate" hreflang="pt" href="https://sync.koalastuff.net/pt/alternatives/scener">
|
||||
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/alternatives/scener">
|
||||
|
||||
<meta name="description" content="{{ALT_SCENER_META_DESC}}">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:url" content="https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives/scener">
|
||||
<meta property="og:title" content="{{ALT_SCENER_TITLE}}">
|
||||
<meta property="og:description" content="{{ALT_SCENER_META_DESC}}">
|
||||
<meta property="og:image" content="https://sync.koalastuff.net/assets/ProContraKoala.webp">
|
||||
<meta property="og:locale" content="{{OG_LOCALE}}">
|
||||
|
||||
<!-- Twitter / X -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ALT_SCENER_TITLE}}">
|
||||
<meta name="twitter:description" content="{{ALT_SCENER_META_DESC}}">
|
||||
<meta name="twitter:image" content="https://sync.koalastuff.net/assets/ProContraKoala.webp">
|
||||
|
||||
<!-- Structured Data (Schema.org) for Breadcrumbs -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "Home",
|
||||
"item": "https://sync.koalastuff.net/{{LANG_PREFIX}}"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "Alternatives",
|
||||
"item": "https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 3,
|
||||
"name": "KoalaSync vs Scener",
|
||||
"item": "https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives/scener"
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Structured Data (Schema.org) for WebPage -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "{{ALT_SCENER_TITLE}}",
|
||||
"url": "https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives/scener",
|
||||
"description": "{{ALT_SCENER_META_DESC}}",
|
||||
"inLanguage": "{{LANG_CODE}}",
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/{{LANG_PREFIX}}"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Organization Schema -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/",
|
||||
"logo": "https://sync.koalastuff.net/assets/NewLogoIcon_128.webp",
|
||||
"sameAs": [
|
||||
"https://github.com/Shik3i/KoalaSync",
|
||||
"https://mastodon.social/@koalastuff"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="{{ASSET_PATH}}lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="container nav-content">
|
||||
<a href="{{ASSET_PATH}}" class="logo-area" style="text-decoration: none;">
|
||||
<img src="{{ASSET_PATH}}assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="{{ASSET_PATH}}" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
|
||||
<span>Home</span>
|
||||
</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="de">🇩🇪 Deutsch</option>
|
||||
<option value="fr">🇫🇷 Français</option>
|
||||
<option value="es">🇪🇸 Español</option>
|
||||
<option value="pt-BR">🇧🇷 Português (Brasil)</option>
|
||||
<option value="ru">🇷🇺 Русский</option>
|
||||
<option value="it">🇮🇹 Italiano</option>
|
||||
<option value="pl">🇵🇱 Polski</option>
|
||||
<option value="tr">🇹🇷 Türkçe</option>
|
||||
<option value="nl">🇳🇱 Nederlands</option>
|
||||
<option value="ja">🇯🇵 日本語</option>
|
||||
<option value="ko">🇰🇷 한국어</option>
|
||||
<option value="zh">🇨🇳 中文</option>
|
||||
<option value="uk">🇺🇦 Українська</option>
|
||||
<option value="pt">🇵🇹 Português (Portugal)</option>
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="legal-content">
|
||||
<div class="legal-card" data-reveal style="padding: 2rem;">
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<img src="{{ASSET_PATH}}assets/ProContraKoala.webp" alt="Koala weighing pros and cons" class="legal-mascot" width="180" height="180">
|
||||
</div>
|
||||
|
||||
<h1>{{ALT_SCENER_TITLE}}</h1>
|
||||
|
||||
<!-- Intro -->
|
||||
<section>
|
||||
<p><em>{{ALT_SCENER_INTRO}}</em></p>
|
||||
</section>
|
||||
|
||||
<!-- When Scener may still make sense -->
|
||||
<section>
|
||||
<h2>{{ALT_SCENER_WHEN_SCENER_TITLE}}</h2>
|
||||
<p>{{ALT_SCENER_WHEN_SCENER_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- When KoalaSync may fit better -->
|
||||
<section>
|
||||
<h2>{{ALT_SCENER_WHEN_KOALA_TITLE}}</h2>
|
||||
<p>{{ALT_SCENER_WHEN_KOALA_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- The main difference -->
|
||||
<section>
|
||||
<h2>{{ALT_SCENER_MAIN_DIFF_TITLE}}</h2>
|
||||
<p>{{ALT_SCENER_MAIN_DIFF_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- Honest limitations -->
|
||||
<section>
|
||||
<h2>{{ALT_SCENER_LIMITS_TITLE}}</h2>
|
||||
<p>{{ALT_SCENER_LIMITS_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- Summary -->
|
||||
<section>
|
||||
<h2>{{ALT_SCENER_SUMMARY_TITLE}}</h2>
|
||||
<p>{{ALT_SCENER_SUMMARY_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- CTA block -->
|
||||
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--glass-border);">
|
||||
<a href="{{ASSET_PATH}}" class="btn btn-primary" style="margin-bottom: 1.5rem;">{{ALT_TRY_KOALASYNC}}</a>
|
||||
<p style="font-size: 0.9rem; text-align: center; display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/kosmi" style="color: var(--accent); text-decoration: none;">{{ALT_KOSMI_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/twoseven" style="color: var(--accent); text-decoration: none;">{{ALT_TWOSEVEN_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/teleparty" style="color: var(--accent); text-decoration: none;">{{ALT_TELEPARTY_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/watch2gether" style="color: var(--accent); text-decoration: none;">{{ALT_W2G_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/screen-sharing" style="color: var(--accent); text-decoration: none;">{{ALT_SCREEN_TITLE}}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. {{FOOTER_MIT}}</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">{{FOOTER_RAM}}</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="{{ASSET_PATH}}{{FOOTER_LEGAL_LINK}}">{{FOOTER_LEGAL}}</a>
|
||||
<a href="{{ASSET_PATH}}{{FOOTER_PRIVACY_LINK}}">{{FOOTER_PRIVACY}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives"><span><span lang="en">Guides & comparisons</span><span lang="de">Vergleiche & Anleitungen</span></span></a>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="display: inline-flex; align-items: center; gap: 4px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="14" height="14" aria-hidden="true" style="display: block;"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
|
||||
Mastodon
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="{{ASSET_PATH}}app.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -71,6 +71,23 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Structured Data (Schema.org) for WebPage -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "{{ALT_SCREEN_TITLE}}",
|
||||
"url": "https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives/screen-sharing",
|
||||
"description": "{{ALT_SCREEN_META_DESC}}",
|
||||
"inLanguage": "{{LANG_CODE}}",
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/{{LANG_PREFIX}}"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Organization Schema -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
@@ -87,17 +104,80 @@
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="{{ASSET_PATH}}lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
<div class="blob blob-3"></div>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
@@ -188,7 +268,11 @@
|
||||
<!-- CTA block -->
|
||||
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--glass-border);">
|
||||
<a href="{{ASSET_PATH}}" class="btn btn-primary" style="margin-bottom: 1.5rem;">{{ALT_TRY_KOALASYNC}}</a>
|
||||
<p style="font-size: 0.9rem; text-align: center;">
|
||||
<p style="font-size: 0.9rem; text-align: center; display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/watch2gether" style="color: var(--accent); text-decoration: none;">{{ALT_W2G_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/scener" style="color: var(--accent); text-decoration: none;">{{ALT_SCENER_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/kosmi" style="color: var(--accent); text-decoration: none;">{{ALT_KOSMI_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/twoseven" style="color: var(--accent); text-decoration: none;">{{ALT_TWOSEVEN_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/teleparty" style="color: var(--accent); text-decoration: none;">{{ALT_TELEPARTY_TITLE}}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -71,6 +71,23 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Structured Data (Schema.org) for WebPage -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "{{ALT_TELEPARTY_TITLE}}",
|
||||
"url": "https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives/teleparty",
|
||||
"description": "{{ALT_TELEPARTY_META_DESC}}",
|
||||
"inLanguage": "{{LANG_CODE}}",
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/{{LANG_PREFIX}}"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Organization Schema -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
@@ -87,17 +104,80 @@
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="{{ASSET_PATH}}lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
<div class="blob blob-3"></div>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
@@ -188,7 +268,11 @@
|
||||
<!-- CTA block -->
|
||||
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--glass-border);">
|
||||
<a href="{{ASSET_PATH}}" class="btn btn-primary" style="margin-bottom: 1.5rem;">{{ALT_TRY_KOALASYNC}}</a>
|
||||
<p style="font-size: 0.9rem; text-align: center;">
|
||||
<p style="font-size: 0.9rem; text-align: center; display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/watch2gether" style="color: var(--accent); text-decoration: none;">{{ALT_W2G_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/scener" style="color: var(--accent); text-decoration: none;">{{ALT_SCENER_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/kosmi" style="color: var(--accent); text-decoration: none;">{{ALT_KOSMI_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/twoseven" style="color: var(--accent); text-decoration: none;">{{ALT_TWOSEVEN_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/screen-sharing" style="color: var(--accent); text-decoration: none;">{{ALT_SCREEN_TITLE}}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{LANG_CODE}}" class="lang-{{LANG_CODE}}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ALT_TWOSEVEN_TITLE}}</title>
|
||||
<link rel="preload" href="{{ASSET_PATH}}style.min.css" as="style">
|
||||
<link rel="stylesheet" href="{{ASSET_PATH}}style.min.css">
|
||||
<link rel="icon" type="image/webp" href="{{ASSET_PATH}}assets/NewLogoIcon_64.webp">
|
||||
<link rel="canonical" href="https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives/twoseven">
|
||||
|
||||
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/twoseven">
|
||||
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/alternatives/twoseven">
|
||||
<link rel="alternate" hreflang="fr" href="https://sync.koalastuff.net/fr/alternatives/twoseven">
|
||||
<link rel="alternate" hreflang="es" href="https://sync.koalastuff.net/es/alternatives/twoseven">
|
||||
<link rel="alternate" hreflang="pt-br" href="https://sync.koalastuff.net/pt-BR/alternatives/twoseven">
|
||||
<link rel="alternate" hreflang="ru" href="https://sync.koalastuff.net/ru/alternatives/twoseven">
|
||||
<link rel="alternate" hreflang="it" href="https://sync.koalastuff.net/it/alternatives/twoseven">
|
||||
<link rel="alternate" hreflang="pl" href="https://sync.koalastuff.net/pl/alternatives/twoseven">
|
||||
<link rel="alternate" hreflang="tr" href="https://sync.koalastuff.net/tr/alternatives/twoseven">
|
||||
<link rel="alternate" hreflang="nl" href="https://sync.koalastuff.net/nl/alternatives/twoseven">
|
||||
<link rel="alternate" hreflang="ja" href="https://sync.koalastuff.net/ja/alternatives/twoseven">
|
||||
<link rel="alternate" hreflang="ko" href="https://sync.koalastuff.net/ko/alternatives/twoseven">
|
||||
<link rel="alternate" hreflang="zh" href="https://sync.koalastuff.net/zh/alternatives/twoseven">
|
||||
<link rel="alternate" hreflang="uk" href="https://sync.koalastuff.net/uk/alternatives/twoseven">
|
||||
<link rel="alternate" hreflang="pt" href="https://sync.koalastuff.net/pt/alternatives/twoseven">
|
||||
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/alternatives/twoseven">
|
||||
|
||||
<meta name="description" content="{{ALT_TWOSEVEN_META_DESC}}">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:url" content="https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives/twoseven">
|
||||
<meta property="og:title" content="{{ALT_TWOSEVEN_TITLE}}">
|
||||
<meta property="og:description" content="{{ALT_TWOSEVEN_META_DESC}}">
|
||||
<meta property="og:image" content="https://sync.koalastuff.net/assets/ProContraKoala.webp">
|
||||
<meta property="og:locale" content="{{OG_LOCALE}}">
|
||||
|
||||
<!-- Twitter / X -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ALT_TWOSEVEN_TITLE}}">
|
||||
<meta name="twitter:description" content="{{ALT_TWOSEVEN_META_DESC}}">
|
||||
<meta name="twitter:image" content="https://sync.koalastuff.net/assets/ProContraKoala.webp">
|
||||
|
||||
<!-- Structured Data (Schema.org) for Breadcrumbs -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "Home",
|
||||
"item": "https://sync.koalastuff.net/{{LANG_PREFIX}}"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "Alternatives",
|
||||
"item": "https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 3,
|
||||
"name": "KoalaSync vs TwoSeven",
|
||||
"item": "https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives/twoseven"
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Structured Data (Schema.org) for WebPage -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "{{ALT_TWOSEVEN_TITLE}}",
|
||||
"url": "https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives/twoseven",
|
||||
"description": "{{ALT_TWOSEVEN_META_DESC}}",
|
||||
"inLanguage": "{{LANG_CODE}}",
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/{{LANG_PREFIX}}"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Organization Schema -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/",
|
||||
"logo": "https://sync.koalastuff.net/assets/NewLogoIcon_128.webp",
|
||||
"sameAs": [
|
||||
"https://github.com/Shik3i/KoalaSync",
|
||||
"https://mastodon.social/@koalastuff"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="{{ASSET_PATH}}lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="container nav-content">
|
||||
<a href="{{ASSET_PATH}}" class="logo-area" style="text-decoration: none;">
|
||||
<img src="{{ASSET_PATH}}assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="{{ASSET_PATH}}" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
|
||||
<span>Home</span>
|
||||
</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="de">🇩🇪 Deutsch</option>
|
||||
<option value="fr">🇫🇷 Français</option>
|
||||
<option value="es">🇪🇸 Español</option>
|
||||
<option value="pt-BR">🇧🇷 Português (Brasil)</option>
|
||||
<option value="ru">🇷🇺 Русский</option>
|
||||
<option value="it">🇮🇹 Italiano</option>
|
||||
<option value="pl">🇵🇱 Polski</option>
|
||||
<option value="tr">🇹🇷 Türkçe</option>
|
||||
<option value="nl">🇳🇱 Nederlands</option>
|
||||
<option value="ja">🇯🇵 日本語</option>
|
||||
<option value="ko">🇰🇷 한국어</option>
|
||||
<option value="zh">🇨🇳 中文</option>
|
||||
<option value="uk">🇺🇦 Українська</option>
|
||||
<option value="pt">🇵🇹 Português (Portugal)</option>
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="legal-content">
|
||||
<div class="legal-card" data-reveal style="padding: 2rem;">
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<img src="{{ASSET_PATH}}assets/ProContraKoala.webp" alt="Koala weighing pros and cons" class="legal-mascot" width="180" height="180">
|
||||
</div>
|
||||
|
||||
<h1>{{ALT_TWOSEVEN_TITLE}}</h1>
|
||||
|
||||
<!-- Intro -->
|
||||
<section>
|
||||
<p><em>{{ALT_TWOSEVEN_INTRO}}</em></p>
|
||||
</section>
|
||||
|
||||
<!-- When TwoSeven may still make sense -->
|
||||
<section>
|
||||
<h2>{{ALT_TWOSEVEN_WHEN_TWOSEVEN_TITLE}}</h2>
|
||||
<p>{{ALT_TWOSEVEN_WHEN_TWOSEVEN_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- When KoalaSync may fit better -->
|
||||
<section>
|
||||
<h2>{{ALT_TWOSEVEN_WHEN_KOALA_TITLE}}</h2>
|
||||
<p>{{ALT_TWOSEVEN_WHEN_KOALA_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- The main difference -->
|
||||
<section>
|
||||
<h2>{{ALT_TWOSEVEN_MAIN_DIFF_TITLE}}</h2>
|
||||
<p>{{ALT_TWOSEVEN_MAIN_DIFF_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- Honest limitations -->
|
||||
<section>
|
||||
<h2>{{ALT_TWOSEVEN_LIMITS_TITLE}}</h2>
|
||||
<p>{{ALT_TWOSEVEN_LIMITS_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- Summary -->
|
||||
<section>
|
||||
<h2>{{ALT_TWOSEVEN_SUMMARY_TITLE}}</h2>
|
||||
<p>{{ALT_TWOSEVEN_SUMMARY_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- CTA block -->
|
||||
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--glass-border);">
|
||||
<a href="{{ASSET_PATH}}" class="btn btn-primary" style="margin-bottom: 1.5rem;">{{ALT_TRY_KOALASYNC}}</a>
|
||||
<p style="font-size: 0.9rem; text-align: center; display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/teleparty" style="color: var(--accent); text-decoration: none;">{{ALT_TELEPARTY_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/scener" style="color: var(--accent); text-decoration: none;">{{ALT_SCENER_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/kosmi" style="color: var(--accent); text-decoration: none;">{{ALT_KOSMI_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/watch2gether" style="color: var(--accent); text-decoration: none;">{{ALT_W2G_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/screen-sharing" style="color: var(--accent); text-decoration: none;">{{ALT_SCREEN_TITLE}}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. {{FOOTER_MIT}}</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">{{FOOTER_RAM}}</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="{{ASSET_PATH}}{{FOOTER_LEGAL_LINK}}">{{FOOTER_LEGAL}}</a>
|
||||
<a href="{{ASSET_PATH}}{{FOOTER_PRIVACY_LINK}}">{{FOOTER_PRIVACY}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives"><span><span lang="en">Guides & comparisons</span><span lang="de">Vergleiche & Anleitungen</span></span></a>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="display: inline-flex; align-items: center; gap: 4px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="14" height="14" aria-hidden="true" style="display: block;"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
|
||||
Mastodon
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="{{ASSET_PATH}}app.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,301 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{LANG_CODE}}" class="lang-{{LANG_CODE}}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ALT_W2G_TITLE}}</title>
|
||||
<link rel="preload" href="{{ASSET_PATH}}style.min.css" as="style">
|
||||
<link rel="stylesheet" href="{{ASSET_PATH}}style.min.css">
|
||||
<link rel="icon" type="image/webp" href="{{ASSET_PATH}}assets/NewLogoIcon_64.webp">
|
||||
<link rel="canonical" href="https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives/watch2gether">
|
||||
|
||||
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/alternatives/watch2gether">
|
||||
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/alternatives/watch2gether">
|
||||
<link rel="alternate" hreflang="fr" href="https://sync.koalastuff.net/fr/alternatives/watch2gether">
|
||||
<link rel="alternate" hreflang="es" href="https://sync.koalastuff.net/es/alternatives/watch2gether">
|
||||
<link rel="alternate" hreflang="pt-br" href="https://sync.koalastuff.net/pt-BR/alternatives/watch2gether">
|
||||
<link rel="alternate" hreflang="ru" href="https://sync.koalastuff.net/ru/alternatives/watch2gether">
|
||||
<link rel="alternate" hreflang="it" href="https://sync.koalastuff.net/it/alternatives/watch2gether">
|
||||
<link rel="alternate" hreflang="pl" href="https://sync.koalastuff.net/pl/alternatives/watch2gether">
|
||||
<link rel="alternate" hreflang="tr" href="https://sync.koalastuff.net/tr/alternatives/watch2gether">
|
||||
<link rel="alternate" hreflang="nl" href="https://sync.koalastuff.net/nl/alternatives/watch2gether">
|
||||
<link rel="alternate" hreflang="ja" href="https://sync.koalastuff.net/ja/alternatives/watch2gether">
|
||||
<link rel="alternate" hreflang="ko" href="https://sync.koalastuff.net/ko/alternatives/watch2gether">
|
||||
<link rel="alternate" hreflang="zh" href="https://sync.koalastuff.net/zh/alternatives/watch2gether">
|
||||
<link rel="alternate" hreflang="uk" href="https://sync.koalastuff.net/uk/alternatives/watch2gether">
|
||||
<link rel="alternate" hreflang="pt" href="https://sync.koalastuff.net/pt/alternatives/watch2gether">
|
||||
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/alternatives/watch2gether">
|
||||
|
||||
<meta name="description" content="{{ALT_W2G_META_DESC}}">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:url" content="https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives/watch2gether">
|
||||
<meta property="og:title" content="{{ALT_W2G_TITLE}}">
|
||||
<meta property="og:description" content="{{ALT_W2G_META_DESC}}">
|
||||
<meta property="og:image" content="https://sync.koalastuff.net/assets/ProContraKoala.webp">
|
||||
<meta property="og:locale" content="{{OG_LOCALE}}">
|
||||
|
||||
<!-- Twitter / X -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ALT_W2G_TITLE}}">
|
||||
<meta name="twitter:description" content="{{ALT_W2G_META_DESC}}">
|
||||
<meta name="twitter:image" content="https://sync.koalastuff.net/assets/ProContraKoala.webp">
|
||||
|
||||
<!-- Structured Data (Schema.org) for Breadcrumbs -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "Home",
|
||||
"item": "https://sync.koalastuff.net/{{LANG_PREFIX}}"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "Alternatives",
|
||||
"item": "https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 3,
|
||||
"name": "KoalaSync vs Watch2Gether",
|
||||
"item": "https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives/watch2gether"
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Structured Data (Schema.org) for WebPage -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "{{ALT_W2G_TITLE}}",
|
||||
"url": "https://sync.koalastuff.net/{{LANG_PREFIX}}alternatives/watch2gether",
|
||||
"description": "{{ALT_W2G_META_DESC}}",
|
||||
"inLanguage": "{{LANG_CODE}}",
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/{{LANG_PREFIX}}"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Organization Schema -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/",
|
||||
"logo": "https://sync.koalastuff.net/assets/NewLogoIcon_128.webp",
|
||||
"sameAs": [
|
||||
"https://github.com/Shik3i/KoalaSync",
|
||||
"https://mastodon.social/@koalastuff"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="{{ASSET_PATH}}lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="container nav-content">
|
||||
<a href="{{ASSET_PATH}}" class="logo-area" style="text-decoration: none;">
|
||||
<img src="{{ASSET_PATH}}assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="{{ASSET_PATH}}" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
|
||||
<span>Home</span>
|
||||
</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="de">🇩🇪 Deutsch</option>
|
||||
<option value="fr">🇫🇷 Français</option>
|
||||
<option value="es">🇪🇸 Español</option>
|
||||
<option value="pt-BR">🇧🇷 Português (Brasil)</option>
|
||||
<option value="ru">🇷🇺 Русский</option>
|
||||
<option value="it">🇮🇹 Italiano</option>
|
||||
<option value="pl">🇵🇱 Polski</option>
|
||||
<option value="tr">🇹🇷 Türkçe</option>
|
||||
<option value="nl">🇳🇱 Nederlands</option>
|
||||
<option value="ja">🇯🇵 日本語</option>
|
||||
<option value="ko">🇰🇷 한국어</option>
|
||||
<option value="zh">🇨🇳 中文</option>
|
||||
<option value="uk">🇺🇦 Українська</option>
|
||||
<option value="pt">🇵🇹 Português (Portugal)</option>
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="legal-content">
|
||||
<div class="legal-card" data-reveal style="padding: 2rem;">
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<img src="{{ASSET_PATH}}assets/ProContraKoala.webp" alt="Koala weighing pros and cons" class="legal-mascot" width="180" height="180">
|
||||
</div>
|
||||
|
||||
<h1>{{ALT_W2G_TITLE}}</h1>
|
||||
|
||||
<!-- Intro -->
|
||||
<section>
|
||||
<p><em>{{ALT_W2G_INTRO}}</em></p>
|
||||
</section>
|
||||
|
||||
<!-- When Watch2Gether may still make sense -->
|
||||
<section>
|
||||
<h2>{{ALT_W2G_WHEN_W2G_TITLE}}</h2>
|
||||
<p>{{ALT_W2G_WHEN_W2G_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- When KoalaSync may fit better -->
|
||||
<section>
|
||||
<h2>{{ALT_W2G_WHEN_KOALA_TITLE}}</h2>
|
||||
<p>{{ALT_W2G_WHEN_KOALA_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- The main difference -->
|
||||
<section>
|
||||
<h2>{{ALT_W2G_MAIN_DIFF_TITLE}}</h2>
|
||||
<p>{{ALT_W2G_MAIN_DIFF_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- Honest limitations -->
|
||||
<section>
|
||||
<h2>{{ALT_W2G_LIMITS_TITLE}}</h2>
|
||||
<p>{{ALT_W2G_LIMITS_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- Summary -->
|
||||
<section>
|
||||
<h2>{{ALT_W2G_SUMMARY_TITLE}}</h2>
|
||||
<p>{{ALT_W2G_SUMMARY_BODY}}</p>
|
||||
</section>
|
||||
|
||||
<!-- CTA block -->
|
||||
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--glass-border);">
|
||||
<a href="{{ASSET_PATH}}" class="btn btn-primary" style="margin-bottom: 1.5rem;">{{ALT_TRY_KOALASYNC}}</a>
|
||||
<p style="font-size: 0.9rem; text-align: center; display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/scener" style="color: var(--accent); text-decoration: none;">{{ALT_SCENER_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/kosmi" style="color: var(--accent); text-decoration: none;">{{ALT_KOSMI_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/twoseven" style="color: var(--accent); text-decoration: none;">{{ALT_TWOSEVEN_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/teleparty" style="color: var(--accent); text-decoration: none;">{{ALT_TELEPARTY_TITLE}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives/screen-sharing" style="color: var(--accent); text-decoration: none;">{{ALT_SCREEN_TITLE}}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. {{FOOTER_MIT}}</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">{{FOOTER_RAM}}</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="{{ASSET_PATH}}{{FOOTER_LEGAL_LINK}}">{{FOOTER_LEGAL}}</a>
|
||||
<a href="{{ASSET_PATH}}{{FOOTER_PRIVACY_LINK}}">{{FOOTER_PRIVACY}}</a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives"><span><span lang="en">Guides & comparisons</span><span lang="de">Vergleiche & Anleitungen</span></span></a>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="display: inline-flex; align-items: center; gap: 4px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="14" height="14" aria-hidden="true" style="display: block;"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
|
||||
Mastodon
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="{{ASSET_PATH}}app.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"comment": "Generated by tools/subset-flag-font.mjs — do not edit. build.cjs fails when site flags and this manifest diverge.",
|
||||
"flags": [
|
||||
"BR",
|
||||
"CN",
|
||||
"DE",
|
||||
"ES",
|
||||
"FR",
|
||||
"GB",
|
||||
"IT",
|
||||
"JP",
|
||||
"KR",
|
||||
"NL",
|
||||
"PL",
|
||||
"PT",
|
||||
"RU",
|
||||
"TR",
|
||||
"UA"
|
||||
],
|
||||
"sourceFontSha256": "9f04f14429bb6a9f415c7a4dd902a918d7e81a4f7526c415496fdb063954e3b8",
|
||||
"subsetSha256": "d6683cd924e343443effaa96008656e9914c168afad6cd8ac168af79e2b65811"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1041px" height="565px" viewBox="0 0 1041 565" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1">
|
||||
<!-- Generated by Pixelmator Pro 2.2 -->
|
||||
<g id="Layer_1">
|
||||
<g id="group">
|
||||
<g id="group-1">
|
||||
<g id="group-2">
|
||||
<path id="Path" d="M735.8 365.7 C721.4 369 683.5 370.9 683.5 370.9 L678.7 385.9 C678.7 385.9 697.6 384.3 711.4 385.7 711.4 385.7 715.9 385.2 716.4 390.8 716.6 396 716 401.6 716 401.6 716 401.6 715.7 405 710.9 405.8 705.7 406.7 670.1 408 670.1 408 L664.3 427.5 C664.3 427.5 662.2 432 667 430.7 671.5 429.5 708.8 422.5 713.7 423.5 718.9 424.8 724.7 431.7 723 438.1 721 445.9 683.8 469.7 661.1 468 661.1 468 649.2 468.8 639.1 452.7 629.7 437.4 642.7 408.3 642.7 408.3 642.7 408.3 636.8 394.7 641.1 390.2 641.1 390.2 643.7 387.9 651.1 387.3 L660.2 368.4 C660.2 368.4 649.8 369.1 643.6 361.5 637.8 354.2 637.4 350.9 641.8 348.9 646.5 346.6 689.8 338.7 719.6 339.7 719.6 339.7 730 338.7 738.9 356.7 738.8 356.7 743.2 364 735.8 365.7 Z M623.7 438.3 C619.9 447.3 609.8 456.9 597.3 450.9 584.9 444.9 565.2 404.6 565.2 404.6 565.2 404.6 557.7 389.6 556.3 389.9 556.3 389.9 554.7 387 553.7 403.4 552.7 419.8 553.9 451.7 547.4 456.7 541.2 461.7 533.7 459.7 529.8 453.8 526.3 448 524.8 434.2 526.7 410 529 385.8 534.6 360 541.8 351.9 549 343.9 554.8 349.7 557 351.8 557 351.8 566.6 360.5 582.5 386.1 L585.3 390.8 C585.3 390.8 599.7 415 601.2 414.9 601.2 414.9 602.4 416 603.4 415.2 604.9 414.8 604.3 407 604.3 407 604.3 407 601.3 380.7 588.2 336.1 588.2 336.1 586.2 330.5 587.6 325.3 588.9 320 594.2 322.5 594.2 322.5 594.2 322.5 614.6 332.7 624.4 365.9 634.1 399.4 627.5 429.3 623.7 438.3 Z M523.5 353 C521.8 356.4 520.8 361.3 512.2 362.6 512.2 362.6 429.9 368.2 426 374 426 374 423.1 377.4 427.6 378.4 432.1 379.3 450.7 381.8 459.7 382.3 469.3 382.4 501.7 382.7 513.3 397.2 513.3 397.2 520.2 404.1 519.9 419.7 519.6 435.7 516.8 441.3 510.6 447.1 504.1 452.5 448.3 477.5 412.3 439.1 412.3 439.1 395.7 420.6 418 406.6 418 406.6 434.1 396.9 475 408.3 475 408.3 487.4 412.8 486.8 417.3 486.1 422.1 476.6 427.2 462.8 426.9 449.4 426.5 439.6 420.1 441.5 421.1 443.3 421.8 427.1 413.3 422.1 419.1 417.1 424.4 418.3 427.7 423.2 431 435.7 438.1 484 435.6 498.4 419.6 498.4 419.6 504.1 413.1 495.4 407.8 486.7 402.8 461.8 399.8 452.1 399.3 442.8 398.8 408.2 399.4 403.2 390.2 403.2 390.2 398.2 384 403.7 366.4 409.5 348 449.8 340.9 467.2 339.3 467.2 339.3 515.1 337.6 523.9 347.4 523.8 347.4 525 349.7 523.5 353 Z M387.5 460.9 C381.7 465.2 369.4 463.3 365.9 458.5 362.4 454.2 361.2 437.1 361.9 410.3 362.6 383.2 363.2 349.6 369 344.3 375.2 338.9 379 343.6 381.4 347.3 384 350.9 387.1 354.9 387.8 363.4 388.4 371.9 390.4 416.5 390.4 416.5 390.4 416.5 393 456.7 387.5 460.9 Z M400 317.1 C383.1 322.7 371.5 320.8 361.7 316.6 357.4 324.1 354.9 326.4 351.6 326.9 346.8 327.4 342.5 319.7 341.7 317.2 340.9 315.3 338.6 312.1 341.4 304.5 331.8 295.9 331.1 284.3 332.7 276.5 335.1 267.5 351.3 233.3 400.6 229.3 400.6 229.3 424.7 227.5 428.8 240.4 L429.5 240.4 C429.5 240.4 452.9 240.5 452.4 261.3 452.1 282.2 426.4 308.2 400 317.1 Z M354 270.8 C349 278.8 348.8 283.6 351.1 286.9 356.8 278.2 367.2 264.5 382.5 254.1 370.7 255.1 360.8 260.2 354 270.8 Z M422.1 257.4 C406.6 259.7 382.6 280.5 371.2 297.5 388.7 300.7 419.6 299.5 433.3 271.6 433.2 271.6 439.8 254.3 422.1 257.4 Z M842.9 418.5 C833.6 434.7 807.5 468.5 772.7 460.6 761.2 488.5 751.6 516.6 746.1 558.8 746.1 558.8 744.9 567 738.1 564.1 731.4 561.7 720.2 550.5 718 535 715.6 514.6 724.7 480.1 743.2 440.6 737.8 431.8 734.1 419.2 737.3 401.3 737.3 401.3 742 368.1 775.3 338.1 775.3 338.1 779.3 334.6 781.6 335.7 784.2 336.8 783 347.6 780.9 352.8 778.8 358 763.9 383.8 763.9 383.8 763.9 383.8 754.6 401.2 757.2 414.9 774.7 388 814.5 333.7 839.2 350.8 847.5 356.7 851.3 369.6 851.3 383.5 851.2 395.8 848.3 408.8 842.9 418.5 Z M835.7 375.9 C835.7 375.9 834.3 365.2 823.9 377 814.9 386.9 798.7 405.6 785.6 430.9 799.3 429.4 812.5 421.9 816.5 418.1 823 412.3 838.1 396.7 835.7 375.9 Z M350.2 389.5 C348.3 413.7 339 454.4 273.1 474.5 229.6 487.6 188.5 481.3 166.1 475.6 165.6 484.5 164.6 488.3 163.2 489.8 161.3 491.7 147.1 499.9 139.3 488.3 135.8 482.8 134 472.8 133 463.9 82.6 440.7 59.4 407.3 58.5 405.8 57.4 404.7 45.9 392.7 57.4 378 68.2 364.7 103.5 351.4 135.3 346 136.4 318.8 139.6 298.3 143.4 288.9 148 278 153.8 287.8 158.8 295.2 163 300.7 165.5 324.4 165.7 343.3 186.5 342.3 198.8 343.8 222 348 252.2 353.5 272.4 368.9 270.6 386.4 269.3 403.6 253.5 410.7 247.5 411.2 241.2 411.7 231.4 407.2 231.4 407.2 224.7 404 230.9 401.2 239 397.7 247.8 393.4 245.8 389 245.8 389 242.5 379.4 203.3 372.7 164.3 372.7 164.1 394.2 165.2 429.9 165.7 450.7 193 455.9 213.4 454.9 213.4 454.9 213.4 454.9 313 452.1 316 388.5 319.1 324.8 216.7 263.7 141 244.3 65.4 224.5 22.6 238.3 18.9 240.2 14.9 242.2 18.6 242.8 18.6 242.8 18.6 242.8 22.7 243.4 29.8 245.8 37.3 248.2 31.5 252.1 31.5 252.1 18.6 256.2 4.1 253.6 1.3 247.7 -1.5 241.8 3.2 236.5 8.6 228.9 14 220.9 19.9 221.2 19.9 221.2 113.4 188.8 227.3 247.4 227.3 247.4 334 301.5 352.2 364.9 350.2 389.5 Z M68 386.2 C57.4 391.4 64.7 398.9 64.7 398.9 84.6 420.3 109.1 433.7 132.4 442 135.1 405.1 134.7 392.1 135 373.5 98.6 376 77.6 381.8 68 386.2 Z" fill="#01147c" fill-opacity="1" stroke="none"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="group-3">
|
||||
<g id="group-4">
|
||||
<g id="group-5">
|
||||
<path id="Path-1" d="M1040.9 378.6 L1040.9 391.8 C1040.9 394.7 1038.6 397 1035.7 397 L972.8 397 C972.8 400.3 972.9 403.2 972.9 405.9 972.9 425.4 972.1 441.3 970.2 459.2 969.9 461.9 967.7 463.9 965.1 463.9 L951.5 463.9 C950.1 463.9 948.8 463.3 947.9 462.3 947 461.3 946.5 459.9 946.7 458.5 948.6 440.7 949.5 425 949.5 405.9 949.5 403.1 949.5 400.2 949.4 397 L887.2 397 C884.3 397 882 394.7 882 391.8 L882 378.6 C882 375.7 884.3 373.4 887.2 373.4 L948.5 373.4 C947.2 351.9 944.6 331.2 940.4 310.2 940.2 308.9 940.5 307.6 941.3 306.6 942.1 305.6 943.3 305 944.6 305 L959.3 305 C961.6 305 963.5 306.6 964 308.9 968.1 330.6 970.7 351.7 972 373.4 L1035.7 373.4 C1038.5 373.4 1040.9 375.8 1040.9 378.6 Z" fill="#01147c" fill-opacity="1" stroke="none"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="group-6">
|
||||
<g id="group-7">
|
||||
<path id="Path-2" d="M200.2 204.3 L200.1 204.3 M199.4 204.4 C199.1 204.4 198.8 204.3 198.5 204.3 198.8 204.4 199.1 204.4 199.4 204.4 L199.7 204.4 C199.6 204.4 199.5 204.4 199.4 204.4 Z M199.4 204.4 C199.1 204.4 198.8 204.3 198.5 204.3 198.8 204.4 199.1 204.4 199.4 204.4 L199.7 204.4 C199.6 204.4 199.5 204.4 199.4 204.4 Z" fill="none" stroke="none"/>
|
||||
<defs>
|
||||
<radialGradient id="radial-gradient" gradientUnits="userSpaceOnUse" cx="942.524" cy="279.896" r="760.124" fx="942.524" fy="279.896">
|
||||
<stop offset="0.007" stop-color="#021192" stop-opacity="1"/>
|
||||
<stop offset="0.03" stop-color="#021096" stop-opacity="1"/>
|
||||
<stop offset="0.057" stop-color="#010cb4" stop-opacity="1"/>
|
||||
<stop offset="0.084" stop-color="#0008ce" stop-opacity="1"/>
|
||||
<stop offset="0.111" stop-color="#0006d7" stop-opacity="1"/>
|
||||
<stop offset="0.138" stop-color="#0004e1" stop-opacity="1"/>
|
||||
<stop offset="0.165" stop-color="#0001fa" stop-opacity="1"/>
|
||||
<stop offset="0.191" stop-color="#0000fe" stop-opacity="1"/>
|
||||
<stop offset="0.216" stop-color="#0f1eff" stop-opacity="1"/>
|
||||
<stop offset="1" stop-color="#00ffff" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<path id="Path-3" d="M955.3 273.9 C922.8 194 867.9 125.9 796.5 76.9 723.4 26.8 637.7 0.3 548.7 0.3 401.5 0.3 264.9 73.4 183.4 195.9 182.5 197.2 182.3 198.9 182.8 200.4 183.3 202 184.5 203.1 186 203.6 L197.4 207.5 C198.1 207.7 198.8 207.8 199.4 207.8 201.5 207.8 203.5 206.7 204.7 205 242.1 150 292.7 104.3 351.1 72.7 411.4 40.1 479.7 22.8 548.6 22.8 631.9 22.8 712.2 47.4 781 93.8 848.1 139.1 900.2 202.4 931.7 276.7 932.6 278.9 934.8 280.4 937.2 280.4 L950.8 280.4 C952.4 280.4 953.9 279.6 954.7 278.3 955.7 277 955.9 275.4 955.3 273.9 Z M199.4 204.4 C199.1 204.4 198.8 204.3 198.5 204.2 198.8 204.3 199.1 204.4 199.4 204.4 L199.6 204.4 C199.6 204.4 199.5 204.4 199.4 204.4 Z M934.4 278.6 C934.7 278.8 935 279 935.3 279.1 935 278.9 934.7 278.8 934.4 278.6 Z" fill-opacity="1" fill="url(#radial-gradient)" stroke="none"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 117 KiB |
@@ -7,18 +7,103 @@ const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const sharp = require('sharp');
|
||||
const esbuild = require('esbuild');
|
||||
const htmlnano = require('htmlnano');
|
||||
const { optimize: svgoOptimize } = require('svgo');
|
||||
|
||||
// CSS minifier: simple regex-based (proven, 27% reduction, no deps)
|
||||
function minifyCSS(code) {
|
||||
return code
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\s*([{}:;,])\s*/g, '$1')
|
||||
// Never strip whitespace before `:`: `.parent :is(...)` is a
|
||||
// descendant selector, while `.parent:is(...)` targets the parent.
|
||||
.replace(/\s*([{},;])\s*/g, '$1')
|
||||
.replace(/:\s+/g, ':')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/;\}/g, '}')
|
||||
.trim();
|
||||
}
|
||||
const MIN_AVIF_KB = 0;
|
||||
if (minifyCSS('.parent :is(.a, .b) { color: red; }') !== '.parent :is(.a,.b){color:red}') {
|
||||
throw new Error('CSS minifier must preserve descendant combinators before functional pseudo-classes');
|
||||
}
|
||||
// HTML only: parser-based comment removal and conservative whitespace collapse.
|
||||
// CSS, JS, JSON, and SVG keep their dedicated build steps below.
|
||||
const HTML_MINIFY_OPTIONS = Object.freeze({
|
||||
skipConfigLoading: true,
|
||||
removeComments: 'safe',
|
||||
collapseWhitespace: 'conservative'
|
||||
});
|
||||
const HTML_MINIFY_PRESET = Object.freeze({});
|
||||
|
||||
async function minifyHTML(html) {
|
||||
return (await htmlnano.process(html, HTML_MINIFY_OPTIONS, HTML_MINIFY_PRESET)).html;
|
||||
}
|
||||
|
||||
async function verifyHTMLMinifier() {
|
||||
const pre = '<pre> keep\n exactly </pre>';
|
||||
const jsonLd = '<script type="application/ld+json">{"name":"KoalaSync"}</script>';
|
||||
const conditional = '<!--[if IE]>legacy<![endif]-->';
|
||||
const result = await minifyHTML(`<!<!--nested-->--unsafe-->${pre}${jsonLd}${conditional}`);
|
||||
const ordinaryComments = result.replace(conditional, '');
|
||||
if (ordinaryComments.includes('<!--') || !result.includes(pre) || !result.includes(conditional)) {
|
||||
throw new Error('HTML minifier security/preservation invariant failed');
|
||||
}
|
||||
JSON.parse(result.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1]);
|
||||
}
|
||||
|
||||
// ── Country-flag font subset (committed artifact, validated here) ────────
|
||||
// The subset itself is generated by tools/subset-flag-font.mjs (Python
|
||||
// fontTools — the harfbuzz-based JS subsetters drop the COLR/CPAL color
|
||||
// tables and produce invisible flags, so the build never subsets itself).
|
||||
// This step verifies the committed subset still matches the site and ships
|
||||
// it under a content-hashed name.
|
||||
function stageFlagFontSubset(websiteDir, wwwDir) {
|
||||
const { collectFlagSourceFiles, extractUsedFlags, flagToCountryCode } = require('./flag-font-utils.cjs');
|
||||
const assetsDir = path.join(websiteDir, 'assets');
|
||||
const subsetPath = path.join(assetsDir, 'TwemojiCountryFlags.subset.woff2');
|
||||
const manifestPath = path.join(assetsDir, 'TwemojiCountryFlags.subset.json');
|
||||
const regenerate = 'run "npm run subset-flags" and commit the result';
|
||||
|
||||
if (!fs.existsSync(subsetPath) || !fs.existsSync(manifestPath)) {
|
||||
throw new Error(`Flag font subset or manifest missing – ${regenerate}`);
|
||||
}
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
const subset = fs.readFileSync(subsetPath);
|
||||
const sha256 = buf => crypto.createHash('sha256').update(buf).digest('hex');
|
||||
if (sha256(subset) !== manifest.subsetSha256) {
|
||||
throw new Error(`TwemojiCountryFlags.subset.woff2 does not match its manifest – ${regenerate}`);
|
||||
}
|
||||
const sourceFont = fs.readFileSync(path.join(assetsDir, 'TwemojiCountryFlags.woff2'));
|
||||
if (sha256(sourceFont) !== manifest.sourceFontSha256) {
|
||||
throw new Error(`TwemojiCountryFlags.woff2 changed since the subset was generated – ${regenerate}`);
|
||||
}
|
||||
|
||||
const usedFlags = extractUsedFlags(collectFlagSourceFiles(websiteDir));
|
||||
if (usedFlags.length === 0) {
|
||||
throw new Error('Flag extraction found no flag emoji – extraction is broken');
|
||||
}
|
||||
const usedCodes = usedFlags.map(flagToCountryCode);
|
||||
const missing = usedCodes.filter(c => !manifest.flags.includes(c));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Flags used on the site but missing from the font subset: ${missing.join(', ')} – ${regenerate}`);
|
||||
}
|
||||
const extra = manifest.flags.filter(c => !usedCodes.includes(c));
|
||||
if (extra.length > 0) {
|
||||
console.warn(` ⚠️ Subset contains unused flags (${extra.join(', ')}) – "npm run subset-flags" would shrink it.`);
|
||||
}
|
||||
|
||||
const name = `TwemojiCountryFlags.${sha8(subset)}.woff2`;
|
||||
const destAssets = path.join(wwwDir, 'assets');
|
||||
fs.mkdirSync(destAssets, { recursive: true });
|
||||
for (const f of fs.readdirSync(destAssets)) {
|
||||
if (/^TwemojiCountryFlags\.[0-9a-f]{8}\.woff2$/.test(f) && f !== name) {
|
||||
fs.unlinkSync(path.join(destAssets, f));
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(path.join(destAssets, name), subset);
|
||||
const pct = ((1 - subset.length / sourceFont.length) * 100).toFixed(0);
|
||||
console.log(` Flag font: ${name} (${(subset.length / 1024).toFixed(1)} KB, ${usedCodes.length} flags, -${pct}% vs unsubset)`);
|
||||
return name;
|
||||
}
|
||||
|
||||
function copyDirSync(src, dest) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
@@ -30,12 +115,13 @@ function copyDirSync(src, dest) {
|
||||
}
|
||||
|
||||
function sha8(buf) { return crypto.createHash('sha256').update(buf).digest('hex').slice(0, 8); }
|
||||
function sha384(buf) { return 'sha384-' + crypto.createHash('sha384').update(buf).digest('base64'); }
|
||||
|
||||
async function minifyJS(raw) {
|
||||
const result = await esbuild.transform(raw, {
|
||||
loader: 'js',
|
||||
minify: true,
|
||||
target: 'es2020'
|
||||
target: 'es2022'
|
||||
});
|
||||
return result.code;
|
||||
}
|
||||
@@ -50,7 +136,12 @@ function injectAvifPictures(html) {
|
||||
const srcsetMatch = attrs.match(/\bsrcset="([^"]*)"/i);
|
||||
if (srcsetMatch) {
|
||||
const avifSrcset = srcsetMatch[1].replace(/\.webp/gi, '.avif');
|
||||
return `<picture><source srcset="${avifSrcset}" type="image/avif"><img${attrs}></picture>`;
|
||||
// A <source> with width descriptors requires its own sizes
|
||||
// attribute; without it browsers may pick and even reject
|
||||
// candidates (observed as broken images in Chrome).
|
||||
const sizesMatch = attrs.match(/\bsizes="([^"]*)"/i);
|
||||
const sizesAttr = sizesMatch ? ` sizes="${sizesMatch[1]}"` : '';
|
||||
return `<picture><source srcset="${avifSrcset}"${sizesAttr} type="image/avif"><img${attrs}></picture>`;
|
||||
}
|
||||
return `<picture><source srcset="${avifSrc}" type="image/avif"><img${attrs}></picture>`;
|
||||
});
|
||||
@@ -67,6 +158,7 @@ function minifyInlineSvgs(html) {
|
||||
}
|
||||
|
||||
async function compile() {
|
||||
await verifyHTMLMinifier();
|
||||
console.log('Starting KoalaSync i18n compilation...');
|
||||
const websiteDir = __dirname;
|
||||
const wwwDir = path.join(websiteDir, 'www');
|
||||
@@ -117,27 +209,94 @@ async function compile() {
|
||||
}
|
||||
console.log(' ✓ Favicons/touch icons successfully synced to website/assets/');
|
||||
|
||||
// Social link preview image (og:image / twitter:image), same artwork as the
|
||||
// GitHub repository OpenGraph card. PNG kept for maximum scraper support.
|
||||
const ogSrc = path.join(websiteDir, '..', 'assets', 'StoreAssets', 'RepositoryOpenGraph.png');
|
||||
if (fs.existsSync(ogSrc)) {
|
||||
await sharp(ogSrc)
|
||||
.png({ quality: 80, palette: true })
|
||||
.toFile(path.join(targetAssetsDir, 'og-image.png'));
|
||||
console.log(' ✓ og-image.png generated from RepositoryOpenGraph.png');
|
||||
} else {
|
||||
console.warn(` ⚠️ Warning: ${ogSrc} not found. Skipping og-image generation.`);
|
||||
}
|
||||
|
||||
// ── 0.7 Stage the committed flag-font subset (~45% smaller) ──
|
||||
console.log('Staging flag font subset...');
|
||||
const flagFontName = stageFlagFontSubset(websiteDir, wwwDir);
|
||||
|
||||
// ── 1. Minify CSS/JS (must happen first so hashes go into HTML) ──
|
||||
console.log('Minifying CSS/JS...');
|
||||
const styleRaw = fs.readFileSync(path.join(websiteDir, 'style.css'), 'utf8');
|
||||
// CSS references the hashed subset instead of the raw font file
|
||||
const readStyle = relativePath => fs.readFileSync(path.join(websiteDir, relativePath), 'utf8')
|
||||
.replaceAll('TwemojiCountryFlags.woff2', flagFontName);
|
||||
const styleModules = [
|
||||
'styles/foundation.css',
|
||||
'styles/hero.css',
|
||||
'styles/landing-primary.css',
|
||||
'styles/legal.css',
|
||||
'styles/landing-controls.css',
|
||||
'styles/join-spinner.css',
|
||||
'styles/landing-sections.css',
|
||||
'styles/join.css',
|
||||
'styles/landing-faq.css',
|
||||
'styles/alternatives.css',
|
||||
'styles/demo.css'
|
||||
];
|
||||
// Keep the landing cascade equivalent to styleModules while excluding
|
||||
// utility-page-only rules. A single blocking file avoids both request
|
||||
// fan-out and layout shifts from activating structural CSS after paint.
|
||||
const landingBundles = [
|
||||
{
|
||||
key: 'landing',
|
||||
modules: [
|
||||
'styles/foundation.css',
|
||||
'styles/hero.css',
|
||||
'styles/landing-primary.css',
|
||||
'styles/legal.css',
|
||||
'styles/landing-controls.css',
|
||||
'styles/landing-sections.css',
|
||||
'styles/landing-selfhost.css',
|
||||
'styles/landing-faq.css',
|
||||
'styles/demo.css'
|
||||
]
|
||||
}
|
||||
];
|
||||
const styleRaw = styleModules.map(readStyle).join('');
|
||||
const styleMin = minifyCSS(styleRaw);
|
||||
const styleHash = sha8(styleMin);
|
||||
const styleName = `style.${styleHash}.min.css`;
|
||||
const styleSRI = sha384(styleMin);
|
||||
const compiledLandingBundles = landingBundles.map(bundle => {
|
||||
const raw = bundle.modules.map(readStyle).join('');
|
||||
const min = minifyCSS(raw);
|
||||
return {
|
||||
...bundle,
|
||||
min,
|
||||
name: `${bundle.key === 'landing' ? 'landing' : `landing-${bundle.key}`}.${sha8(min)}.min.css`,
|
||||
sri: sha384(min)
|
||||
};
|
||||
});
|
||||
|
||||
const appRaw = fs.readFileSync(path.join(websiteDir, 'app.js'), 'utf8');
|
||||
const appMin = await minifyJS(appRaw);
|
||||
const appHash = sha8(appMin);
|
||||
const appName = `app.${appHash}.min.js`;
|
||||
const appSRI = sha384(appMin);
|
||||
|
||||
const langRaw = fs.readFileSync(path.join(websiteDir, 'lang-init.js'), 'utf8');
|
||||
const langMin = await minifyJS(langRaw);
|
||||
const langHash = sha8(langMin);
|
||||
const langName = `lang-init.${langHash}.min.js`;
|
||||
const langSRI = sha384(langMin);
|
||||
|
||||
const stylePct = ((1 - styleMin.length / styleRaw.length) * 100).toFixed(0);
|
||||
const appPct = ((1 - appMin.length / appRaw.length) * 100).toFixed(0);
|
||||
const langPct = ((1 - langMin.length / langRaw.length) * 100).toFixed(0);
|
||||
console.log(` CSS: ${styleName} (${(styleMin.length/1024).toFixed(1)} KB, -${stylePct}%)`);
|
||||
for (const bundle of compiledLandingBundles) {
|
||||
console.log(` Landing ${bundle.key}: ${bundle.name} (${(bundle.min.length/1024).toFixed(1)} KB)`);
|
||||
}
|
||||
console.log(` App: ${appName} (${(appMin.length/1024).toFixed(1)} KB, -${appPct}%)`);
|
||||
console.log(` Lang: ${langName} (${(langMin.length/1024).toFixed(1)} KB, -${langPct}%)`);
|
||||
|
||||
@@ -151,6 +310,9 @@ async function compile() {
|
||||
|
||||
// Write minified files
|
||||
fs.writeFileSync(path.join(wwwDir, styleName), styleMin);
|
||||
for (const bundle of compiledLandingBundles) {
|
||||
fs.writeFileSync(path.join(wwwDir, bundle.name), bundle.min);
|
||||
}
|
||||
fs.writeFileSync(path.join(wwwDir, appName), appMin);
|
||||
fs.writeFileSync(path.join(wwwDir, langName), langMin);
|
||||
|
||||
@@ -174,13 +336,14 @@ async function compile() {
|
||||
let compiled = templateContent;
|
||||
compiled = compiled.replace(/\{\{ASSET_PATH\}\}/g, assetPath);
|
||||
compiled = compiled.replace(/\{\{VERSION\}\}/g, buildVersion);
|
||||
compiled = compiled.replace(/\{\{VERSION_DATE\}\}/g, versionJson.date || '');
|
||||
const langPrefix = lang === 'en' ? '' : `${lang}/`;
|
||||
compiled = compiled.replace(/\{\{LANG_PREFIX\}\}/g, langPrefix);
|
||||
languages.forEach(l => {
|
||||
compiled = compiled.replace(new RegExp(`\\{\\{SELECTED_${l.toUpperCase()}\\}\\}`, 'g'), l === lang ? 'selected' : '');
|
||||
});
|
||||
for (const [key, value] of Object.entries(locale)) {
|
||||
compiled = compiled.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
|
||||
compiled = compiled.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), () => value);
|
||||
}
|
||||
return compiled;
|
||||
}
|
||||
@@ -188,12 +351,24 @@ async function compile() {
|
||||
// Read alternative page templates
|
||||
const telepartyTemplatePath = path.join(websiteDir, 'alternatives/teleparty.html');
|
||||
const screenSharingTemplatePath = path.join(websiteDir, 'alternatives/screen-sharing.html');
|
||||
const watch2getherTemplatePath = path.join(websiteDir, 'alternatives/watch2gether.html');
|
||||
const scenerTemplatePath = path.join(websiteDir, 'alternatives/scener.html');
|
||||
const kosmiTemplatePath = path.join(websiteDir, 'alternatives/kosmi.html');
|
||||
const twosevenTemplatePath = path.join(websiteDir, 'alternatives/twoseven.html');
|
||||
const overviewTemplatePath = path.join(websiteDir, 'alternatives/index.html');
|
||||
const hasTelepartyTemplate = fs.existsSync(telepartyTemplatePath);
|
||||
const hasScreenSharingTemplate = fs.existsSync(screenSharingTemplatePath);
|
||||
const hasWatch2getherTemplate = fs.existsSync(watch2getherTemplatePath);
|
||||
const hasScenerTemplate = fs.existsSync(scenerTemplatePath);
|
||||
const hasKosmiTemplate = fs.existsSync(kosmiTemplatePath);
|
||||
const hasTwosevenTemplate = fs.existsSync(twosevenTemplatePath);
|
||||
const hasOverviewTemplate = fs.existsSync(overviewTemplatePath);
|
||||
const telepartyTemplate = hasTelepartyTemplate ? fs.readFileSync(telepartyTemplatePath, 'utf8') : '';
|
||||
const screenSharingTemplate = hasScreenSharingTemplate ? fs.readFileSync(screenSharingTemplatePath, 'utf8') : '';
|
||||
const watch2getherTemplate = hasWatch2getherTemplate ? fs.readFileSync(watch2getherTemplatePath, 'utf8') : '';
|
||||
const scenerTemplate = hasScenerTemplate ? fs.readFileSync(scenerTemplatePath, 'utf8') : '';
|
||||
const kosmiTemplate = hasKosmiTemplate ? fs.readFileSync(kosmiTemplatePath, 'utf8') : '';
|
||||
const twosevenTemplate = hasTwosevenTemplate ? fs.readFileSync(twosevenTemplatePath, 'utf8') : '';
|
||||
const overviewTemplate = hasOverviewTemplate ? fs.readFileSync(overviewTemplatePath, 'utf8') : '';
|
||||
|
||||
// Preload English locale for fallback
|
||||
@@ -207,14 +382,6 @@ async function compile() {
|
||||
compiled = compiled.replace(/\{\{LANG_PREFIX\}\}/g, langPrefix);
|
||||
compiled = compiled.replace(/\{\{VERSION\}\}/g, buildVersion);
|
||||
|
||||
const indexTitle = lang === 'de' ? 'KoalaSync - Vergleiche & Anleitungen' : 'KoalaSync - Alternatives & Comparisons';
|
||||
const indexDesc = lang === 'de'
|
||||
? 'Entdecke ehrliche Vergleiche und detaillierte Leitfäden zwischen KoalaSync und anderen Watch-Party-Erweiterungen.'
|
||||
: 'Explore honest comparisons and detailed guides comparing KoalaSync with other watch party extensions and streaming solutions.';
|
||||
|
||||
compiled = compiled.replace(/\{\{ALT_INDEX_TITLE\}\}/g, indexTitle);
|
||||
compiled = compiled.replace(/\{\{ALT_INDEX_META_DESC\}\}/g, indexDesc);
|
||||
|
||||
// og:locale mapping for Facebook
|
||||
const ogLocales = {
|
||||
en: 'en_US', de: 'de_DE', fr: 'fr_FR', es: 'es_ES',
|
||||
@@ -227,7 +394,7 @@ async function compile() {
|
||||
// Merge locale with English fallback for keys not present in current locale
|
||||
const mergedLocale = { ...englishLocale, ...locale };
|
||||
for (const [key, value] of Object.entries(mergedLocale)) {
|
||||
compiled = compiled.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
|
||||
compiled = compiled.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), () => value);
|
||||
}
|
||||
return compiled;
|
||||
}
|
||||
@@ -256,6 +423,30 @@ async function compile() {
|
||||
const ssCompiled = compileAlternativePage(screenSharingTemplate, locale, lang, '../', '');
|
||||
fs.writeFileSync(path.join(altDir, 'screen-sharing.html'), ssCompiled);
|
||||
}
|
||||
if (hasWatch2getherTemplate) {
|
||||
const altDir = path.join(wwwDir, 'alternatives');
|
||||
fs.mkdirSync(altDir, { recursive: true });
|
||||
const w2gCompiled = compileAlternativePage(watch2getherTemplate, locale, lang, '../', '');
|
||||
fs.writeFileSync(path.join(altDir, 'watch2gether.html'), w2gCompiled);
|
||||
}
|
||||
if (hasScenerTemplate) {
|
||||
const altDir = path.join(wwwDir, 'alternatives');
|
||||
fs.mkdirSync(altDir, { recursive: true });
|
||||
const scCompiled = compileAlternativePage(scenerTemplate, locale, lang, '../', '');
|
||||
fs.writeFileSync(path.join(altDir, 'scener.html'), scCompiled);
|
||||
}
|
||||
if (hasKosmiTemplate) {
|
||||
const altDir = path.join(wwwDir, 'alternatives');
|
||||
fs.mkdirSync(altDir, { recursive: true });
|
||||
const koCompiled = compileAlternativePage(kosmiTemplate, locale, lang, '../', '');
|
||||
fs.writeFileSync(path.join(altDir, 'kosmi.html'), koCompiled);
|
||||
}
|
||||
if (hasTwosevenTemplate) {
|
||||
const altDir = path.join(wwwDir, 'alternatives');
|
||||
fs.mkdirSync(altDir, { recursive: true });
|
||||
const tsCompiled = compileAlternativePage(twosevenTemplate, locale, lang, '../', '');
|
||||
fs.writeFileSync(path.join(altDir, 'twoseven.html'), tsCompiled);
|
||||
}
|
||||
if (hasOverviewTemplate) {
|
||||
const altDir = path.join(wwwDir, 'alternatives');
|
||||
fs.mkdirSync(altDir, { recursive: true });
|
||||
@@ -283,6 +474,30 @@ async function compile() {
|
||||
const ssCompiled = compileAlternativePage(screenSharingTemplate, locale, lang, '../../', lang + '/');
|
||||
fs.writeFileSync(path.join(langAltDir, 'screen-sharing.html'), ssCompiled);
|
||||
}
|
||||
if (hasWatch2getherTemplate) {
|
||||
const langAltDir = path.join(langDir, 'alternatives');
|
||||
fs.mkdirSync(langAltDir, { recursive: true });
|
||||
const w2gCompiled = compileAlternativePage(watch2getherTemplate, locale, lang, '../../', lang + '/');
|
||||
fs.writeFileSync(path.join(langAltDir, 'watch2gether.html'), w2gCompiled);
|
||||
}
|
||||
if (hasScenerTemplate) {
|
||||
const langAltDir = path.join(langDir, 'alternatives');
|
||||
fs.mkdirSync(langAltDir, { recursive: true });
|
||||
const scCompiled = compileAlternativePage(scenerTemplate, locale, lang, '../../', lang + '/');
|
||||
fs.writeFileSync(path.join(langAltDir, 'scener.html'), scCompiled);
|
||||
}
|
||||
if (hasKosmiTemplate) {
|
||||
const langAltDir = path.join(langDir, 'alternatives');
|
||||
fs.mkdirSync(langAltDir, { recursive: true });
|
||||
const koCompiled = compileAlternativePage(kosmiTemplate, locale, lang, '../../', lang + '/');
|
||||
fs.writeFileSync(path.join(langAltDir, 'kosmi.html'), koCompiled);
|
||||
}
|
||||
if (hasTwosevenTemplate) {
|
||||
const langAltDir = path.join(langDir, 'alternatives');
|
||||
fs.mkdirSync(langAltDir, { recursive: true });
|
||||
const tsCompiled = compileAlternativePage(twosevenTemplate, locale, lang, '../../', lang + '/');
|
||||
fs.writeFileSync(path.join(langAltDir, 'twoseven.html'), tsCompiled);
|
||||
}
|
||||
if (hasOverviewTemplate) {
|
||||
const langAltDir = path.join(langDir, 'alternatives');
|
||||
fs.mkdirSync(langAltDir, { recursive: true });
|
||||
@@ -302,7 +517,8 @@ async function compile() {
|
||||
{ src: 'impressum-de.html', dest: 'de/impressum.html' },
|
||||
{ src: 'datenschutz-de.html', dest: 'de/datenschutz.html' },
|
||||
{ src: 'impressum.html', dest: 'impressum.html' },
|
||||
{ src: 'datenschutz.html', dest: 'datenschutz.html' }
|
||||
{ src: 'datenschutz.html', dest: 'datenschutz.html' },
|
||||
{ src: '404.html', dest: '404.html' }
|
||||
];
|
||||
for (const mapping of staticMappings) {
|
||||
const src = path.join(websiteDir, mapping.src);
|
||||
@@ -314,20 +530,45 @@ async function compile() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. Copy generic static files ──
|
||||
const genericFiles = ['robots.txt', 'sitemap.xml', 'site.webmanifest', 'version.json'];
|
||||
// ── 5. Copy generic static files and verification files ──
|
||||
const genericFiles = ['robots.txt', 'site.webmanifest', 'version.json', 'llms.txt'];
|
||||
for (const file of genericFiles) {
|
||||
const src = path.join(websiteDir, file);
|
||||
const dest = path.join(wwwDir, file);
|
||||
if (fs.existsSync(src)) { fs.copyFileSync(src, dest); }
|
||||
}
|
||||
|
||||
// ── 5.5 Generate dynamic sitemap ──
|
||||
generateSitemap(wwwDir);
|
||||
|
||||
// Auto-copy Google verification files and IndexNow/txt key files from website source to www root
|
||||
const websiteFiles = fs.readdirSync(websiteDir);
|
||||
for (const file of websiteFiles) {
|
||||
const filePath = path.join(websiteDir, file);
|
||||
if (fs.statSync(filePath).isFile()) {
|
||||
// Match google[hex/alphanumeric].html or any hex/uuid txt file (e.g. 4650678bb36b488d8f64343a67f1b931.txt)
|
||||
const isGoogleVerification = /^google[a-zA-Z0-9_-]+\.html$/i.test(file);
|
||||
const isIndexNowVerification = (/^[a-zA-Z0-9_-]{8,}\.txt$/i.test(file) || /^[a-f0-9-]{32,36}$/i.test(file)) && file !== 'robots.txt';
|
||||
|
||||
if (isGoogleVerification || isIndexNowVerification) {
|
||||
const dest = path.join(wwwDir, file);
|
||||
fs.copyFileSync(filePath, dest);
|
||||
console.log(` Copied verification file: ${file} → ${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Copy assets ──
|
||||
console.log('Copying assets...');
|
||||
const srcAssets = path.join(websiteDir, 'assets');
|
||||
const destAssets = path.join(wwwDir, 'assets');
|
||||
if (fs.existsSync(srcAssets)) {
|
||||
copyDirSync(srcAssets, destAssets);
|
||||
// Pages use the hashed subset staged in step 0.7; ship neither the raw
|
||||
// font nor the unhashed subset artifacts
|
||||
fs.rmSync(path.join(destAssets, 'TwemojiCountryFlags.woff2'), { force: true });
|
||||
fs.rmSync(path.join(destAssets, 'TwemojiCountryFlags.subset.woff2'), { force: true });
|
||||
fs.rmSync(path.join(destAssets, 'TwemojiCountryFlags.subset.json'), { force: true });
|
||||
console.log(' Assets copied.');
|
||||
}
|
||||
|
||||
@@ -339,43 +580,80 @@ async function compile() {
|
||||
console.log(' ✓ Apple touch icons copied to www root.');
|
||||
}
|
||||
|
||||
// ── 7. Convert all WebP to AVIF (quality 70) ──
|
||||
console.log('Converting WebP → AVIF...');
|
||||
let avifCount = 0;
|
||||
const webpFiles = fs.readdirSync(destAssets).filter(f => f.endsWith('.webp'));
|
||||
for (const f of webpFiles) {
|
||||
const src = path.join(destAssets, f);
|
||||
const stat = fs.statSync(src);
|
||||
if (stat.size < MIN_AVIF_KB * 1024) continue;
|
||||
const dest = path.join(destAssets, f.replace(/\.webp$/, '.avif'));
|
||||
if (fs.existsSync(dest) && fs.statSync(dest).mtimeMs >= stat.mtimeMs) continue;
|
||||
await sharp(src).avif({ quality: 80, speed: 4 }).toFile(dest);
|
||||
avifCount++;
|
||||
}
|
||||
console.log(` ${avifCount} AVIF files generated.`);
|
||||
|
||||
// ── 8. Post-process ALL HTML files ──
|
||||
console.log('Post-processing HTML...');
|
||||
function walkHtml(dir, fn) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) { walkHtml(p, fn); }
|
||||
else if (entry.name.endsWith('.html')) { fn(p); }
|
||||
// ── 6.5 Responsive variants for the static-page mascots ──
|
||||
// These pages showed 434-500px sources at 180px CSS (2x need: 360px).
|
||||
// Variants feed the AVIF step below and the srcset attributes in the
|
||||
// static HTML files.
|
||||
console.log('Generating responsive mascot variants...');
|
||||
const RESPONSIVE_VARIANTS = [
|
||||
{ src: 'KoalaImprintl.webp', widths: [180, 360] },
|
||||
{ src: 'KoalaPrivacy.webp', widths: [180, 360] },
|
||||
{ src: 'KoalaSearching.webp', widths: [180, 360] }
|
||||
];
|
||||
for (const variant of RESPONSIVE_VARIANTS) {
|
||||
const variantSrc = path.join(destAssets, variant.src);
|
||||
if (!fs.existsSync(variantSrc)) {
|
||||
throw new Error(`Responsive variant source ${variant.src} not found in assets`);
|
||||
}
|
||||
for (const width of variant.widths) {
|
||||
const out = variantSrc.replace(/\.webp$/, `-${width}.webp`);
|
||||
await sharp(variantSrc).resize({ width }).toFile(out);
|
||||
}
|
||||
}
|
||||
|
||||
walkHtml(wwwDir, (filePath) => {
|
||||
// ── 7. Convert all WebP to AVIF (quality 65) ──
|
||||
// Content-hash cache: mtime comparison never hit because copyDirSync
|
||||
// refreshes mtimes, so every verify re-encoded all AVIFs.
|
||||
console.log('Converting WebP → AVIF...');
|
||||
const AVIF_OPTS = { quality: 65, speed: 4 };
|
||||
const avifCachePath = path.join(websiteDir, '.avif-cache.json');
|
||||
let avifCache = {};
|
||||
try { avifCache = JSON.parse(fs.readFileSync(avifCachePath, 'utf8')); } catch { /* cold cache */ }
|
||||
const avifCacheNext = {};
|
||||
let avifCount = 0, avifCachedCount = 0;
|
||||
const webpFiles = fs.readdirSync(destAssets).filter(f => f.endsWith('.webp'));
|
||||
for (const f of webpFiles) {
|
||||
const src = path.join(destAssets, f);
|
||||
const dest = path.join(destAssets, f.replace(/\.webp$/, '.avif'));
|
||||
const key = `${sha8(fs.readFileSync(src))}:q${AVIF_OPTS.quality}s${AVIF_OPTS.speed}`;
|
||||
avifCacheNext[f] = key;
|
||||
if (avifCache[f] === key && fs.existsSync(dest)) { avifCachedCount++; continue; }
|
||||
await sharp(src).avif(AVIF_OPTS).toFile(dest);
|
||||
avifCount++;
|
||||
}
|
||||
fs.writeFileSync(avifCachePath, JSON.stringify(avifCacheNext, null, 2) + '\n');
|
||||
console.log(` ${avifCount} AVIF files generated, ${avifCachedCount} unchanged (cache).`);
|
||||
|
||||
// ── 8. Post-process ALL HTML files ──
|
||||
console.log('Post-processing HTML...');
|
||||
async function walkHtml(dir, fn) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) { await walkHtml(p, fn); }
|
||||
else if (entry.name.endsWith('.html')) { await fn(p); }
|
||||
}
|
||||
}
|
||||
|
||||
await walkHtml(wwwDir, async (filePath) => {
|
||||
let html = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// 8a. Replace hashed asset refs
|
||||
html = html.replace(/href="((?:\.\.\/)*)style\.min\.css"/g, (m, prefix) => {
|
||||
return `href="${prefix}${styleName}"`;
|
||||
// 8a. Replace hashed asset refs and inject SRI (Subresource Integrity)
|
||||
// The prefix is either any number of "../" segments or a single "/"
|
||||
// (404.html must use absolute paths: it is served for arbitrary URLs).
|
||||
html = html.replace(/(<link\b[^>]*?\bhref=")((?:\.\.\/)*\/?)style\.min\.css"/g, (m, before, prefix) => {
|
||||
return `${before}${prefix}${styleName}" integrity="${styleSRI}" crossorigin="anonymous"`;
|
||||
});
|
||||
html = html.replace(/src="((?:\.\.\/)*)app\.min\.js"/g, (m, prefix) => {
|
||||
return `src="${prefix}${appName}"`;
|
||||
for (const bundle of compiledLandingBundles) {
|
||||
const placeholder = bundle.key === 'landing' ? 'landing.min.css' : `landing-${bundle.key}.min.css`;
|
||||
html = html.replace(new RegExp(`(<link\\b[^>]*?\\bhref=")((?:\\.\\.\\/)*\\/?)${placeholder.replaceAll('.', '\\.') }"`, 'g'), (m, before, prefix) => {
|
||||
return `${before}${prefix}${bundle.name}" integrity="${bundle.sri}" crossorigin="anonymous"`;
|
||||
});
|
||||
}
|
||||
html = html.replace(/(<script\b[^>]*?\bsrc=")((?:\.\.\/)*\/?)app\.min\.js"/g, (m, before, prefix) => {
|
||||
return `${before}${prefix}${appName}" integrity="${appSRI}" crossorigin="anonymous"`;
|
||||
});
|
||||
html = html.replace(/src="((?:\.\.\/)*)lang-init\.min\.js"/g, (m, prefix) => {
|
||||
return `src="${prefix}${langName}"`;
|
||||
html = html.replace(/(<script\b[^>]*?\bsrc=")((?:\.\.\/)*\/?)lang-init\.min\.js"/g, (m, before, prefix) => {
|
||||
return `${before}${prefix}${langName}" integrity="${langSRI}" crossorigin="anonymous"`;
|
||||
});
|
||||
|
||||
// 8b. Inject AVIF <picture> wrappers
|
||||
@@ -384,10 +662,119 @@ async function compile() {
|
||||
// 8c. Minify inline SVGs
|
||||
html = minifyInlineSvgs(html);
|
||||
|
||||
// 8d. Preload the subset flag font on pages that show flags. This
|
||||
// cuts the HTML→CSS→font chain; unicode-range alone would delay the
|
||||
// request until after CSS parse + layout.
|
||||
if (/[\u{1F1E6}-\u{1F1FF}]{2}/u.test(html)) {
|
||||
const relDir = path.relative(wwwDir, path.dirname(filePath));
|
||||
// 404.html is served for arbitrary URLs and must use absolute paths
|
||||
const prefix = path.basename(filePath) === '404.html'
|
||||
? '/'
|
||||
: (relDir ? '../'.repeat(relDir.split(path.sep).length) : '');
|
||||
const preload = `<link rel="preload" as="font" type="font/woff2" href="${prefix}assets/${flagFontName}" crossorigin>`;
|
||||
html = html.replace('</head>', `${preload}\n</head>`);
|
||||
}
|
||||
|
||||
// 8e. Minify HTML (comments + indentation)
|
||||
html = await minifyHTML(html);
|
||||
|
||||
fs.writeFileSync(filePath, html);
|
||||
});
|
||||
|
||||
console.log('KoalaSync build finished successfully! Output: website/www/');
|
||||
}
|
||||
|
||||
function generateSitemap(wwwDir) {
|
||||
const languages = [
|
||||
{ code: 'en', prefix: '', hreflang: 'en' },
|
||||
{ code: 'de', prefix: 'de/', hreflang: 'de' },
|
||||
{ code: 'fr', prefix: 'fr/', hreflang: 'fr' },
|
||||
{ code: 'es', prefix: 'es/', hreflang: 'es' },
|
||||
{ code: 'pt-BR', prefix: 'pt-BR/', hreflang: 'pt-br' },
|
||||
{ code: 'ru', prefix: 'ru/', hreflang: 'ru' },
|
||||
{ code: 'it', prefix: 'it/', hreflang: 'it' },
|
||||
{ code: 'pl', prefix: 'pl/', hreflang: 'pl' },
|
||||
{ code: 'tr', prefix: 'tr/', hreflang: 'tr' },
|
||||
{ code: 'nl', prefix: 'nl/', hreflang: 'nl' },
|
||||
{ code: 'ja', prefix: 'ja/', hreflang: 'ja' },
|
||||
{ code: 'ko', prefix: 'ko/', hreflang: 'ko' },
|
||||
{ code: 'zh', prefix: 'zh/', hreflang: 'zh' },
|
||||
{ code: 'uk', prefix: 'uk/', hreflang: 'uk' },
|
||||
{ code: 'pt', prefix: 'pt/', hreflang: 'pt' }
|
||||
];
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xhtml="http://www.w3.org/1999/xhtml">`;
|
||||
|
||||
// Static legal pages
|
||||
xml += `
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/imprint</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/privacy</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/de/impressum</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/de/datenschutz</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>`;
|
||||
|
||||
function addPage(relativePath, changefreq, priority) {
|
||||
for (const lang of languages) {
|
||||
const loc = `https://sync.koalastuff.net/${lang.prefix}${relativePath}`;
|
||||
xml += `
|
||||
<url>
|
||||
<loc>${loc}</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
<changefreq>${changefreq}</changefreq>
|
||||
<priority>${priority}</priority>`;
|
||||
for (const alt of languages) {
|
||||
const altHref = `https://sync.koalastuff.net/${alt.prefix}${relativePath}`;
|
||||
xml += `
|
||||
<xhtml:link rel="alternate" hreflang="${alt.hreflang}" href="${altHref}"/>`;
|
||||
}
|
||||
const defaultHref = `https://sync.koalastuff.net/${relativePath}`;
|
||||
xml += `
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="${defaultHref}"/>
|
||||
</url>`;
|
||||
}
|
||||
}
|
||||
|
||||
addPage('', 'weekly', '1.0');
|
||||
addPage('alternatives', 'weekly', '0.7');
|
||||
|
||||
const subpages = [
|
||||
'alternatives/teleparty',
|
||||
'alternatives/screen-sharing',
|
||||
'alternatives/watch2gether',
|
||||
'alternatives/scener',
|
||||
'alternatives/kosmi',
|
||||
'alternatives/twoseven'
|
||||
];
|
||||
for (const sub of subpages) {
|
||||
addPage(sub, 'weekly', '0.7');
|
||||
}
|
||||
|
||||
xml += `\n</urlset>\n`;
|
||||
|
||||
fs.writeFileSync(path.join(wwwDir, 'sitemap.xml'), xml.trim() + '\n', 'utf8');
|
||||
console.log(' ✓ Dynamically generated sitemap.xml with current date');
|
||||
}
|
||||
compile().catch(err => { console.error('Build failed:', err); process.exit(1); });
|
||||
|
||||
@@ -25,24 +25,103 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "Datenschutzerklärung",
|
||||
"url": "https://sync.koalastuff.net/de/datenschutz",
|
||||
"description": "Datenschutzerklärung von KoalaSync - Wir nehmen Datenschutz ernst. Erfahre mehr über unseren Ansatz ohne Tracking, ohne Logs und ohne Cookies.",
|
||||
"inLanguage": "de",
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/de/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="../lang-init.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-blobs">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
<div class="blob blob-3"></div>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="container nav-content">
|
||||
<a href="./" class="logo-area" style="text-decoration: none;">
|
||||
<img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<img src="../assets/NewLogoIcon_128.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
@@ -82,7 +161,7 @@
|
||||
<main class="legal-content">
|
||||
<div class="legal-card" data-reveal style="padding: 2rem;">
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<img src="../assets/KoalaPrivacy.webp" alt="Niedlicher Koala, der den Datenschutz repräsentiert" class="legal-mascot" width="180" height="180">
|
||||
<img src="../assets/KoalaPrivacy-180.webp" srcset="../assets/KoalaPrivacy-180.webp 180w, ../assets/KoalaPrivacy-360.webp 360w" sizes="180px" alt="Niedlicher Koala, der den Datenschutz repräsentiert" class="legal-mascot" width="180" height="180">
|
||||
</div>
|
||||
<h1>Datenschutz</h1>
|
||||
<p style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
|
||||
@@ -90,14 +169,28 @@
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>1. Hosting & Logfiles</h2>
|
||||
<h2>1. Kontakt</h2>
|
||||
<p>
|
||||
Diese Seite wird auf einem privaten Server gehostet. Zur Gewährleistung der Stabilität werden standardmäßige Server-Logs (IP, Browser, Zeit) erhoben, aber nicht mit Personen verknüpft und nach 7 Tagen automatisch gelöscht.
|
||||
KoalaSync wird von Timo Schmidt betrieben.<br>
|
||||
Für datenschutzbezogene Fragen: <span class="email-reveal" data-user="koalasync" data-domain="koalastuff.net" style="color: var(--accent); cursor: pointer; text-decoration: underline;">[E-Mail anzeigen]</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Keine Drittanbieter & Open Source</h2>
|
||||
<h2>2. Website-Hosting & Access Logs</h2>
|
||||
<p>
|
||||
KoalaSync wird auf Infrastruktur der Hetzner Online GmbH, Industriestr. 25, 91710 Gunzenhausen, Deutschland, gehostet. Der Serverstandort ist Finnland und damit innerhalb der EU/des EWR. Mit Hetzner besteht ein Auftragsverarbeitungsvertrag gemäß Art. 28 DSGVO.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Beim Aufruf dieser Website können Standard-Access-Logs Ihre IP-Adresse, Browser/User-Agent und Zeitpunkt enthalten. Diese Logs werden ausschließlich für Sicherheit, Stabilität und Missbrauchsprävention verwendet und nach 7 Tagen automatisch gelöscht.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO, berechtigtes Interesse am sicheren Betrieb der Website, an Stabilität und Missbrauchsprävention.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. Keine Drittanbieter & Open Source</h2>
|
||||
<p>
|
||||
KoalaSync verzichtet bewusst auf Analyse-Tools, Tracking-Cookies oder Werbenetzwerke. Wir laden keine Ressourcen von Drittanbietern (wie Google Fonts) nach, um Ihre Privatsphäre maximal zu schützen.
|
||||
</p>
|
||||
@@ -107,26 +200,41 @@
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. Relay-Server Architektur</h2>
|
||||
<h2>4. Offizieller Relay-Server & Raumdaten</h2>
|
||||
<p>
|
||||
Unser Relay-Server arbeitet ausschließlich im Arbeitsspeicher (RAM). Nachrichten zwischen Teilnehmern werden nicht auf Festplatten gespeichert und sind flüchtig. Sobald ein Raum geschlossen wird, werden alle zugehörigen Metadaten sofort gelöscht. Wir tracken Sie nicht. Wir überwachen lediglich unseren Server auf Basis von aggregierten, anonymen, nicht-personenbezogenen System-Leistungsmetriken.
|
||||
Der offizielle KoalaSync-Relay-Server führt keine persistenten Logs. Er speichert keine Raumhistorie, keinen Sync-Verlauf, keinen Wiedergabeverlauf, keine Raumnachrichten, keine Playback-Events, keine Tab-Titel und keine Medientitel auf Festplatte.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Zur Bereitstellung der Raumfunktion verarbeitet der Relay Raum- und Synchronisationsdaten temporär im Arbeitsspeicher, solange ein Raum aktiv ist. Dazu können Room-ID, Peer-ID, Anzeigename, Wiedergabestatus, Sync-Events und – abhängig von den Titel-Privatsphäre-Einstellungen – Tab-/Medientitel gehören. Diese Daten werden nur an Teilnehmer desselben Raums weitergeleitet und aus dem Arbeitsspeicher entfernt, wenn der Raum abläuft oder geschlossen wird.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO, berechtigtes Interesse an der Bereitstellung der gewünschten Synchronisationsfunktion, Sicherheit und Missbrauchsprävention.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Raum- und Peer-Zahlen sind aggregierte Betriebsmetriken zur Überwachung des Serverstatus.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4. Browser-Erweiterung (Extension)</h2>
|
||||
<h2>5. Browser-Erweiterung & lokaler Speicher</h2>
|
||||
<p>
|
||||
Um die geräteübergreifende Synchronisation zu ermöglichen, erfasst die KoalaSync Browser-Erweiterung temporär Daten des aktuell aktiven Video-Tabs (z. B. Tab-Titel, Medien-Metadaten wie den Videotitel sowie den Wiedergabestatus). Diese Daten werden ausschließlich zur Synchronisation an die anderen Teilnehmer in Ihrem Raum gesendet. Es wird ausdrücklich <strong>kein allgemeiner Browserverlauf (Browsing History)</strong> ausgelesen, gespeichert oder übermittelt.
|
||||
Zur Synchronisierung der Wiedergabe zwischen Teilnehmern desselben Raums erfasst die KoalaSync Browser-Erweiterung temporär Daten des aktuell aktiven Video-Tabs (z. B. Tab-Titel, Medien-Metadaten wie den Videotitel sowie den Wiedergabestatus). Tab- oder Medientitel können je nach Inhalt potenziell personenbezogen sein. Sie werden nur für die Synchronisationsfunktion verarbeitet, können über die Privatsphäre-Einstellungen der Erweiterung eingeschränkt oder deaktiviert werden, werden nur an Teilnehmer desselben Raums weitergeleitet und vom Relay nicht protokolliert oder dauerhaft gespeichert.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Die Erweiterung speichert nur für den Betrieb erforderliche Einstellungen lokal im Browser, z. B. Anzeigename, Server-URL, aktuelle Raumdaten, Sprache, Titel-Privatsphäre-Einstellungen, Benachrichtigungseinstellungen und Audio-Processing-Einstellungen. KoalaSync speichert keinen allgemeinen Browserverlauf und keinen persistenten Wiedergabeverlauf.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Die Erweiterung verbindet sich nur mit dem Relay-Server, während Sie sich aktiv in einem Raum befinden. Es wird keine dauerhafte Hintergrundverbindung aufrechterhalten, sodass Ihre IP-Adresse dem Server nicht offengelegt wird, wenn Sie die Erweiterung nicht nutzen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5. Berechtigungen der Erweiterung</h2>
|
||||
<h2>6. Berechtigungen der Erweiterung</h2>
|
||||
<p>
|
||||
Um ihre technischen Zweck zu erfüllen, benötigt die Browser-Erweiterung bestimmte Berechtigungen. Jede Berechtigung wird ausschließlich für die Kernfunktionalität genutzt:
|
||||
Um ihren technischen Zweck zu erfüllen, benötigt die Browser-Erweiterung bestimmte Berechtigungen. Jede Berechtigung wird ausschließlich für die Kernfunktionalität genutzt:
|
||||
</p>
|
||||
<ul style="margin-left: 1.5rem; margin-top: 0.5rem; color: var(--text-muted); font-size: 0.9rem; list-style-type: disc; display: flex; flex-direction: column; gap: 0.35rem;">
|
||||
<li><strong>storage</strong>: Erlaubt das lokale Speichern von Benutzernamen, Server-URLs und Raum-Zugangsdaten im Browser, damit Sie sich nicht jedes Mal neu anmelden müssen.</li>
|
||||
<li><strong>storage</strong>: Ermöglicht die lokale Speicherung von Anzeigename, Server-URL, Raumdaten und Einstellungen im Browser, damit diese zwischen Sitzungen erhalten bleiben.</li>
|
||||
<li><strong>tabs</strong>: Wird benötigt, um geöffnete Tabs im Dropdown der Erweiterung aufzulisten und deren Titel auszulesen, damit Sie bequem den richtigen Video-Tab auswählen können.</li>
|
||||
<li><strong>scripting</strong>: Erforderlich, um das Synchronisationsskript (content.js) sicher in den von Ihnen ausgewählten Videotab zu injizieren.</li>
|
||||
<li><strong>alarms</strong>: Verhindert, dass der Hintergrunddienst der Erweiterung (Service Worker) während einer aktiven Synchronisationssitzung vom Browser in den Ruhezustand versetzt wird.</li>
|
||||
@@ -137,18 +245,49 @@
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>6. Brute-Force Schutz</h2>
|
||||
<h2>7. Brute-Force & Rate-Limit-Schutz</h2>
|
||||
<p>
|
||||
Zur Sicherheit unserer Nutzer speichern wir fehlgeschlagene Login-Versuche (IP-Adresse und Raum-ID) für maximal 15 Minuten im RAM, um automatisierte Angriffe zu verhindern. Diese Daten werden danach rückstandslos gelöscht.
|
||||
Zur Missbrauchsprävention können fehlgeschlagene Beitritts-/Login-Versuche die IP-Adresse und Room-ID für bis zu 15 Minuten im Arbeitsspeicher halten. Diese Daten werden nicht auf Festplatte geschrieben, nicht öffentlich zugänglich gemacht und automatisch gelöscht. Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO, berechtigtes Interesse an der Verhinderung von Brute-Force-Angriffen und Missbrauch.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>7. Ihre Rechte</h2>
|
||||
<h2>8. Empfänger & Dritte</h2>
|
||||
<p>
|
||||
Sie haben das Recht auf Auskunft, Berichtigung oder Löschung Ihrer Daten. Da wir jedoch keine personenbezogenen Daten dauerhaft speichern, ist eine Zuordnung zu Ihrer Person in der Regel technisch nicht möglich.
|
||||
Technische Hosting- und Verbindungsdaten können durch Hetzner als Hosting-Anbieter im Auftrag des Betreibers verarbeitet werden. Raum-Synchronisationsdaten werden nur an Teilnehmer desselben Raums weitergeleitet. KoalaSync verwendet keine Analytics-Anbieter, Werbenetzwerke oder Tracking-Dienste.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>9. Erforderliche & optionale Daten</h2>
|
||||
<p>
|
||||
Die Bereitstellung technischer Verbindungsdaten ist erforderlich, um die Website aufzurufen und den Relay zu nutzen. Raumdaten und Sync-Events sind erforderlich, um die Raumsynchronisation zu verwenden. Das Teilen von Tab-/Medientitel ist optional und kann über die Privatsphäre-Einstellungen der Erweiterung eingeschränkt werden.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>10. Rechte der betroffenen Person</h2>
|
||||
<p>
|
||||
Nach der DSGVO können Ihnen Rechte auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Datenübertragbarkeit, Widerspruch sowie das Recht auf Beschwerde bei einer Aufsichtsbehörde zustehen.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Da KoalaSync keine Nutzerkonten verwendet und keine persistenten Raumlogs oder Wiedergabeverläufe speichert, sind viele Datenpunkte entweder kurzlebig oder nach ihrer Löschung keiner bestimmten Person mehr zuordenbar. KoalaSync erhebt oder speichert außerdem keine zusätzlichen Identifikationsdaten nur um Nutzer für Betroffenenanfragen zu identifizieren.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">Kontakt für Datenschutzanfragen: <span class="email-reveal" data-user="koalasync" data-domain="koalastuff.net" style="color: var(--accent); cursor: pointer; text-decoration: underline;">[E-Mail anzeigen]</span></p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>11. Kein Profiling & automatisierte Entscheidungsfindung</h2>
|
||||
<p>
|
||||
KoalaSync verwendet kein Profiling und keine automatisierte Entscheidungsfindung im Sinne von Art. 22 DSGVO.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>12. Drittlandstransfers</h2>
|
||||
<p>
|
||||
Das reguläre Hosting befindet sich innerhalb der EU/des EWR. Eine regelmäßige Übermittlung personenbezogener Daten in Drittländer findet nicht statt.
|
||||
</p>
|
||||
<p>Kontakt bei Fragen: <span class="email-reveal" data-user="koalasync_datenschutz" data-domain="koalamail.rocks" style="color: var(--accent); cursor: pointer; text-decoration: underline;">[E-Mail anzeigen]</span></p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
@@ -156,7 +295,7 @@
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. Open Source unter der MIT-Lizenz.</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">Keine Daten werden auf unseren Servern gespeichert. Reines RAM-basiertes Relay.</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">Keine Relay-Logs. Kein Tracking. Website-Access-Logs werden nach 7 Tagen gelöscht.</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="impressum" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="datenschutz" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, follow">
|
||||
<title>Redirecting...</title>
|
||||
<script>
|
||||
(function() {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Shared helpers for the country-flag font subset.
|
||||
* Used by build.cjs (validation) and tools/subset-flag-font.mjs (generation).
|
||||
*
|
||||
* Flags are regional-indicator pairs; tag-sequence flags (🏴 England etc.)
|
||||
* are NOT extracted or subset — if one is ever added it will render via the
|
||||
* system emoji font.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const FLAG_PAIR_RE = /[\u{1F1E6}-\u{1F1FF}]{2}/gu;
|
||||
|
||||
function collectFlagSourceFiles(websiteDir) {
|
||||
const files = [];
|
||||
for (const entry of fs.readdirSync(websiteDir)) {
|
||||
if (entry.endsWith('.html')) files.push(path.join(websiteDir, entry));
|
||||
}
|
||||
for (const dir of ['alternatives', 'locales']) {
|
||||
const p = path.join(websiteDir, dir);
|
||||
if (!fs.existsSync(p)) continue;
|
||||
for (const entry of fs.readdirSync(p)) {
|
||||
if (/\.(html|json)$/.test(entry)) files.push(path.join(p, entry));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function extractUsedFlags(files) {
|
||||
const flags = new Set();
|
||||
for (const file of files) {
|
||||
for (const m of fs.readFileSync(file, 'utf8').matchAll(FLAG_PAIR_RE)) {
|
||||
flags.add(m[0]);
|
||||
}
|
||||
}
|
||||
return [...flags].sort();
|
||||
}
|
||||
|
||||
// 🇩🇪 → "DE" (for readable manifests and tool output)
|
||||
function flagToCountryCode(flag) {
|
||||
return [...flag].map(c => String.fromCharCode(c.codePointAt(0) - 0x1F1E6 + 65)).join('');
|
||||
}
|
||||
|
||||
function countryCodeToFlag(code) {
|
||||
return [...code].map(c => String.fromCodePoint(0x1F1E6 + c.charCodeAt(0) - 65)).join('');
|
||||
}
|
||||
|
||||
module.exports = { FLAG_PAIR_RE, collectFlagSourceFiles, extractUsedFlags, flagToCountryCode, countryCodeToFlag };
|
||||
@@ -0,0 +1 @@
|
||||
google-site-verification: google157cb8453d13ccdc.html
|
||||